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