From 8220d653e7fde7eb1797a9a515b4fda5126ed76f Mon Sep 17 00:00:00 2001
From: Alexander Polyankin <alexander.polyankin@metabase.com>
Date: Fri, 18 Mar 2022 12:47:07 +0300
Subject: [PATCH] Highlight selected events in the query builder (#21038)

---
 .../src/metabase/query_builder/actions.js     | 83 +++++++++++--------
 .../components/VisualizationResult.jsx        |  4 +
 .../query_builder/components/view/View.jsx    | 14 ++--
 .../TimelineSidebar/TimelineSidebar.tsx       | 25 +++---
 .../query_builder/containers/QueryBuilder.jsx | 30 +++++--
 .../src/metabase/query_builder/reducers.js    | 47 ++++++++---
 .../src/metabase/query_builder/selectors.js   | 25 +++---
 .../components/EventCard/EventCard.styled.tsx | 15 +++-
 .../components/EventCard/EventCard.tsx        |  4 +-
 .../TimelineCard/TimelineCard.styled.tsx      |  2 +-
 .../components/TimelineCard/TimelineCard.tsx  | 15 +++-
 .../TimelineCard/TimelineCard.unit.spec.tsx   |  1 -
 .../components/TimelineList/TimelineList.tsx  | 12 +--
 .../TimelineList/TimelineList.unit.spec.tsx   |  2 -
 .../TimelinePanel/TimelinePanel.tsx           | 12 +--
 .../TimelinePanel/TimelinePanel.unit.spec.tsx |  2 -
 .../TimelinePanel/TimelinePanel.tsx           |  6 +-
 .../components/Visualization.jsx              |  6 +-
 .../lib/LineAreaBarPostRender.js              | 12 +++
 .../visualizations/lib/LineAreaBarRenderer.js |  6 ++
 .../metabase/visualizations/lib/timelines.js  | 42 +++++++---
 .../visualizations/line.cy.spec.js            |  4 -
 .../scenarios/question/timelines.cy.spec.js   | 10 +--
 23 files changed, 245 insertions(+), 134 deletions(-)

diff --git a/frontend/src/metabase/query_builder/actions.js b/frontend/src/metabase/query_builder/actions.js
index cf6cd6e901d..436fca365e4 100644
--- a/frontend/src/metabase/query_builder/actions.js
+++ b/frontend/src/metabase/query_builder/actions.js
@@ -1,32 +1,31 @@
 import { fetchAlertsForQuestion } from "metabase/alert/alert";
 
 /*global ace*/
-
 import { createAction } from "redux-actions";
 import _ from "underscore";
-import { getIn, assocIn, updateIn, merge } from "icepick";
+import { assocIn, getIn, merge, updateIn } from "icepick";
 import { t } from "ttag";
 
 import * as Urls from "metabase/lib/urls";
 
 import { createThunkAction } from "metabase/lib/redux";
 import { push, replace } from "react-router-redux";
-import { setErrorPage, openUrl } from "metabase/redux/app";
+import { openUrl, setErrorPage } from "metabase/redux/app";
 import { loadMetadataForQueries } from "metabase/redux/metadata";
 import { addUndo } from "metabase/redux/undo";
 
 import * as MetabaseAnalytics from "metabase/lib/analytics";
 import { startTimer } from "metabase/lib/performance";
 import {
-  loadCard,
-  startNewCard,
+  cleanCopyCard,
   deserializeCardFromUrl,
+  loadCard,
   serializeCardForUrl,
-  cleanCopyCard,
+  startNewCard,
 } from "metabase/lib/card";
 import { shouldOpenInBlankWindow } from "metabase/lib/dom";
 import * as Q_DEPRECATED from "metabase/lib/query";
-import { isSameField, isLocalField } from "metabase/lib/query/field_ref";
+import { isLocalField, isSameField } from "metabase/lib/query/field_ref";
 import { isAdHocModelQuestion } from "metabase/lib/data-modeling/utils";
 import Utils from "metabase/lib/utils";
 import { defer } from "metabase/lib/promise";
@@ -42,33 +41,34 @@ import { normalize } from "cljs/metabase.mbql.js";
 
 import {
   getCard,
-  getQuestion,
-  getOriginalQuestion,
-  getOriginalCard,
-  getIsEditing,
-  getTransformedSeries,
-  getRawSeries,
-  getResultsMetadata,
+  getDatasetEditorTab,
   getFirstQueryResult,
+  getIsEditing,
   getIsPreviewing,
-  getTableForeignKeys,
-  getQueryBuilderMode,
-  getPreviousQueryBuilderMode,
-  getDatasetEditorTab,
-  getIsShowingTemplateTagsEditor,
   getIsRunning,
+  getIsShowingTemplateTagsEditor,
   getNativeEditorCursorOffset,
   getNativeEditorSelectedText,
-  getSnippetCollectionId,
+  getNextRowPKValue,
+  getOriginalCard,
+  getOriginalQuestion,
+  getPreviousQueryBuilderMode,
+  getPreviousRowPKValue,
+  getQueryBuilderMode,
   getQueryResults,
+  getQuestion,
+  getRawSeries,
+  getResultsMetadata,
+  getSnippetCollectionId,
+  getTableForeignKeys,
+  getTimelines,
+  getTransformedSeries,
   getZoomedObjectId,
-  getPreviousRowPKValue,
-  getNextRowPKValue,
   isBasedOnExistingQuestion,
 } from "./selectors";
 import { trackNewQuestionSaved } from "./analytics";
 
-import { MetabaseApi, CardApi, UserApi, DashboardApi } from "metabase/services";
+import { CardApi, DashboardApi, MetabaseApi, UserApi } from "metabase/services";
 
 import { parse as urlParse } from "url";
 import querystring from "querystring";
@@ -81,17 +81,16 @@ import { getPersistableDefaultSettingsForSeries } from "metabase/visualizations/
 import Databases from "metabase/entities/databases";
 import Questions from "metabase/entities/questions";
 import Snippets from "metabase/entities/snippets";
-import Timelines from "metabase/entities/timelines";
 
 import { getMetadata } from "metabase/selectors/metadata";
 import { setRequestUnloaded } from "metabase/redux/requests";
 
 import {
   getCurrentQueryParams,
-  getURLForCardState,
-  getQueryBuilderModeFromLocation,
-  getPathNameFromQueryBuilderMode,
   getNextTemplateTagVisibilityState,
+  getPathNameFromQueryBuilderMode,
+  getQueryBuilderModeFromLocation,
+  getURLForCardState,
 } from "./utils";
 
 const PREVIEW_RESULT_LIMIT = 10;
@@ -802,10 +801,6 @@ export const loadMetadataForCard = card => (dispatch, getState) => {
   return dispatch(loadMetadataForQueries(queries));
 };
 
-export const loadTimelinesForCard = card => dispatch => {
-  dispatch(Timelines.actions.fetchList({ cardId: card.id, include: "events" }));
-};
-
 function hasNewColumns(question, queryResult) {
   // NOTE: this assume column names will change
   // technically this is wrong because you could add and remove two columns with the same name
@@ -1667,8 +1662,26 @@ export const setFieldMetadata = ({ field_ref, changes }) => (
   dispatch(setResultsMetadata(nextResultsMetadata));
 };
 
-export const SHOW_TIMELINE = "metabase/qb/SHOW_TIMELINE";
-export const showTimeline = createAction(SHOW_TIMELINE);
+export const SHOW_TIMELINES = "metabase/qb/SHOW_TIMELINES";
+export const showTimelines = createAction(SHOW_TIMELINES);
+
+export const HIDE_TIMELINES = "metabase/qb/HIDE_TIMELINES";
+export const hideTimelines = createAction(HIDE_TIMELINES);
+
+export const SELECT_TIMELINE_EVENTS = "metabase/qb/SELECT_TIMELINE_EVENTS";
+export const selectTimelineEvents = createAction(SELECT_TIMELINE_EVENTS);
 
-export const HIDE_TIMELINE = "metabase/qb/HIDE_TIMELINE";
-export const hideTimeline = createAction(HIDE_TIMELINE);
+export const DESELECT_TIMELINE_EVENTS = "metabase/qb/DESELECT_TIMELINE_EVENTS";
+export const deselectTimelineEvents = createAction(DESELECT_TIMELINE_EVENTS);
+
+export const showTimelinesForCollection = collectionId => (
+  dispatch,
+  getState,
+) => {
+  const availableTimelines = getTimelines(getState());
+  const collectionTimelines = collectionId
+    ? availableTimelines.filter(t => t.collection_id === collectionId)
+    : availableTimelines.filter(t => t.collection_id == null);
+
+  dispatch(showTimelines(collectionTimelines));
+};
diff --git a/frontend/src/metabase/query_builder/components/VisualizationResult.jsx b/frontend/src/metabase/query_builder/components/VisualizationResult.jsx
index eef4d237b97..f0df99cd9c0 100644
--- a/frontend/src/metabase/query_builder/components/VisualizationResult.jsx
+++ b/frontend/src/metabase/query_builder/components/VisualizationResult.jsx
@@ -41,6 +41,7 @@ export default class VisualizationResult extends Component {
       result,
       rawSeries,
       timelineEvents,
+      selectedTimelineEventIds,
       className,
     } = this.props;
     const { showCreateAlertModal } = this.state;
@@ -102,8 +103,11 @@ export default class VisualizationResult extends Component {
           showTitle={false}
           metadata={question.metadata()}
           timelineEvents={timelineEvents}
+          selectedTimelineEventIds={selectedTimelineEventIds}
           handleVisualizationClick={this.props.handleVisualizationClick}
           onOpenTimelines={this.props.onOpenTimelines}
+          onSelectTimelineEvents={this.props.selectTimelineEvents}
+          onDeselectTimelineEvents={this.props.deselectTimelineEvents}
           onOpenChartSettings={this.props.onOpenChartSettings}
           onUpdateWarnings={this.props.onUpdateWarnings}
           onUpdateVisualizationSettings={
diff --git a/frontend/src/metabase/query_builder/components/view/View.jsx b/frontend/src/metabase/query_builder/components/view/View.jsx
index 5ceb1261969..0b8648060c0 100644
--- a/frontend/src/metabase/query_builder/components/view/View.jsx
+++ b/frontend/src/metabase/query_builder/components/view/View.jsx
@@ -161,9 +161,10 @@ export default class View extends React.Component {
       isShowingFilterSidebar,
       isShowingTimelineSidebar,
       runQuestionQuery,
-      timelineVisibility,
-      showTimeline,
-      hideTimeline,
+      visibleTimelineIds,
+      selectedTimelineEventIds,
+      showTimelines,
+      hideTimelines,
       onOpenModal,
       onCloseSummary,
       onCloseFilter,
@@ -189,9 +190,10 @@ export default class View extends React.Component {
       return (
         <TimelineSidebar
           question={question}
-          visibility={timelineVisibility}
-          onShowTimeline={showTimeline}
-          onHideTimeline={hideTimeline}
+          visibleTimelineIds={visibleTimelineIds}
+          selectedTimelineEventIds={selectedTimelineEventIds}
+          onShowTimelines={showTimelines}
+          onHideTimelines={hideTimelines}
           onOpenModal={onOpenModal}
           onClose={onCloseTimelines}
         />
diff --git a/frontend/src/metabase/query_builder/components/view/sidebars/TimelineSidebar/TimelineSidebar.tsx b/frontend/src/metabase/query_builder/components/view/sidebars/TimelineSidebar/TimelineSidebar.tsx
index 4dfe92fd672..051debf060e 100644
--- a/frontend/src/metabase/query_builder/components/view/sidebars/TimelineSidebar/TimelineSidebar.tsx
+++ b/frontend/src/metabase/query_builder/components/view/sidebars/TimelineSidebar/TimelineSidebar.tsx
@@ -8,19 +8,21 @@ import { Timeline, TimelineEvent } from "metabase-types/api";
 
 export interface TimelineSidebarProps {
   question: Question;
-  visibility: Record<number, boolean>;
-  onShowTimeline?: (timeline: Timeline) => void;
-  onHideTimeline?: (timeline: Timeline) => void;
+  visibleTimelineIds: number[];
+  selectedTimelineEventIds: number[];
+  onShowTimelines?: (timelines: Timeline[]) => void;
+  onHideTimelines?: (timelines: Timeline[]) => void;
   onOpenModal?: (modal: string, modalContext?: unknown) => void;
   onClose?: () => void;
 }
 
 const TimelineSidebar = ({
   question,
-  visibility,
+  visibleTimelineIds,
+  selectedTimelineEventIds,
   onOpenModal,
-  onShowTimeline,
-  onHideTimeline,
+  onShowTimelines,
+  onHideTimelines,
   onClose,
 }: TimelineSidebarProps) => {
   const handleNewEvent = useCallback(() => {
@@ -41,21 +43,20 @@ const TimelineSidebar = ({
   const handleToggleTimeline = useCallback(
     (timeline: Timeline, isVisible: boolean) => {
       if (isVisible) {
-        onShowTimeline?.(timeline);
+        onShowTimelines?.([timeline]);
       } else {
-        onHideTimeline?.(timeline);
+        onHideTimelines?.([timeline]);
       }
     },
-    [onShowTimeline, onHideTimeline],
+    [onShowTimelines, onHideTimelines],
   );
 
   return (
     <SidebarContent title={t`Events`} onClose={onClose}>
       <TimelinePanel
-        cardId={question.id()}
+        visibleTimelineIds={visibleTimelineIds}
+        selectedEventIds={selectedTimelineEventIds}
         collectionId={question.collectionId()}
-        visibility={visibility}
-        isVisibleByDefault={question.isSaved()}
         onNewEvent={handleNewEvent}
         onNewEventWithTimeline={handleNewEventWithTimeline}
         onEditEvent={handleEditEvent}
diff --git a/frontend/src/metabase/query_builder/containers/QueryBuilder.jsx b/frontend/src/metabase/query_builder/containers/QueryBuilder.jsx
index b6a54b22418..188ac913261 100644
--- a/frontend/src/metabase/query_builder/containers/QueryBuilder.jsx
+++ b/frontend/src/metabase/query_builder/containers/QueryBuilder.jsx
@@ -7,6 +7,7 @@ import _ from "underscore";
 
 import Bookmark from "metabase/entities/bookmarks";
 import Collections from "metabase/entities/collections";
+import Timelines from "metabase/entities/timelines";
 import { MetabaseApi } from "metabase/services";
 import { getMetadata } from "metabase/selectors/metadata";
 import { getUser, getUserIsAdmin } from "metabase/selectors/user";
@@ -63,9 +64,10 @@ import {
   getNativeEditorCursorOffset,
   getNativeEditorSelectedText,
   getIsBookmarked,
-  getTimelineVisibility,
+  getVisibleTimelineIds,
   getVisibleTimelines,
   getVisibleTimelineEvents,
+  getSelectedTimelineEventIds,
 } from "../selectors";
 import * as actions from "../actions";
 
@@ -82,6 +84,11 @@ function autocompleteResults(card, prefix) {
   return apiCall;
 }
 
+const timelineProps = {
+  query: { include: "events" },
+  loadingAndErrorWrapper: false,
+};
+
 const mapStateToProps = (state, props) => {
   return {
     user: getUser(state, props),
@@ -109,9 +116,10 @@ const mapStateToProps = (state, props) => {
     query: getQuery(state),
     metadata: getMetadata(state),
 
-    timelines: getVisibleTimelines(state),
-    timelineEvents: getVisibleTimelineEvents(state),
-    timelineVisibility: getTimelineVisibility(state),
+    timelines: getVisibleTimelines(state, props),
+    timelineEvents: getVisibleTimelineEvents(state, props),
+    visibleTimelineIds: getVisibleTimelineIds(state, props),
+    selectedTimelineEventIds: getSelectedTimelineEventIds(state, props),
 
     result: getFirstQueryResult(state),
     results: getQueryResults(state),
@@ -166,7 +174,6 @@ const mapDispatchToProps = {
 
 function QueryBuilder(props) {
   const {
-    isBookmarked,
     question,
     location,
     params,
@@ -181,9 +188,11 @@ function QueryBuilder(props) {
     onChangeLocation,
     setUIControls,
     cancelQuery,
+    isBookmarked,
     createBookmark,
     deleteBookmark,
-    loadTimelinesForCard,
+    allLoaded,
+    showTimelinesForCollection,
   } = props;
 
   const forceUpdate = useForceUpdate();
@@ -194,6 +203,8 @@ function QueryBuilder(props) {
 
   const previousUIControls = usePrevious(uiControls);
   const previousLocation = usePrevious(location);
+  const hasQuestion = question != null;
+  const collectionId = question?.collectionId();
 
   const openModal = useCallback(
     (modal, modalContext) => setUIControls({ modal, modalContext }),
@@ -274,10 +285,10 @@ function QueryBuilder(props) {
   });
 
   useEffect(() => {
-    if (question && question.hasBreakoutByDateTime()) {
-      loadTimelinesForCard(question.card());
+    if (allLoaded && hasQuestion) {
+      showTimelinesForCollection(collectionId);
     }
-  }, [question, loadTimelinesForCard]);
+  }, [allLoaded, hasQuestion, collectionId, showTimelinesForCollection]);
 
   useEffect(() => {
     const { isShowingDataReference, isShowingTemplateTagsEditor } = uiControls;
@@ -326,6 +337,7 @@ function QueryBuilder(props) {
 
 export default _.compose(
   Bookmark.loadList(),
+  Timelines.loadList(timelineProps),
   connect(mapStateToProps, mapDispatchToProps),
   title(({ card }) => card?.name ?? t`Question`),
   titleWithLoadingTime("queryStartTime"),
diff --git a/frontend/src/metabase/query_builder/reducers.js b/frontend/src/metabase/query_builder/reducers.js
index ec19b5df0a8..21fd03b8d73 100644
--- a/frontend/src/metabase/query_builder/reducers.js
+++ b/frontend/src/metabase/query_builder/reducers.js
@@ -1,6 +1,7 @@
 import Utils from "metabase/lib/utils";
 import { handleActions } from "redux-actions";
 import { assoc, dissoc, merge } from "icepick";
+import _ from "underscore";
 
 import {
   RESET_QB,
@@ -56,8 +57,10 @@ import {
   onCloseQuestionHistory,
   onOpenTimelines,
   onCloseTimelines,
-  SHOW_TIMELINE,
-  HIDE_TIMELINE,
+  SHOW_TIMELINES,
+  HIDE_TIMELINES,
+  SELECT_TIMELINE_EVENTS,
+  DESELECT_TIMELINE_EVENTS,
 } from "./actions";
 
 const DEFAULT_UI_CONTROLS = {
@@ -500,16 +503,40 @@ export const currentState = handleActions(
   null,
 );
 
-export const timelineVisibility = handleActions(
+export const visibleTimelineIds = handleActions(
   {
-    [INITIALIZE_QB]: { next: () => ({}) },
-    [SHOW_TIMELINE]: {
-      next: (state, { payload }) => assoc(state, payload.id, true),
+    [INITIALIZE_QB]: { next: () => [] },
+    [SHOW_TIMELINES]: {
+      next: (state, { payload: timelines }) => [
+        ...state,
+        ...timelines.map(t => t.id),
+      ],
     },
-    [HIDE_TIMELINE]: {
-      next: (state, { payload }) => assoc(state, payload.id, false),
+    [HIDE_TIMELINES]: {
+      next: (state, { payload: timelines }) =>
+        _.without(state, ...timelines.map(t => t.id)),
     },
-    [RESET_QB]: { next: () => ({}) },
+    [RESET_QB]: { next: () => [] },
   },
-  {},
+  [],
+);
+
+export const selectedTimelineEventIds = handleActions(
+  {
+    [INITIALIZE_QB]: { next: () => [] },
+    [SELECT_TIMELINE_EVENTS]: {
+      next: (state, { payload: events = [] }) => events.map(e => e.id),
+    },
+    [DESELECT_TIMELINE_EVENTS]: {
+      next: (state, { payload: events = [] }) =>
+        _.without(state, ...events.map(e => e.id)),
+    },
+    [HIDE_TIMELINES]: {
+      next: (state, { payload: timelines }) =>
+        _.without(state, ...timelines.flatMap(t => t.events.map(e => e.id))),
+    },
+    [onCloseTimelines]: { next: () => [] },
+    [RESET_QB]: { next: () => [] },
+  },
+  [],
 );
diff --git a/frontend/src/metabase/query_builder/selectors.js b/frontend/src/metabase/query_builder/selectors.js
index a7eb5e2adb8..5d414182788 100644
--- a/frontend/src/metabase/query_builder/selectors.js
+++ b/frontend/src/metabase/query_builder/selectors.js
@@ -49,7 +49,9 @@ export const getParameterValues = state => state.qb.parameterValues;
 export const getMetadataDiff = state => state.qb.metadataDiff;
 
 export const getEntities = state => state.entities;
-export const getTimelineVisibility = state => state.qb.timelineVisibility;
+export const getVisibleTimelineIds = state => state.qb.visibleTimelineIds;
+export const getSelectedTimelineEventIds = state =>
+  state.qb.selectedTimelineEventIds;
 
 const getRawQueryResults = state => state.qb.queryResults;
 
@@ -284,26 +286,19 @@ export const getQuestion = createSelector(
   },
 );
 
-export const getTimelines = createSelector(
-  [getEntities, getQuestion],
-  (entities, question) => {
-    if (!question) {
-      return [];
-    }
-
-    const entityQuery = { cardId: question.id(), include: "events" };
-    return Timelines.selectors.getList({ entities }, { entityQuery }) ?? [];
-  },
-);
+export const getTimelines = createSelector([getEntities], entities => {
+  const entityQuery = { include: "events" };
+  return Timelines.selectors.getList({ entities }, { entityQuery }) ?? [];
+});
 
 export const getVisibleTimelines = createSelector(
-  [getQuestion, getTimelines, getTimelineVisibility],
-  (question, timelines, visibility) => {
+  [getQuestion, getTimelines, getVisibleTimelineIds],
+  (question, timelines, timelineIds) => {
     if (!question) {
       return [];
     }
 
-    return timelines.filter(t => visibility[t.id] ?? question.isSaved());
+    return timelines.filter(t => timelineIds.includes(t.id));
   },
 );
 
diff --git a/frontend/src/metabase/timelines/questions/components/EventCard/EventCard.styled.tsx b/frontend/src/metabase/timelines/questions/components/EventCard/EventCard.styled.tsx
index c66bf7e7797..caf7912cae3 100644
--- a/frontend/src/metabase/timelines/questions/components/EventCard/EventCard.styled.tsx
+++ b/frontend/src/metabase/timelines/questions/components/EventCard/EventCard.styled.tsx
@@ -1,9 +1,18 @@
 import styled from "@emotion/styled";
-import Icon from "metabase/components/Icon";
 import { alpha, color } from "metabase/lib/colors";
+import Icon from "metabase/components/Icon";
+
+export interface CardRootProps {
+  isSelected?: boolean;
+}
 
-export const CardRoot = styled.div`
+export const CardRoot = styled.div<CardRootProps>`
   display: flex;
+  padding: 0.25rem 0.75rem;
+  border-left: 0.25rem solid
+    ${props => (props.isSelected ? color("brand") : "transparent")};
+  background-color: ${props =>
+    props.isSelected ? alpha("brand", 0.03) : "transparent"};
 `;
 
 export const CardIcon = styled(Icon)`
@@ -25,7 +34,7 @@ export const CardIconContainer = styled.div`
 
 export const CardBody = styled.div`
   flex: 1 1 auto;
-  padding: 0.25rem 0.75rem 0.5rem;
+  padding: 0.25rem 0.75rem 0;
 `;
 
 export const CardTitle = styled.div`
diff --git a/frontend/src/metabase/timelines/questions/components/EventCard/EventCard.tsx b/frontend/src/metabase/timelines/questions/components/EventCard/EventCard.tsx
index fe14b43f660..be01b7bd19d 100644
--- a/frontend/src/metabase/timelines/questions/components/EventCard/EventCard.tsx
+++ b/frontend/src/metabase/timelines/questions/components/EventCard/EventCard.tsx
@@ -20,6 +20,7 @@ import {
 export interface EventCardProps {
   event: TimelineEvent;
   collection: Collection;
+  isSelected?: boolean;
   onEdit?: (event: TimelineEvent) => void;
   onArchive?: (event: TimelineEvent) => void;
 }
@@ -27,6 +28,7 @@ export interface EventCardProps {
 const EventCard = ({
   event,
   collection,
+  isSelected,
   onEdit,
   onArchive,
 }: EventCardProps): JSX.Element => {
@@ -35,7 +37,7 @@ const EventCard = ({
   const creatorMessage = getCreatorMessage(event);
 
   return (
-    <CardRoot>
+    <CardRoot isSelected={isSelected}>
       <CardIconContainer>
         <CardIcon name={event.icon} />
       </CardIconContainer>
diff --git a/frontend/src/metabase/timelines/questions/components/TimelineCard/TimelineCard.styled.tsx b/frontend/src/metabase/timelines/questions/components/TimelineCard/TimelineCard.styled.tsx
index 71ea16b5136..e87733cf1ec 100644
--- a/frontend/src/metabase/timelines/questions/components/TimelineCard/TimelineCard.styled.tsx
+++ b/frontend/src/metabase/timelines/questions/components/TimelineCard/TimelineCard.styled.tsx
@@ -34,5 +34,5 @@ export const CardIcon = styled(Icon)`
 `;
 
 export const CardContent = styled.div`
-  margin: 1rem -0.75rem 1rem -0.5rem;
+  margin: 1rem -1.5rem 1rem -1.5rem;
 `;
diff --git a/frontend/src/metabase/timelines/questions/components/TimelineCard/TimelineCard.tsx b/frontend/src/metabase/timelines/questions/components/TimelineCard/TimelineCard.tsx
index 6f2406a2d46..8213e65dead 100644
--- a/frontend/src/metabase/timelines/questions/components/TimelineCard/TimelineCard.tsx
+++ b/frontend/src/metabase/timelines/questions/components/TimelineCard/TimelineCard.tsx
@@ -4,6 +4,7 @@ import React, {
   memo,
   useCallback,
   useState,
+  useEffect,
 } from "react";
 import _ from "underscore";
 import { parseTimestamp } from "metabase/lib/time";
@@ -21,7 +22,9 @@ import {
 export interface TimelineCardProps {
   timeline: Timeline;
   collection: Collection;
+  isDefault?: boolean;
   isVisible?: boolean;
+  selectedEventIds?: number[];
   onEditEvent?: (event: TimelineEvent) => void;
   onArchiveEvent?: (event: TimelineEvent) => void;
   onToggleTimeline?: (timeline: Timeline, isVisible: boolean) => void;
@@ -30,13 +33,16 @@ export interface TimelineCardProps {
 const TimelineCard = ({
   timeline,
   collection,
+  isDefault,
   isVisible,
+  selectedEventIds = [],
   onToggleTimeline,
   onEditEvent,
   onArchiveEvent,
 }: TimelineCardProps): JSX.Element => {
   const events = getEvents(timeline.events);
-  const [isExpanded, setIsExpanded] = useState(false);
+  const isEventSelected = events.some(e => selectedEventIds.includes(e.id));
+  const [isExpanded, setIsExpanded] = useState(isDefault || isEventSelected);
 
   const handleHeaderClick = useCallback(() => {
     setIsExpanded(isExpanded => !isExpanded);
@@ -53,6 +59,12 @@ const TimelineCard = ({
     event.stopPropagation();
   }, []);
 
+  useEffect(() => {
+    if (isEventSelected) {
+      setIsExpanded(isEventSelected);
+    }
+  }, [isEventSelected, selectedEventIds]);
+
   return (
     <CardRoot>
       <CardHeader onClick={handleHeaderClick}>
@@ -71,6 +83,7 @@ const TimelineCard = ({
               key={event.id}
               event={event}
               collection={collection}
+              isSelected={selectedEventIds.includes(event.id)}
               onEdit={onEditEvent}
               onArchive={onArchiveEvent}
             />
diff --git a/frontend/src/metabase/timelines/questions/components/TimelineCard/TimelineCard.unit.spec.tsx b/frontend/src/metabase/timelines/questions/components/TimelineCard/TimelineCard.unit.spec.tsx
index 19745e0aa45..cd1f1840c36 100644
--- a/frontend/src/metabase/timelines/questions/components/TimelineCard/TimelineCard.unit.spec.tsx
+++ b/frontend/src/metabase/timelines/questions/components/TimelineCard/TimelineCard.unit.spec.tsx
@@ -52,7 +52,6 @@ describe("TimelineCard", () => {
 const getProps = (opts?: Partial<TimelineCardProps>): TimelineCardProps => ({
   timeline: createMockTimeline(),
   collection: createMockCollection(),
-  isVisible: false,
   onEditEvent: jest.fn(),
   onArchiveEvent: jest.fn(),
   onToggleTimeline: jest.fn(),
diff --git a/frontend/src/metabase/timelines/questions/components/TimelineList/TimelineList.tsx b/frontend/src/metabase/timelines/questions/components/TimelineList/TimelineList.tsx
index 7cc86ead8cd..90bc790cf90 100644
--- a/frontend/src/metabase/timelines/questions/components/TimelineList/TimelineList.tsx
+++ b/frontend/src/metabase/timelines/questions/components/TimelineList/TimelineList.tsx
@@ -5,8 +5,8 @@ import TimelineCard from "metabase/timelines/questions/components/TimelineCard/T
 export interface TimelineListProps {
   timelines: Timeline[];
   collection: Collection;
-  visibility?: Record<number, boolean>;
-  isVisibleByDefault?: boolean;
+  visibleTimelineIds?: number[];
+  selectedEventIds?: number[];
   onEditEvent?: (event: TimelineEvent) => void;
   onArchiveEvent?: (event: TimelineEvent) => void;
   onToggleTimeline?: (timeline: Timeline, isVisible: boolean) => void;
@@ -15,8 +15,8 @@ export interface TimelineListProps {
 const TimelineList = ({
   timelines,
   collection,
-  visibility = {},
-  isVisibleByDefault = false,
+  visibleTimelineIds = [],
+  selectedEventIds = [],
   onEditEvent,
   onArchiveEvent,
   onToggleTimeline,
@@ -28,7 +28,9 @@ const TimelineList = ({
           key={timeline.id}
           timeline={timeline}
           collection={collection}
-          isVisible={visibility[timeline.id] ?? isVisibleByDefault}
+          isDefault={timelines.length === 1}
+          isVisible={visibleTimelineIds.includes(timeline.id)}
+          selectedEventIds={selectedEventIds}
           onToggleTimeline={onToggleTimeline}
           onEditEvent={onEditEvent}
           onArchiveEvent={onArchiveEvent}
diff --git a/frontend/src/metabase/timelines/questions/components/TimelineList/TimelineList.unit.spec.tsx b/frontend/src/metabase/timelines/questions/components/TimelineList/TimelineList.unit.spec.tsx
index e565d9d21f2..4a1bdc2d491 100644
--- a/frontend/src/metabase/timelines/questions/components/TimelineList/TimelineList.unit.spec.tsx
+++ b/frontend/src/metabase/timelines/questions/components/TimelineList/TimelineList.unit.spec.tsx
@@ -25,8 +25,6 @@ describe("TimelineList", () => {
 const getProps = (opts?: Partial<TimelineListProps>): TimelineListProps => ({
   timelines: [],
   collection: createMockCollection(),
-  visibility: {},
-  isVisibleByDefault: false,
   onToggleTimeline: jest.fn(),
   onEditEvent: jest.fn(),
   onArchiveEvent: jest.fn(),
diff --git a/frontend/src/metabase/timelines/questions/components/TimelinePanel/TimelinePanel.tsx b/frontend/src/metabase/timelines/questions/components/TimelinePanel/TimelinePanel.tsx
index 942500fef81..e1887d4a155 100644
--- a/frontend/src/metabase/timelines/questions/components/TimelinePanel/TimelinePanel.tsx
+++ b/frontend/src/metabase/timelines/questions/components/TimelinePanel/TimelinePanel.tsx
@@ -9,8 +9,8 @@ import { PanelRoot, PanelToolbar } from "./TimelinePanel.styled";
 export interface TimelinePanelProps {
   timelines: Timeline[];
   collection: Collection;
-  visibility?: Record<number, boolean>;
-  isVisibleByDefault?: boolean;
+  visibleTimelineIds?: number[];
+  selectedEventIds?: number[];
   onNewEvent?: () => void;
   onNewEventWithTimeline?: () => void;
   onEditEvent?: (event: TimelineEvent) => void;
@@ -21,8 +21,8 @@ export interface TimelinePanelProps {
 const TimelinePanel = ({
   timelines,
   collection,
-  visibility,
-  isVisibleByDefault,
+  visibleTimelineIds,
+  selectedEventIds,
   onNewEvent,
   onNewEventWithTimeline,
   onEditEvent,
@@ -43,8 +43,8 @@ const TimelinePanel = ({
         <TimelineList
           timelines={timelines}
           collection={collection}
-          visibility={visibility}
-          isVisibleByDefault={isVisibleByDefault}
+          visibleTimelineIds={visibleTimelineIds}
+          selectedEventIds={selectedEventIds}
           onToggleTimeline={onToggleTimeline}
           onEditEvent={onEditEvent}
           onArchiveEvent={onArchiveEvent}
diff --git a/frontend/src/metabase/timelines/questions/components/TimelinePanel/TimelinePanel.unit.spec.tsx b/frontend/src/metabase/timelines/questions/components/TimelinePanel/TimelinePanel.unit.spec.tsx
index 9c7bf0de94f..affb9e89b4c 100644
--- a/frontend/src/metabase/timelines/questions/components/TimelinePanel/TimelinePanel.unit.spec.tsx
+++ b/frontend/src/metabase/timelines/questions/components/TimelinePanel/TimelinePanel.unit.spec.tsx
@@ -47,8 +47,6 @@ describe("TimelinePanel", () => {
 const getProps = (opts?: Partial<TimelinePanelProps>): TimelinePanelProps => ({
   timelines: [],
   collection: createMockCollection(),
-  visibility: {},
-  isVisibleByDefault: false,
   onNewEvent: jest.fn(),
   onNewEventWithTimeline: jest.fn(),
   onEditEvent: jest.fn(),
diff --git a/frontend/src/metabase/timelines/questions/containers/TimelinePanel/TimelinePanel.tsx b/frontend/src/metabase/timelines/questions/containers/TimelinePanel/TimelinePanel.tsx
index 4b0e89dfe42..5de09c3b2ce 100644
--- a/frontend/src/metabase/timelines/questions/containers/TimelinePanel/TimelinePanel.tsx
+++ b/frontend/src/metabase/timelines/questions/containers/TimelinePanel/TimelinePanel.tsx
@@ -8,15 +8,11 @@ import { State } from "metabase-types/store";
 import TimelinePanel from "../../components/TimelinePanel";
 
 interface TimelinePanelProps {
-  cardId?: number;
   collectionId?: number;
 }
 
 const timelineProps = {
-  query: (state: State, props: TimelinePanelProps) => ({
-    cardId: props.cardId,
-    include: "events",
-  }),
+  query: { include: "events" },
 };
 
 const collectionProps = {
diff --git a/frontend/src/metabase/visualizations/components/Visualization.jsx b/frontend/src/metabase/visualizations/components/Visualization.jsx
index 1d9f9bc1f31..4b5c5e62ec8 100644
--- a/frontend/src/metabase/visualizations/components/Visualization.jsx
+++ b/frontend/src/metabase/visualizations/components/Visualization.jsx
@@ -82,7 +82,11 @@ export default class Visualization extends React.PureComponent {
     if (
       !isSameSeries(newProps.rawSeries, this.props.rawSeries) ||
       !Utils.equals(newProps.settings, this.props.settings) ||
-      !Utils.equals(newProps.timelineEvents, this.props.timelineEvents)
+      !Utils.equals(newProps.timelineEvents, this.props.timelineEvents) ||
+      !Utils.equals(
+        newProps.selectedTimelineEventIds,
+        this.props.selectedTimelineEventIds,
+      )
     ) {
       this.transform(newProps);
     }
diff --git a/frontend/src/metabase/visualizations/lib/LineAreaBarPostRender.js b/frontend/src/metabase/visualizations/lib/LineAreaBarPostRender.js
index fc35606b1da..343febd2b6d 100644
--- a/frontend/src/metabase/visualizations/lib/LineAreaBarPostRender.js
+++ b/frontend/src/metabase/visualizations/lib/LineAreaBarPostRender.js
@@ -392,20 +392,26 @@ function onRenderAddTimelineEvents(
   chart,
   {
     timelineEvents,
+    selectedTimelineEventIds,
     xDomain,
     xInterval,
     isTimeseries,
     onHoverChange,
     onOpenTimelines,
+    onSelectTimelineEvents,
+    onDeselectTimelineEvents,
   },
 ) {
   renderEvents(chart, {
     timelineEvents,
+    selectedTimelineEventIds,
     xDomain,
     xInterval,
     isTimeseries,
     onHoverChange,
     onOpenTimelines,
+    onSelectTimelineEvents,
+    onDeselectTimelineEvents,
   });
 }
 
@@ -415,6 +421,7 @@ function onRender(
   {
     datas,
     timelineEvents,
+    selectedTimelineEventIds,
     isSplitAxis,
     xDomain,
     xInterval,
@@ -425,6 +432,8 @@ function onRender(
     onGoalHover,
     onHoverChange,
     onOpenTimelines,
+    onSelectTimelineEvents,
+    onDeselectTimelineEvents,
   },
 ) {
   onRenderRemoveClipPath(chart);
@@ -447,11 +456,14 @@ function onRender(
   onRenderSetZeroGridLineClassName(chart);
   onRenderAddTimelineEvents(chart, {
     timelineEvents,
+    selectedTimelineEventIds,
     xDomain,
     xInterval,
     isTimeseries,
     onHoverChange,
     onOpenTimelines,
+    onSelectTimelineEvents,
+    onDeselectTimelineEvents,
   });
 }
 
diff --git a/frontend/src/metabase/visualizations/lib/LineAreaBarRenderer.js b/frontend/src/metabase/visualizations/lib/LineAreaBarRenderer.js
index 31b38377571..4078a2c5465 100644
--- a/frontend/src/metabase/visualizations/lib/LineAreaBarRenderer.js
+++ b/frontend/src/metabase/visualizations/lib/LineAreaBarRenderer.js
@@ -816,9 +816,12 @@ export default function lineAreaBar(element, props) {
     settings,
     series,
     timelineEvents,
+    selectedTimelineEventIds,
     onRender,
     onHoverChange,
     onOpenTimelines,
+    onSelectTimelineEvents,
+    onDeselectTimelineEvents,
   } = props;
 
   const warnings = {};
@@ -924,6 +927,7 @@ export default function lineAreaBar(element, props) {
   lineAndBarOnRender(parent, {
     datas,
     timelineEvents,
+    selectedTimelineEventIds,
     isSplitAxis: yAxisProps.isSplit,
     yAxisSplit: yAxisProps.yAxisSplit,
     xDomain: xAxisProps.xDomain,
@@ -934,6 +938,8 @@ export default function lineAreaBar(element, props) {
     onGoalHover,
     onHoverChange,
     onOpenTimelines,
+    onSelectTimelineEvents,
+    onDeselectTimelineEvents,
   });
 
   // only ordinal axis can display "null" values
diff --git a/frontend/src/metabase/visualizations/lib/timelines.js b/frontend/src/metabase/visualizations/lib/timelines.js
index d5a9769d939..8e88e78cd2a 100644
--- a/frontend/src/metabase/visualizations/lib/timelines.js
+++ b/frontend/src/metabase/visualizations/lib/timelines.js
@@ -71,7 +71,15 @@ function getEventAxis(xAxis, xDomain, xInterval, eventTicks) {
 
 function renderEventTicks(
   chart,
-  { eventAxis, eventGroups, onHoverChange, onOpenTimelines },
+  {
+    eventAxis,
+    eventGroups,
+    selectedEventIds,
+    onHoverChange,
+    onOpenTimelines,
+    onSelectTimelineEvents,
+    onDeselectTimelineEvents,
+  },
 ) {
   const svg = chart.svg();
   const brush = svg.select("g.brush");
@@ -86,6 +94,7 @@ function renderEventTicks(
     const [tickX] = getTranslateFromStyle(transformStyle);
     defaultTick.remove();
 
+    const isSelected = group.some(event => selectedEventIds.includes(event.id));
     const isOnlyOneEvent = group.length === 1;
     const iconName = isOnlyOneEvent ? group[0].icon : "star";
 
@@ -94,20 +103,22 @@ function renderEventTicks(
       : ICON_PATHS[iconName];
     const iconScale = iconName === "mail" ? 0.45 : 0.5;
 
-    const eventPointerLine = brush
+    const eventLine = brush
       .append("line")
       .attr("class", "event-line")
+      .classed("hover", isSelected)
       .attr("x1", tickX)
       .attr("x2", tickX)
       .attr("y1", "0")
       .attr("y2", brushHeight);
 
-    const eventIconContainer = eventAxis
+    const eventTick = eventAxis
       .append("g")
       .attr("class", "event-tick")
+      .classed("hover", isSelected)
       .attr("transform", transformStyle);
 
-    const eventIcon = eventIconContainer
+    const eventIcon = eventTick
       .append("path")
       .attr("class", "event-icon")
       .attr("d", iconPath)
@@ -118,7 +129,7 @@ function renderEventTicks(
       );
 
     if (!isOnlyOneEvent) {
-      eventIconContainer
+      eventTick
         .append("text")
         .text(group.length)
         .attr(
@@ -127,22 +138,27 @@ function renderEventTicks(
         );
     }
 
-    eventIconContainer
+    eventTick
       .on("mousemove", () => {
         onHoverChange({
           element: eventIcon.node(),
           timelineEvents: group,
         });
-        eventIconContainer.classed("hover", true);
-        eventPointerLine.classed("hover", true);
+        eventTick.classed("hover", true);
+        eventLine.classed("hover", true);
       })
       .on("mouseleave", () => {
         onHoverChange(null);
-        eventIconContainer.classed("hover", false);
-        eventPointerLine.classed("hover", false);
+        eventTick.classed("hover", isSelected);
+        eventLine.classed("hover", isSelected);
       })
       .on("click", () => {
         onOpenTimelines();
+        if (isSelected) {
+          onDeselectTimelineEvents(group);
+        } else {
+          onSelectTimelineEvents(group);
+        }
       });
   });
 }
@@ -151,11 +167,14 @@ export function renderEvents(
   chart,
   {
     timelineEvents = [],
+    selectedTimelineEventIds = [],
     xDomain,
     xInterval,
     isTimeseries,
     onHoverChange,
     onOpenTimelines,
+    onSelectTimelineEvents,
+    onDeselectTimelineEvents,
   },
 ) {
   const xAxis = getXAxis(chart);
@@ -174,8 +193,11 @@ export function renderEvents(
   renderEventTicks(chart, {
     eventAxis,
     eventGroups,
+    selectedEventIds: selectedTimelineEventIds,
     onHoverChange,
     onOpenTimelines,
+    onSelectTimelineEvents,
+    onDeselectTimelineEvents,
   });
 }
 
diff --git a/frontend/test/metabase-visual/visualizations/line.cy.spec.js b/frontend/test/metabase-visual/visualizations/line.cy.spec.js
index 9aa47ea262a..74039680b9e 100644
--- a/frontend/test/metabase-visual/visualizations/line.cy.spec.js
+++ b/frontend/test/metabase-visual/visualizations/line.cy.spec.js
@@ -229,12 +229,8 @@ describe("visual tests > visualizations > line", () => {
       },
     });
 
-    cy.findByLabelText("calendar icon").click();
-    cy.findByRole("checkbox").click();
     cy.findByLabelText("star icon").realHover();
     cy.findByText("RC1");
-    cy.findByText("RC2");
-
     cy.percySnapshot();
   });
 });
diff --git a/frontend/test/metabase/scenarios/question/timelines.cy.spec.js b/frontend/test/metabase/scenarios/question/timelines.cy.spec.js
index 6feea1f4af8..9192728d511 100644
--- a/frontend/test/metabase/scenarios/question/timelines.cy.spec.js
+++ b/frontend/test/metabase/scenarios/question/timelines.cy.spec.js
@@ -19,7 +19,7 @@ describe("scenarios > collections > timelines", () => {
       cy.findByLabelText("Date").type("10/20/2018");
       cy.button("Create").click();
 
-      cy.findByText("Our analytics events").click();
+      cy.findByText("Our analytics events");
       cy.findByText("RC1");
     });
 
@@ -37,7 +37,7 @@ describe("scenarios > collections > timelines", () => {
       cy.findByLabelText("Date").type("10/30/2018");
       cy.button("Create").click();
 
-      cy.findByText("Releases").click();
+      cy.findByText("Releases");
       cy.findByText("RC1");
       cy.findByText("RC2");
     });
@@ -50,7 +50,7 @@ describe("scenarios > collections > timelines", () => {
 
       cy.visit("/question/3");
       cy.findByLabelText("calendar icon").click();
-      cy.findByText("Releases").click();
+      cy.findByText("Releases");
       cy.findByLabelText("ellipsis icon").click();
       cy.findByText("Edit event").click();
 
@@ -71,7 +71,7 @@ describe("scenarios > collections > timelines", () => {
 
       cy.visit("/question/3");
       cy.findByLabelText("calendar icon").click();
-      cy.findByText("Releases").click();
+      cy.findByText("Releases");
       cy.findByLabelText("ellipsis icon").click();
       cy.findByText("Archive event").click();
       cy.findByText("RC1").should("not.exist");
@@ -102,7 +102,7 @@ describe("scenarios > collections > timelines", () => {
       cy.visit("/question/3");
 
       cy.findByLabelText("calendar icon").click();
-      cy.findByText("Releases").click();
+      cy.findByText("Releases");
       cy.findByText("Add an event").should("not.exist");
       cy.findByLabelText("ellipsis icon").should("not.exist");
     });
-- 
GitLab