From 6426f366da599dca80f0165da1e7dc040f9d2d4c Mon Sep 17 00:00:00 2001 From: Alexander Polyankin <alexander.polyankin@metabase.com> Date: Mon, 11 Apr 2022 13:57:47 +0300 Subject: [PATCH] Move events between timelines (#21557) --- frontend/src/metabase-types/api/timeline.ts | 2 + .../src/metabase/entities/timeline-events.js | 15 ++- frontend/src/metabase/entities/timelines.js | 33 +++++- frontend/src/metabase/lib/timelines.ts | 17 +++ frontend/src/metabase/lib/urls.js | 33 +++--- .../query_builder/components/QueryModals.jsx | 9 ++ .../TimelineSidebar/TimelineSidebar.tsx | 8 ++ .../src/metabase/query_builder/constants.js | 1 + .../src/metabase/query_builder/selectors.js | 12 +-- .../DeleteEventModal.styled.tsx | 11 -- .../DeleteEventModal/DeleteEventModal.tsx | 46 -------- .../DeleteTimelineModal.styled.tsx | 11 -- .../DeleteTimelineModal.tsx | 40 ------- .../EditEventModal/EditEventModal.styled.tsx | 18 ---- .../EditTimelineModal.styled.tsx | 18 ---- .../components/EventCard/EventCard.tsx | 23 ++-- .../EventCard/EventCard.unit.spec.tsx | 1 - .../components/EventList/EventList.tsx | 5 +- .../EventList/EventList.unit.spec.tsx | 2 - .../NewEventModal/NewEventModal.tsx | 66 ------------ .../NewEventModal/NewEventModal.unit.spec.tsx | 32 ------ .../NewTimelineModal.styled.tsx | 5 - .../components/TimelineCard/TimelineCard.tsx | 23 ++-- .../TimelineCard/TimelineCard.unit.spec.tsx | 2 - .../TimelineDetailsModal.tsx | 28 +++-- .../TimelineDetailsModal.unit.spec.tsx | 1 - .../TimelineEmptyState/TimelineEmptyState.tsx | 6 +- .../components/TimelineList/TimelineList.tsx | 5 +- .../TimelineListModal/TimelineListModal.tsx | 23 ++-- .../DeleteEventModal/DeleteEventModal.tsx | 29 ++--- .../DeleteTimelineModal.tsx | 23 ++-- .../EditEventModal/EditEventModal.tsx | 39 +++---- .../EditTimelineModal/EditTimelineModal.tsx | 28 +++-- .../MoveEventModal/MoveEventModal.tsx | 55 ++++++++++ .../containers/MoveEventModal/index.ts | 1 + .../NewEventModal/NewEventModal.tsx | 26 ++--- .../NewEventWithTimelineModal.tsx | 10 +- .../NewTimelineModal/NewTimelineModal.tsx | 16 +-- .../TimelineArchiveModal.tsx | 22 ++-- .../TimelineDetailsModal.tsx | 17 +-- .../TimelineIndexModal/TimelineIndexModal.tsx | 10 +- .../TimelineListArchiveModal.tsx | 13 ++- .../TimelineListModal/TimelineListModal.tsx | 13 ++- .../metabase/timelines/collections/routes.tsx | 8 ++ .../metabase/timelines/collections/types.ts | 4 - .../DeleteEventModal/DeleteEventModal.tsx | 41 +++++++ .../DeleteEventModal.unit.spec.tsx | 2 - .../components/DeleteEventModal/index.ts | 0 .../DeleteTimelineModal.tsx | 39 +++++++ .../components/DeleteTimelineModal/index.ts | 0 .../EditEventModal/EditEventModal.tsx | 41 ++++--- .../EditEventModal.unit.spec.tsx | 2 - .../components/EditEventModal/index.ts | 0 .../EditTimelineModal/EditTimelineModal.tsx | 31 +++--- .../EditTimelineModal.unit.spec.tsx | 6 +- .../components/EditTimelineModal/index.ts | 0 .../ModalBody/ModalBody.styled.tsx} | 2 +- .../common/components/ModalBody/ModalBody.tsx | 12 +++ .../common/components/ModalBody/index.ts | 1 + .../ModalDangerButton.styled.tsx} | 6 +- .../ModalDangerButton/ModalDangerButton.tsx | 20 ++++ .../components/ModalDangerButton/index.ts | 1 + .../ModalFooter/ModalFooter.styled.tsx | 12 +++ .../components/ModalFooter/ModalFooter.tsx | 16 +++ .../common/components/ModalFooter/index.ts | 1 + .../MoveEventModal/MoveEventModal.styled.tsx | 21 ++++ .../MoveEventModal/MoveEventModal.tsx | 67 ++++++++++++ .../MoveEventModal.unit.spec.tsx | 43 ++++++++ .../common/components/MoveEventModal/index.ts | 1 + .../NewEventModal/NewEventModal.tsx | 42 +++++--- .../components/NewEventModal/index.ts | 0 .../NewTimelineModal/NewTimelineModal.tsx | 11 +- .../NewTimelineModal.unit.spec.tsx | 0 .../components/NewTimelineModal/index.ts | 0 .../TimelinePicker/TimelinePicker.stories.tsx | 45 ++++++++ .../TimelinePicker/TimelinePicker.styled.tsx | 89 +++++++++++++++ .../TimelinePicker/TimelinePicker.tsx | 74 +++++++++++++ .../common/components/TimelinePicker/index.ts | 1 + .../EditEventModal/EditEventModal.tsx | 58 ---------- .../EditEventModal.unit.spec.tsx | 34 ------ .../components/EditEventModal/index.ts | 1 - .../components/EventCard/EventCard.tsx | 9 +- .../NewEventModal/NewEventModal.styled.tsx | 5 - .../NewEventModal/NewEventModal.unit.spec.tsx | 31 ------ .../components/NewEventModal/index.ts | 1 - .../components/TimelineCard/TimelineCard.tsx | 3 + .../components/TimelineList/TimelineList.tsx | 3 + .../TimelinePanel/TimelinePanel.tsx | 3 + .../EditEventModal/EditEventModal.tsx | 17 ++- .../MoveEventModal/MoveEventModal.tsx | 53 +++++++++ .../containers/MoveEventModal/index.ts | 1 + .../NewEventModal/NewEventModal.tsx | 15 ++- .../collections/timelines.cy.spec.js | 4 +- .../collections/timelines.cy.spec.js | 49 +++++++++ .../scenarios/question/timelines.cy.spec.js | 101 +++++++++++------- 95 files changed, 1094 insertions(+), 731 deletions(-) delete mode 100644 frontend/src/metabase/timelines/collections/components/DeleteEventModal/DeleteEventModal.styled.tsx delete mode 100644 frontend/src/metabase/timelines/collections/components/DeleteEventModal/DeleteEventModal.tsx delete mode 100644 frontend/src/metabase/timelines/collections/components/DeleteTimelineModal/DeleteTimelineModal.styled.tsx delete mode 100644 frontend/src/metabase/timelines/collections/components/DeleteTimelineModal/DeleteTimelineModal.tsx delete mode 100644 frontend/src/metabase/timelines/collections/components/EditEventModal/EditEventModal.styled.tsx delete mode 100644 frontend/src/metabase/timelines/collections/components/EditTimelineModal/EditTimelineModal.styled.tsx delete mode 100644 frontend/src/metabase/timelines/collections/components/NewEventModal/NewEventModal.tsx delete mode 100644 frontend/src/metabase/timelines/collections/components/NewEventModal/NewEventModal.unit.spec.tsx delete mode 100644 frontend/src/metabase/timelines/collections/components/NewTimelineModal/NewTimelineModal.styled.tsx create mode 100644 frontend/src/metabase/timelines/collections/containers/MoveEventModal/MoveEventModal.tsx create mode 100644 frontend/src/metabase/timelines/collections/containers/MoveEventModal/index.ts create mode 100644 frontend/src/metabase/timelines/common/components/DeleteEventModal/DeleteEventModal.tsx rename frontend/src/metabase/timelines/{collections => common}/components/DeleteEventModal/DeleteEventModal.unit.spec.tsx (92%) rename frontend/src/metabase/timelines/{collections => common}/components/DeleteEventModal/index.ts (100%) create mode 100644 frontend/src/metabase/timelines/common/components/DeleteTimelineModal/DeleteTimelineModal.tsx rename frontend/src/metabase/timelines/{collections => common}/components/DeleteTimelineModal/index.ts (100%) rename frontend/src/metabase/timelines/{collections => common}/components/EditEventModal/EditEventModal.tsx (57%) rename frontend/src/metabase/timelines/{collections => common}/components/EditEventModal/EditEventModal.unit.spec.tsx (94%) rename frontend/src/metabase/timelines/{collections => common}/components/EditEventModal/index.ts (100%) rename frontend/src/metabase/timelines/{collections => common}/components/EditTimelineModal/EditTimelineModal.tsx (63%) rename frontend/src/metabase/timelines/{collections => common}/components/EditTimelineModal/EditTimelineModal.unit.spec.tsx (87%) rename frontend/src/metabase/timelines/{collections => common}/components/EditTimelineModal/index.ts (100%) rename frontend/src/metabase/timelines/{collections/components/NewEventModal/NewEventModal.styled.tsx => common/components/ModalBody/ModalBody.styled.tsx} (61%) create mode 100644 frontend/src/metabase/timelines/common/components/ModalBody/ModalBody.tsx create mode 100644 frontend/src/metabase/timelines/common/components/ModalBody/index.ts rename frontend/src/metabase/timelines/{questions/components/EditEventModal/EditEventModal.styled.tsx => common/components/ModalDangerButton/ModalDangerButton.styled.tsx} (73%) create mode 100644 frontend/src/metabase/timelines/common/components/ModalDangerButton/ModalDangerButton.tsx create mode 100644 frontend/src/metabase/timelines/common/components/ModalDangerButton/index.ts create mode 100644 frontend/src/metabase/timelines/common/components/ModalFooter/ModalFooter.styled.tsx create mode 100644 frontend/src/metabase/timelines/common/components/ModalFooter/ModalFooter.tsx create mode 100644 frontend/src/metabase/timelines/common/components/ModalFooter/index.ts create mode 100644 frontend/src/metabase/timelines/common/components/MoveEventModal/MoveEventModal.styled.tsx create mode 100644 frontend/src/metabase/timelines/common/components/MoveEventModal/MoveEventModal.tsx create mode 100644 frontend/src/metabase/timelines/common/components/MoveEventModal/MoveEventModal.unit.spec.tsx create mode 100644 frontend/src/metabase/timelines/common/components/MoveEventModal/index.ts rename frontend/src/metabase/timelines/{questions => common}/components/NewEventModal/NewEventModal.tsx (68%) rename frontend/src/metabase/timelines/{collections => common}/components/NewEventModal/index.ts (100%) rename frontend/src/metabase/timelines/{collections => common}/components/NewTimelineModal/NewTimelineModal.tsx (85%) rename frontend/src/metabase/timelines/{collections => common}/components/NewTimelineModal/NewTimelineModal.unit.spec.tsx (100%) rename frontend/src/metabase/timelines/{collections => common}/components/NewTimelineModal/index.ts (100%) create mode 100644 frontend/src/metabase/timelines/common/components/TimelinePicker/TimelinePicker.stories.tsx create mode 100644 frontend/src/metabase/timelines/common/components/TimelinePicker/TimelinePicker.styled.tsx create mode 100644 frontend/src/metabase/timelines/common/components/TimelinePicker/TimelinePicker.tsx create mode 100644 frontend/src/metabase/timelines/common/components/TimelinePicker/index.ts delete mode 100644 frontend/src/metabase/timelines/questions/components/EditEventModal/EditEventModal.tsx delete mode 100644 frontend/src/metabase/timelines/questions/components/EditEventModal/EditEventModal.unit.spec.tsx delete mode 100644 frontend/src/metabase/timelines/questions/components/EditEventModal/index.ts delete mode 100644 frontend/src/metabase/timelines/questions/components/NewEventModal/NewEventModal.styled.tsx delete mode 100644 frontend/src/metabase/timelines/questions/components/NewEventModal/NewEventModal.unit.spec.tsx delete mode 100644 frontend/src/metabase/timelines/questions/components/NewEventModal/index.ts create mode 100644 frontend/src/metabase/timelines/questions/containers/MoveEventModal/MoveEventModal.tsx create mode 100644 frontend/src/metabase/timelines/questions/containers/MoveEventModal/index.ts diff --git a/frontend/src/metabase-types/api/timeline.ts b/frontend/src/metabase-types/api/timeline.ts index 2aa4437f669..c2db3554b0d 100644 --- a/frontend/src/metabase-types/api/timeline.ts +++ b/frontend/src/metabase-types/api/timeline.ts @@ -26,3 +26,5 @@ export interface TimelineEvent { creator: User; created_at: string; } + +export type TimelineEventSource = "question" | "collections" | "api"; diff --git a/frontend/src/metabase/entities/timeline-events.js b/frontend/src/metabase/entities/timeline-events.js index 8e31950a377..547e49f91ea 100644 --- a/frontend/src/metabase/entities/timeline-events.js +++ b/frontend/src/metabase/entities/timeline-events.js @@ -11,12 +11,21 @@ const TimelineEvents = createEntity({ forms, objectActions: { - setArchived: ({ id }, archived, opts) => - TimelineEvents.actions.update( + setTimeline: ({ id }, timeline, opts) => { + return TimelineEvents.actions.update( + { id }, + { timeline_id: timeline.id }, + undo(opts, t`event`, t`moved`), + ); + }, + + setArchived: ({ id }, archived, opts) => { + return TimelineEvents.actions.update( { id }, { archived }, undo(opts, t`event`, archived ? t`archived` : t`unarchived`), - ), + ); + }, }, }); diff --git a/frontend/src/metabase/entities/timelines.js b/frontend/src/metabase/entities/timelines.js index 87aa80221be..4ef0f84e9ed 100644 --- a/frontend/src/metabase/entities/timelines.js +++ b/frontend/src/metabase/entities/timelines.js @@ -45,16 +45,39 @@ const Timelines = createEntity({ reducer: (state = {}, action) => { if (action.type === TimelineEvents.actionTypes.CREATE) { const event = TimelineEvents.HACK_getObjectFromAction(action); - return updateIn(state, [event.timeline_id, "events"], (events = []) => { - return [...events, event.id]; + + return updateIn(state, [event.timeline_id, "events"], (eventIds = []) => { + return [...eventIds, event.id]; + }); + } + + if (action.type === TimelineEvents.actionTypes.UPDATE) { + const event = TimelineEvents.HACK_getObjectFromAction(action); + + return _.mapObject(state, timeline => { + const hasEvent = timeline.events?.includes(event.id); + const hasTimeline = event.timeline_id === timeline.id; + + return updateIn(timeline, ["events"], (eventIds = []) => { + if (hasEvent && !hasTimeline) { + return _.without(eventIds, event.id); + } else if (!hasEvent && hasTimeline) { + return [...eventIds, event.id]; + } else { + return eventIds; + } + }); }); } if (action.type === TimelineEvents.actionTypes.DELETE) { const eventId = action.payload.result; - return _.mapObject(state, timeline => - updateIn(timeline, ["events"], events => _.without(events, eventId)), - ); + + return _.mapObject(state, timeline => { + return updateIn(timeline, ["events"], (eventIds = []) => { + return _.without(eventIds, eventId); + }); + }); } return state; diff --git a/frontend/src/metabase/lib/timelines.ts b/frontend/src/metabase/lib/timelines.ts index 81207c91431..051881830c5 100644 --- a/frontend/src/metabase/lib/timelines.ts +++ b/frontend/src/metabase/lib/timelines.ts @@ -1,3 +1,4 @@ +import _ from "underscore"; import { t } from "ttag"; import { Collection, Timeline } from "metabase-types/api"; import { canonicalCollectionId } from "metabase/collections/utils"; @@ -37,3 +38,19 @@ export const getDefaultTimelineName = (collection: Collection) => { export const getDefaultTimelineIcon = () => { return "star"; }; + +export const getSortedTimelines = ( + timelines: Timeline[], + collection?: Collection, +) => { + return _.chain(timelines) + .sortBy(timeline => getTimelineName(timeline).toLowerCase()) + .sortBy(timeline => timeline.collection?.personal_owner_id != null) // personal collections last + .sortBy(timeline => !timeline.default) // default timelines first + .sortBy(timeline => timeline.collection?.id !== collection?.id) // timelines within the collection first + .value(); +}; + +export const getEventCount = ({ events = [], archived }: Timeline) => { + return events.filter(e => e.archived === archived).length; +}; diff --git a/frontend/src/metabase/lib/urls.js b/frontend/src/metabase/lib/urls.js index 7f1ff02e206..a044c40ad30 100644 --- a/frontend/src/metabase/lib/urls.js +++ b/frontend/src/metabase/lib/urls.js @@ -305,41 +305,46 @@ export function timelinesArchiveInCollection(collection) { return `${timelinesInCollection(collection)}/archive`; } -export function timelineInCollection(timeline, collection) { - return `${timelinesInCollection(collection)}/${timeline.id}`; +export function timelineInCollection(timeline) { + return `${timelinesInCollection(timeline.collection)}/${timeline.id}`; } export function newTimelineInCollection(collection) { return `${timelinesInCollection(collection)}/new`; } -export function editTimelineInCollection(timeline, collection) { - return `${timelineInCollection(timeline, collection)}/edit`; +export function editTimelineInCollection(timeline) { + return `${timelineInCollection(timeline)}/edit`; } -export function timelineArchiveInCollection(timeline, collection) { - return `${timelineInCollection(timeline, collection)}/archive`; +export function timelineArchiveInCollection(timeline) { + return `${timelineInCollection(timeline)}/archive`; } -export function deleteTimelineInCollection(timeline, collection) { - return `${timelineInCollection(timeline, collection)}/delete`; +export function deleteTimelineInCollection(timeline) { + return `${timelineInCollection(timeline)}/delete`; } -export function newEventInCollection(timeline, collection) { - return `${timelineInCollection(timeline, collection)}/events/new`; +export function newEventInCollection(timeline) { + return `${timelineInCollection(timeline)}/events/new`; } export function newEventAndTimelineInCollection(collection) { return `${timelinesInCollection(collection)}/new/events/new`; } -export function editEventInCollection(event, timeline, collection) { - const timelineUrl = timelineInCollection(timeline, collection); +export function editEventInCollection(event, timeline) { + const timelineUrl = timelineInCollection(timeline); return `${timelineUrl}/events/${event.id}/edit`; } -export function deleteEventInCollection(event, timeline, collection) { - const timelineUrl = timelineInCollection(timeline, collection); +export function moveEventInCollection(event, timeline) { + const timelineUrl = timelineInCollection(timeline); + return `${timelineUrl}/events/${event.id}/move`; +} + +export function deleteEventInCollection(event, timeline) { + const timelineUrl = timelineInCollection(timeline); return `${timelineUrl}/events/${event.id}/delete`; } diff --git a/frontend/src/metabase/query_builder/components/QueryModals.jsx b/frontend/src/metabase/query_builder/components/QueryModals.jsx index 370300ac33e..aef2a9da4ca 100644 --- a/frontend/src/metabase/query_builder/components/QueryModals.jsx +++ b/frontend/src/metabase/query_builder/components/QueryModals.jsx @@ -24,6 +24,7 @@ import NewDatasetModal from "metabase/query_builder/components/NewDatasetModal"; import EntityCopyModal from "metabase/entities/containers/EntityCopyModal"; import NewEventModal from "metabase/timelines/questions/containers/NewEventModal"; import EditEventModal from "metabase/timelines/questions/containers/EditEventModal"; +import MoveEventModal from "metabase/timelines/questions/containers/MoveEventModal"; export default class QueryModals extends React.Component { showAlertsAfterQuestionSaved = () => { @@ -233,6 +234,14 @@ export default class QueryModals extends React.Component { <Modal onClose={onCloseModal}> <EditEventModal eventId={modalContext} onClose={onCloseModal} /> </Modal> + ) : modal === MODAL_TYPES.MOVE_EVENT ? ( + <Modal onClose={onCloseModal}> + <MoveEventModal + eventId={modalContext} + collectionId={question.collectionId()} + onClose={onCloseModal} + /> + </Modal> ) : null; } } 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 eed7c7ddd80..f21a85842e1 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 @@ -45,6 +45,13 @@ const TimelineSidebar = ({ [onOpenModal], ); + const handleMoveEvent = useCallback( + (event: TimelineEvent) => { + onOpenModal?.(MODAL_TYPES.MOVE_EVENT, event.id); + }, + [onOpenModal], + ); + const handleToggleEvent = useCallback( (event: TimelineEvent, isSelected: boolean) => { if (isSelected) { @@ -76,6 +83,7 @@ const TimelineSidebar = ({ selectedEventIds={selectedTimelineEventIds} onNewEvent={handleNewEvent} onEditEvent={handleEditEvent} + onMoveEvent={handleMoveEvent} onToggleEvent={handleToggleEvent} onToggleTimeline={handleToggleTimeline} /> diff --git a/frontend/src/metabase/query_builder/constants.js b/frontend/src/metabase/query_builder/constants.js index 7ab1ee5c935..83955956270 100644 --- a/frontend/src/metabase/query_builder/constants.js +++ b/frontend/src/metabase/query_builder/constants.js @@ -16,6 +16,7 @@ export const MODAL_TYPES = { CAN_NOT_CREATE_MODEL: "can-not-create-model", NEW_EVENT: "new-event", EDIT_EVENT: "edit-event", + MOVE_EVENT: "move-event", }; export const SIDEBAR_SIZES = { diff --git a/frontend/src/metabase/query_builder/selectors.js b/frontend/src/metabase/query_builder/selectors.js index 2d27480f233..7a98f1ad6ec 100644 --- a/frontend/src/metabase/query_builder/selectors.js +++ b/frontend/src/metabase/query_builder/selectors.js @@ -27,7 +27,7 @@ import Timelines from "metabase/entities/timelines"; import { getMetadata } from "metabase/selectors/metadata"; import { getAlerts } from "metabase/alert/selectors"; import { parseTimestamp } from "metabase/lib/time"; -import { getTimelineName } from "metabase/lib/timelines"; +import { getSortedTimelines } from "metabase/lib/timelines"; import { getXValues, isTimeseries, @@ -666,18 +666,16 @@ export const getFetchedTimelines = createSelector([getEntities], entities => { export const getTransformedTimelines = createSelector( [getFetchedTimelines], timelines => { - return _.chain(timelines) - .map(timeline => + return getSortedTimelines( + timelines.map(timeline => updateIn(timeline, ["events"], (events = []) => _.chain(events) .map(event => updateIn(event, ["timestamp"], parseTimestamp)) .filter(event => !event.archived) .value(), ), - ) - .sortBy(getTimelineName) - .sortBy(timeline => timeline.collection?.personal_owner_id != null) // personal collections last - .value(); + ), + ); }, ); diff --git a/frontend/src/metabase/timelines/collections/components/DeleteEventModal/DeleteEventModal.styled.tsx b/frontend/src/metabase/timelines/collections/components/DeleteEventModal/DeleteEventModal.styled.tsx deleted file mode 100644 index 5ff5b4a4491..00000000000 --- a/frontend/src/metabase/timelines/collections/components/DeleteEventModal/DeleteEventModal.styled.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import styled from "@emotion/styled"; - -export const ModalBody = styled.div` - padding: 2rem; -`; - -export const ModalFooter = styled.div` - display: flex; - gap: 1rem; - justify-content: flex-end; -`; diff --git a/frontend/src/metabase/timelines/collections/components/DeleteEventModal/DeleteEventModal.tsx b/frontend/src/metabase/timelines/collections/components/DeleteEventModal/DeleteEventModal.tsx deleted file mode 100644 index e5a4bebee68..00000000000 --- a/frontend/src/metabase/timelines/collections/components/DeleteEventModal/DeleteEventModal.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import React, { useCallback } from "react"; -import { t } from "ttag"; -import Button from "metabase/core/components/Button"; -import ModalHeader from "metabase/timelines/common/components/ModalHeader"; -import { Collection, Timeline, TimelineEvent } from "metabase-types/api"; -import { ModalBody, ModalFooter } from "./DeleteEventModal.styled"; - -export interface DeleteEventModalProps { - event: TimelineEvent; - timeline: Timeline; - collection: Collection; - onSubmit: ( - event: TimelineEvent, - timeline: Timeline, - collection: Collection, - ) => void; - onCancel: () => void; - onClose?: () => void; -} - -const DeleteEventModal = ({ - event, - timeline, - collection, - onSubmit, - onCancel, - onClose, -}: DeleteEventModalProps): JSX.Element => { - const handleSubmit = useCallback(async () => { - await onSubmit(event, timeline, collection); - }, [event, timeline, collection, onSubmit]); - - return ( - <div> - <ModalHeader title={t`Delete ${event?.name}?`} onClose={onClose} /> - <ModalBody> - <ModalFooter> - <Button onClick={onCancel}>{t`Cancel`}</Button> - <Button danger onClick={handleSubmit}>{t`Delete`}</Button> - </ModalFooter> - </ModalBody> - </div> - ); -}; - -export default DeleteEventModal; diff --git a/frontend/src/metabase/timelines/collections/components/DeleteTimelineModal/DeleteTimelineModal.styled.tsx b/frontend/src/metabase/timelines/collections/components/DeleteTimelineModal/DeleteTimelineModal.styled.tsx deleted file mode 100644 index 5ff5b4a4491..00000000000 --- a/frontend/src/metabase/timelines/collections/components/DeleteTimelineModal/DeleteTimelineModal.styled.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import styled from "@emotion/styled"; - -export const ModalBody = styled.div` - padding: 2rem; -`; - -export const ModalFooter = styled.div` - display: flex; - gap: 1rem; - justify-content: flex-end; -`; diff --git a/frontend/src/metabase/timelines/collections/components/DeleteTimelineModal/DeleteTimelineModal.tsx b/frontend/src/metabase/timelines/collections/components/DeleteTimelineModal/DeleteTimelineModal.tsx deleted file mode 100644 index 2284dd22f7e..00000000000 --- a/frontend/src/metabase/timelines/collections/components/DeleteTimelineModal/DeleteTimelineModal.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import React, { useCallback } from "react"; -import { t } from "ttag"; -import Button from "metabase/core/components/Button/Button"; -import ModalHeader from "metabase/timelines/common/components/ModalHeader"; -import { Collection, Timeline } from "metabase-types/api"; -import { ModalBody, ModalFooter } from "./DeleteTimelineModal.styled"; - -export interface DeleteTimelineModalProps { - timeline: Timeline; - collection: Collection; - onSubmit: (timeline: Timeline, collection: Collection) => void; - onCancel: () => void; - onClose: () => void; -} - -const DeleteTimelineModal = ({ - timeline, - collection, - onSubmit, - onCancel, - onClose, -}: DeleteTimelineModalProps): JSX.Element => { - const handleSubmit = useCallback(async () => { - await onSubmit(timeline, collection); - }, [timeline, collection, onSubmit]); - - return ( - <div> - <ModalHeader title={t`Delete ${timeline?.name}?`} onClose={onClose} /> - <ModalBody> - <ModalFooter> - <Button onClick={onCancel}>{t`Cancel`}</Button> - <Button danger onClick={handleSubmit}>{t`Delete`}</Button> - </ModalFooter> - </ModalBody> - </div> - ); -}; - -export default DeleteTimelineModal; diff --git a/frontend/src/metabase/timelines/collections/components/EditEventModal/EditEventModal.styled.tsx b/frontend/src/metabase/timelines/collections/components/EditEventModal/EditEventModal.styled.tsx deleted file mode 100644 index 5d29609249e..00000000000 --- a/frontend/src/metabase/timelines/collections/components/EditEventModal/EditEventModal.styled.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import styled from "@emotion/styled"; -import { color } from "metabase/lib/colors"; -import Button from "metabase/core/components/Button/Button"; - -export const ModalBody = styled.div` - padding: 2rem; -`; - -export const ModalDangerButton = styled(Button)` - color: ${color("danger")}; - padding-left: 0; - padding-right: 0; - - &:hover { - color: ${color("danger")}; - background-color: transparent; - } -`; diff --git a/frontend/src/metabase/timelines/collections/components/EditTimelineModal/EditTimelineModal.styled.tsx b/frontend/src/metabase/timelines/collections/components/EditTimelineModal/EditTimelineModal.styled.tsx deleted file mode 100644 index 83837d6de31..00000000000 --- a/frontend/src/metabase/timelines/collections/components/EditTimelineModal/EditTimelineModal.styled.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import styled from "@emotion/styled"; -import { color } from "metabase/lib/colors"; -import Button from "metabase/core/components/Button"; - -export const ModalBody = styled.div` - padding: 2rem; -`; - -export const ModalDangerButton = styled(Button)` - color: ${color("danger")}; - padding-left: 0; - padding-right: 0; - - &:hover { - color: ${color("danger")}; - background-color: transparent; - } -`; diff --git a/frontend/src/metabase/timelines/collections/components/EventCard/EventCard.tsx b/frontend/src/metabase/timelines/collections/components/EventCard/EventCard.tsx index 947d264c4c9..9542ccc67d7 100644 --- a/frontend/src/metabase/timelines/collections/components/EventCard/EventCard.tsx +++ b/frontend/src/metabase/timelines/collections/components/EventCard/EventCard.tsx @@ -6,7 +6,7 @@ import { parseTimestamp } from "metabase/lib/time"; import { formatDateTimeWithUnit } from "metabase/lib/formatting"; import Link from "metabase/core/components/Link"; import EntityMenu from "metabase/components/EntityMenu"; -import { Collection, Timeline, TimelineEvent } from "metabase-types/api"; +import { Timeline, TimelineEvent } from "metabase-types/api"; import { CardAside, CardBody, @@ -24,7 +24,6 @@ import { export interface EventCardProps { event: TimelineEvent; timeline: Timeline; - collection: Collection; onArchive?: (event: TimelineEvent) => void; onUnarchive?: (event: TimelineEvent) => void; } @@ -32,21 +31,14 @@ export interface EventCardProps { const EventCard = ({ event, timeline, - collection, onArchive, onUnarchive, }: EventCardProps): JSX.Element => { - const menuItems = getMenuItems( - event, - timeline, - collection, - onArchive, - onUnarchive, - ); + const menuItems = getMenuItems(event, timeline, onArchive, onUnarchive); const dateMessage = getDateMessage(event); const creatorMessage = getCreatorMessage(event); const canEdit = timeline.collection?.can_write && !event.archived; - const editLink = Urls.editEventInCollection(event, timeline, collection); + const editLink = Urls.editEventInCollection(event, timeline); return ( <CardRoot> @@ -82,7 +74,6 @@ const EventCard = ({ const getMenuItems = ( event: TimelineEvent, timeline: Timeline, - collection: Collection, onArchive?: (event: TimelineEvent) => void, onUnarchive?: (event: TimelineEvent) => void, ) => { @@ -94,7 +85,11 @@ const getMenuItems = ( return [ { title: t`Edit event`, - link: Urls.editEventInCollection(event, timeline, collection), + link: Urls.editEventInCollection(event, timeline), + }, + { + title: t`Move event`, + link: Urls.moveEventInCollection(event, timeline), }, { title: t`Archive event`, @@ -109,7 +104,7 @@ const getMenuItems = ( }, { title: t`Delete event`, - link: Urls.deleteEventInCollection(event, timeline, collection), + link: Urls.deleteEventInCollection(event, timeline), }, ]; } diff --git a/frontend/src/metabase/timelines/collections/components/EventCard/EventCard.unit.spec.tsx b/frontend/src/metabase/timelines/collections/components/EventCard/EventCard.unit.spec.tsx index 1403cef4609..964d14c1609 100644 --- a/frontend/src/metabase/timelines/collections/components/EventCard/EventCard.unit.spec.tsx +++ b/frontend/src/metabase/timelines/collections/components/EventCard/EventCard.unit.spec.tsx @@ -106,7 +106,6 @@ describe("EventCard", () => { export const getProps = (opts?: Partial<EventCardProps>): EventCardProps => ({ event: createMockTimelineEvent(), timeline: createMockTimeline(), - collection: createMockCollection(), onArchive: jest.fn(), onUnarchive: jest.fn(), ...opts, diff --git a/frontend/src/metabase/timelines/collections/components/EventList/EventList.tsx b/frontend/src/metabase/timelines/collections/components/EventList/EventList.tsx index 4b78f08593a..ff71b5697e5 100644 --- a/frontend/src/metabase/timelines/collections/components/EventList/EventList.tsx +++ b/frontend/src/metabase/timelines/collections/components/EventList/EventList.tsx @@ -1,6 +1,6 @@ import React, { memo } from "react"; import { t } from "ttag"; -import { Collection, Timeline, TimelineEvent } from "metabase-types/api"; +import { Timeline, TimelineEvent } from "metabase-types/api"; import EventCard from "../EventCard"; import { ListFooter, @@ -15,7 +15,6 @@ import { export interface EventListProps { events: TimelineEvent[]; timeline: Timeline; - collection: Collection; onArchive?: (event: TimelineEvent) => void; onUnarchive?: (event: TimelineEvent) => void; } @@ -23,7 +22,6 @@ export interface EventListProps { const EventList = ({ events, timeline, - collection, onArchive, onUnarchive, }: EventListProps): JSX.Element => { @@ -34,7 +32,6 @@ const EventList = ({ key={event.id} event={event} timeline={timeline} - collection={collection} onArchive={onArchive} onUnarchive={onUnarchive} /> diff --git a/frontend/src/metabase/timelines/collections/components/EventList/EventList.unit.spec.tsx b/frontend/src/metabase/timelines/collections/components/EventList/EventList.unit.spec.tsx index 98b5e54b594..7116145b0c5 100644 --- a/frontend/src/metabase/timelines/collections/components/EventList/EventList.unit.spec.tsx +++ b/frontend/src/metabase/timelines/collections/components/EventList/EventList.unit.spec.tsx @@ -1,7 +1,6 @@ import React from "react"; import { render, screen } from "@testing-library/react"; import { - createMockCollection, createMockTimeline, createMockTimelineEvent, } from "metabase-types/api/mocks"; @@ -26,6 +25,5 @@ describe("EventList", () => { const getProps = (opts?: Partial<EventListProps>): EventListProps => ({ events: [], timeline: createMockTimeline(), - collection: createMockCollection(), ...opts, }); diff --git a/frontend/src/metabase/timelines/collections/components/NewEventModal/NewEventModal.tsx b/frontend/src/metabase/timelines/collections/components/NewEventModal/NewEventModal.tsx deleted file mode 100644 index 65ec2b34e79..00000000000 --- a/frontend/src/metabase/timelines/collections/components/NewEventModal/NewEventModal.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import React, { useCallback, useMemo } from "react"; -import { t } from "ttag"; -import { getDefaultTimezone } from "metabase/lib/time"; -import { getDefaultTimelineIcon } from "metabase/lib/timelines"; -import Form from "metabase/containers/Form"; -import forms from "metabase/entities/timeline-events/forms"; -import ModalHeader from "metabase/timelines/common/components/ModalHeader"; -import { Collection, Timeline, TimelineEvent } from "metabase-types/api"; -import { ModalBody } from "./NewEventModal.styled"; - -export interface NewEventModalProps { - timeline?: Timeline; - collection: Collection; - onSubmit: ( - values: Partial<TimelineEvent>, - collection: Collection, - timeline?: Timeline, - ) => void; - onCancel: (location: string) => void; - onClose?: () => void; -} - -const NewEventModal = ({ - timeline, - collection, - onSubmit, - onCancel, - onClose, -}: NewEventModalProps): JSX.Element => { - const form = useMemo(() => forms.details(), []); - - const initialValues = useMemo( - () => ({ - timeline_id: timeline?.id, - icon: timeline ? timeline.icon : getDefaultTimelineIcon(), - timezone: getDefaultTimezone(), - source: "collections", - time_matters: false, - }), - [timeline], - ); - - const handleSubmit = useCallback( - async (values: Partial<TimelineEvent>) => { - await onSubmit(values, collection, timeline); - }, - [timeline, collection, onSubmit], - ); - - return ( - <div> - <ModalHeader title={t`New event`} onClose={onClose} /> - <ModalBody> - <Form - form={form} - initialValues={initialValues} - isModal={true} - onSubmit={handleSubmit} - onClose={onCancel} - /> - </ModalBody> - </div> - ); -}; - -export default NewEventModal; diff --git a/frontend/src/metabase/timelines/collections/components/NewEventModal/NewEventModal.unit.spec.tsx b/frontend/src/metabase/timelines/collections/components/NewEventModal/NewEventModal.unit.spec.tsx deleted file mode 100644 index 8d943ba636a..00000000000 --- a/frontend/src/metabase/timelines/collections/components/NewEventModal/NewEventModal.unit.spec.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import React, { FormHTMLAttributes } from "react"; -import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { createMockCollection } from "metabase-types/api/mocks"; -import NewEventModal, { NewEventModalProps } from "./NewEventModal"; - -const FormMock = (props: FormHTMLAttributes<HTMLFormElement>) => ( - <form {...props}> - <button>Create</button> - </form> -); - -jest.mock("metabase/containers/Form", () => FormMock); - -describe("NewEventModal", () => { - it("should submit modal", () => { - const props = getProps(); - - render(<NewEventModal {...props} />); - userEvent.click(screen.getByText("Create")); - - expect(props.onSubmit).toHaveBeenCalled(); - }); -}); - -const getProps = (opts?: Partial<NewEventModalProps>): NewEventModalProps => ({ - collection: createMockCollection(), - onSubmit: jest.fn(), - onCancel: jest.fn(), - onClose: jest.fn(), - ...opts, -}); diff --git a/frontend/src/metabase/timelines/collections/components/NewTimelineModal/NewTimelineModal.styled.tsx b/frontend/src/metabase/timelines/collections/components/NewTimelineModal/NewTimelineModal.styled.tsx deleted file mode 100644 index 591d8b9a749..00000000000 --- a/frontend/src/metabase/timelines/collections/components/NewTimelineModal/NewTimelineModal.styled.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import styled from "@emotion/styled"; - -export const ModalBody = styled.div` - padding: 2rem; -`; diff --git a/frontend/src/metabase/timelines/collections/components/TimelineCard/TimelineCard.tsx b/frontend/src/metabase/timelines/collections/components/TimelineCard/TimelineCard.tsx index 837bce6d77c..c5914a62453 100644 --- a/frontend/src/metabase/timelines/collections/components/TimelineCard/TimelineCard.tsx +++ b/frontend/src/metabase/timelines/collections/components/TimelineCard/TimelineCard.tsx @@ -1,32 +1,30 @@ import React, { memo } from "react"; -import { t, msgid, ngettext } from "ttag"; +import { msgid, ngettext, t } from "ttag"; import * as Urls from "metabase/lib/urls"; -import { getTimelineName } from "metabase/lib/timelines"; +import { getEventCount, getTimelineName } from "metabase/lib/timelines"; import EntityMenu from "metabase/components/EntityMenu"; -import { Collection, Timeline } from "metabase-types/api"; +import { Timeline } from "metabase-types/api"; import { - CardCount, CardBody, + CardCount, CardDescription, CardIcon, + CardMenu, CardRoot, CardTitle, - CardMenu, } from "./TimelineCard.styled"; export interface TimelineCardProps { timeline: Timeline; - collection: Collection; onUnarchive?: (timeline: Timeline) => void; } const TimelineCard = ({ timeline, - collection, onUnarchive, }: TimelineCardProps): JSX.Element => { - const timelineUrl = Urls.timelineInCollection(timeline, collection); - const menuItems = getMenuItems(timeline, collection, onUnarchive); + const timelineUrl = Urls.timelineInCollection(timeline); + const menuItems = getMenuItems(timeline, onUnarchive); const eventCount = getEventCount(timeline); const hasDescription = Boolean(timeline.description); const hasMenuItems = menuItems.length > 0; @@ -59,13 +57,8 @@ const TimelineCard = ({ ); }; -const getEventCount = (timeline: Timeline) => { - return timeline.events ? timeline.events.filter(e => !e.archived).length : 0; -}; - const getMenuItems = ( timeline: Timeline, - collection: Collection, onUnarchive?: (timeline: Timeline) => void, ) => { if (!timeline.archived || !timeline.collection?.can_write) { @@ -79,7 +72,7 @@ const getMenuItems = ( }, { title: t`Delete timeline`, - link: Urls.deleteTimelineInCollection(timeline, collection), + link: Urls.deleteTimelineInCollection(timeline), }, ]; }; diff --git a/frontend/src/metabase/timelines/collections/components/TimelineCard/TimelineCard.unit.spec.tsx b/frontend/src/metabase/timelines/collections/components/TimelineCard/TimelineCard.unit.spec.tsx index c05a1292459..1f3a488c8f9 100644 --- a/frontend/src/metabase/timelines/collections/components/TimelineCard/TimelineCard.unit.spec.tsx +++ b/frontend/src/metabase/timelines/collections/components/TimelineCard/TimelineCard.unit.spec.tsx @@ -1,7 +1,6 @@ import React from "react"; import { render, screen } from "@testing-library/react"; import { - createMockCollection, createMockTimeline, createMockTimelineEvent, } from "metabase-types/api/mocks"; @@ -27,6 +26,5 @@ describe("TimelineCard", () => { const getProps = (opts?: Partial<TimelineCardProps>): TimelineCardProps => ({ timeline: createMockTimeline(), - collection: createMockCollection(), ...opts, }); diff --git a/frontend/src/metabase/timelines/collections/components/TimelineDetailsModal/TimelineDetailsModal.tsx b/frontend/src/metabase/timelines/collections/components/TimelineDetailsModal/TimelineDetailsModal.tsx index 18f5314d0ae..1e4054d4e08 100644 --- a/frontend/src/metabase/timelines/collections/components/TimelineDetailsModal/TimelineDetailsModal.tsx +++ b/frontend/src/metabase/timelines/collections/components/TimelineDetailsModal/TimelineDetailsModal.tsx @@ -9,7 +9,7 @@ import { useDebouncedValue } from "metabase/hooks/use-debounced-value"; import Icon from "metabase/components/Icon"; import EntityMenu from "metabase/components/EntityMenu"; import ModalHeader from "metabase/timelines/common/components/ModalHeader"; -import { Collection, Timeline, TimelineEvent } from "metabase-types/api"; +import { Timeline, TimelineEvent } from "metabase-types/api"; import SearchEmptyState from "../SearchEmptyState"; import EventList from "../EventList"; import TimelineEmptyState from "../TimelineEmptyState"; @@ -24,18 +24,16 @@ import { MenuItem } from "../../types"; export interface TimelineDetailsModalProps { timeline: Timeline; - collection: Collection; isArchive?: boolean; isOnlyTimeline?: boolean; onArchive?: (event: TimelineEvent) => void; onUnarchive?: (event: TimelineEvent) => void; onClose?: () => void; - onGoBack?: (timeline: Timeline, collection: Collection) => void; + onGoBack?: (timeline: Timeline) => void; } const TimelineDetailsModal = ({ timeline, - collection, isArchive = false, isOnlyTimeline = false, onArchive, @@ -56,12 +54,12 @@ const TimelineDetailsModal = ({ }, [timeline, searchText, isArchive]); const menuItems = useMemo(() => { - return getMenuItems(timeline, collection, isArchive, isOnlyTimeline); - }, [timeline, collection, isArchive, isOnlyTimeline]); + return getMenuItems(timeline, isArchive, isOnlyTimeline); + }, [timeline, isArchive, isOnlyTimeline]); const handleGoBack = useCallback(() => { - onGoBack?.(timeline, collection); - }, [timeline, collection, onGoBack]); + onGoBack?.(timeline); + }, [timeline, onGoBack]); const isNotEmpty = events.length > 0; const isSearching = searchText.length > 0; @@ -90,7 +88,7 @@ const TimelineDetailsModal = ({ {canWrite && !isArchive && ( <ModalToolbarLink className="Button" - to={Urls.newEventInCollection(timeline, collection)} + to={Urls.newEventInCollection(timeline)} >{t`Add an event`}</ModalToolbarLink> )} </ModalToolbar> @@ -100,14 +98,13 @@ const TimelineDetailsModal = ({ <EventList events={events} timeline={timeline} - collection={collection} onArchive={onArchive} onUnarchive={onUnarchive} /> ) : isArchive || isSearching ? ( <SearchEmptyState /> ) : ( - <TimelineEmptyState timeline={timeline} collection={collection} /> + <TimelineEmptyState timeline={timeline} /> )} </ModalBody> </ModalRoot> @@ -139,7 +136,6 @@ const isEventMatch = (event: TimelineEvent, searchText: string) => { const getMenuItems = ( timeline: Timeline, - collection: Collection, isArchive: boolean, isOnlyTimeline: boolean, ) => { @@ -149,11 +145,11 @@ const getMenuItems = ( items.push( { title: t`New timeline`, - link: Urls.newTimelineInCollection(collection), + link: Urls.newTimelineInCollection(timeline.collection), }, { title: t`Edit timeline details`, - link: Urls.editTimelineInCollection(timeline, collection), + link: Urls.editTimelineInCollection(timeline), }, ); } @@ -161,14 +157,14 @@ const getMenuItems = ( if (!isArchive) { items.push({ title: t`View archived events`, - link: Urls.timelineArchiveInCollection(timeline, collection), + link: Urls.timelineArchiveInCollection(timeline), }); } if (isOnlyTimeline) { items.push({ title: t`View archived timelines`, - link: Urls.timelinesArchiveInCollection(collection), + link: Urls.timelinesArchiveInCollection(timeline.collection), }); } diff --git a/frontend/src/metabase/timelines/collections/components/TimelineDetailsModal/TimelineDetailsModal.unit.spec.tsx b/frontend/src/metabase/timelines/collections/components/TimelineDetailsModal/TimelineDetailsModal.unit.spec.tsx index 5615364ecc7..c7eea9ce4f5 100644 --- a/frontend/src/metabase/timelines/collections/components/TimelineDetailsModal/TimelineDetailsModal.unit.spec.tsx +++ b/frontend/src/metabase/timelines/collections/components/TimelineDetailsModal/TimelineDetailsModal.unit.spec.tsx @@ -75,6 +75,5 @@ const getProps = ( opts?: Partial<TimelineDetailsModalProps>, ): TimelineDetailsModalProps => ({ timeline: createMockTimeline(), - collection: createMockCollection(), ...opts, }); diff --git a/frontend/src/metabase/timelines/collections/components/TimelineEmptyState/TimelineEmptyState.tsx b/frontend/src/metabase/timelines/collections/components/TimelineEmptyState/TimelineEmptyState.tsx index a1054d2d2d5..6a870cef7f7 100644 --- a/frontend/src/metabase/timelines/collections/components/TimelineEmptyState/TimelineEmptyState.tsx +++ b/frontend/src/metabase/timelines/collections/components/TimelineEmptyState/TimelineEmptyState.tsx @@ -22,7 +22,7 @@ import { export interface TimelineEmptyStateProps { timeline?: Timeline; - collection: Collection; + collection?: Collection; } const TimelineEmptyState = ({ @@ -31,11 +31,11 @@ const TimelineEmptyState = ({ }: TimelineEmptyStateProps): JSX.Element => { const date = moment(); const link = timeline - ? Urls.newEventInCollection(timeline, collection) + ? Urls.newEventInCollection(timeline) : Urls.newEventAndTimelineInCollection(collection); const canWrite = timeline ? timeline.collection?.can_write - : collection.can_write; + : collection?.can_write; return ( <EmptyStateRoot> diff --git a/frontend/src/metabase/timelines/collections/components/TimelineList/TimelineList.tsx b/frontend/src/metabase/timelines/collections/components/TimelineList/TimelineList.tsx index 703e8834c19..a4ccea87ae4 100644 --- a/frontend/src/metabase/timelines/collections/components/TimelineList/TimelineList.tsx +++ b/frontend/src/metabase/timelines/collections/components/TimelineList/TimelineList.tsx @@ -1,17 +1,15 @@ import React from "react"; -import { Collection, Timeline } from "metabase-types/api"; +import { Timeline } from "metabase-types/api"; import TimelineCard from "../TimelineCard"; import { ListRoot } from "./TimelineList.styled"; export interface TimelineListProps { timelines: Timeline[]; - collection: Collection; onUnarchive?: (timeline: Timeline) => void; } const TimelineList = ({ timelines, - collection, onUnarchive, }: TimelineListProps): JSX.Element => { return ( @@ -20,7 +18,6 @@ const TimelineList = ({ <TimelineCard key={timeline.id} timeline={timeline} - collection={collection} onUnarchive={onUnarchive} /> ))} diff --git a/frontend/src/metabase/timelines/collections/components/TimelineListModal/TimelineListModal.tsx b/frontend/src/metabase/timelines/collections/components/TimelineListModal/TimelineListModal.tsx index 60bf887a031..3cd27b1c016 100644 --- a/frontend/src/metabase/timelines/collections/components/TimelineListModal/TimelineListModal.tsx +++ b/frontend/src/metabase/timelines/collections/components/TimelineListModal/TimelineListModal.tsx @@ -1,10 +1,9 @@ -import React, { useCallback } from "react"; +import React, { useCallback, useMemo } from "react"; import { t } from "ttag"; -import _ from "underscore"; import * as Urls from "metabase/lib/urls"; import { getDefaultTimelineName, - getTimelineName, + getSortedTimelines, } from "metabase/lib/timelines"; import EntityMenu from "metabase/components/EntityMenu"; import { Collection, Timeline } from "metabase-types/api"; @@ -33,10 +32,13 @@ const TimelineListModal = ({ }: TimelineListModalProps): JSX.Element => { const title = getTitle(timelines, collection, isArchive); const menuItems = getMenuItems(timelines, collection, isArchive); - const sortedTimelines = getSortedTimelines(timelines); const hasTimelines = timelines.length > 0; const hasMenuItems = menuItems.length > 0; + const sortedTimelines = useMemo(() => { + return getSortedTimelines(timelines, collection); + }, [timelines, collection]); + const handleGoBack = useCallback(() => { onGoBack?.(collection); }, [collection, onGoBack]); @@ -54,11 +56,7 @@ const TimelineListModal = ({ </ModalHeader> <ModalBody isTopAligned={hasTimelines}> {hasTimelines ? ( - <TimelineList - timelines={sortedTimelines} - collection={collection} - onUnarchive={onUnarchive} - /> + <TimelineList timelines={sortedTimelines} onUnarchive={onUnarchive} /> ) : isArchive ? ( <SearchEmptyState isTimeline={isArchive} /> ) : ( @@ -104,11 +102,4 @@ const getMenuItems = ( ]; }; -const getSortedTimelines = (timelines: Timeline[]) => { - return _.chain(timelines) - .sortBy(getTimelineName) - .sortBy(timeline => timeline.collection?.personal_owner_id != null) // personal collections last - .value(); -}; - export default TimelineListModal; diff --git a/frontend/src/metabase/timelines/collections/containers/DeleteEventModal/DeleteEventModal.tsx b/frontend/src/metabase/timelines/collections/containers/DeleteEventModal/DeleteEventModal.tsx index 354bfb13671..6578957bc2b 100644 --- a/frontend/src/metabase/timelines/collections/containers/DeleteEventModal/DeleteEventModal.tsx +++ b/frontend/src/metabase/timelines/collections/containers/DeleteEventModal/DeleteEventModal.tsx @@ -2,39 +2,33 @@ import { connect } from "react-redux"; import { goBack, push } from "react-router-redux"; import _ from "underscore"; import * as Urls from "metabase/lib/urls"; -import Collections from "metabase/entities/collections"; import Timelines from "metabase/entities/timelines"; import TimelineEvents from "metabase/entities/timeline-events"; -import { Collection, Timeline, TimelineEvent } from "metabase-types/api"; +import DeleteEventModal from "metabase/timelines/common/components/DeleteEventModal"; +import { Timeline, TimelineEvent } from "metabase-types/api"; import { State } from "metabase-types/store"; -import DeleteEventModal from "../../components/DeleteEventModal"; -import { ModalProps } from "../../types"; +import { ModalParams } from "../../types"; + +interface DeleteEventModalProps { + params: ModalParams; +} const timelineProps = { - id: (state: State, props: ModalProps) => + id: (state: State, props: DeleteEventModalProps) => Urls.extractEntityId(props.params.timelineId), query: { include: "events" }, }; const timelineEventProps = { - id: (state: State, props: ModalProps) => + id: (state: State, props: DeleteEventModalProps) => Urls.extractEntityId(props.params.timelineEventId), entityAlias: "event", }; -const collectionProps = { - id: (state: State, props: ModalProps) => - Urls.extractCollectionId(props.params.slug), -}; - const mapDispatchToProps = (dispatch: any) => ({ - onSubmit: async ( - event: TimelineEvent, - timeline: Timeline, - collection: Collection, - ) => { + onSubmit: async (event: TimelineEvent, timeline: Timeline) => { await dispatch(TimelineEvents.actions.delete(event)); - dispatch(push(Urls.timelineArchiveInCollection(timeline, collection))); + dispatch(push(Urls.timelineArchiveInCollection(timeline))); }, onCancel: () => { dispatch(goBack()); @@ -44,6 +38,5 @@ const mapDispatchToProps = (dispatch: any) => ({ export default _.compose( Timelines.load(timelineProps), TimelineEvents.load(timelineEventProps), - Collections.load(collectionProps), connect(null, mapDispatchToProps), )(DeleteEventModal); diff --git a/frontend/src/metabase/timelines/collections/containers/DeleteTimelineModal/DeleteTimelineModal.tsx b/frontend/src/metabase/timelines/collections/containers/DeleteTimelineModal/DeleteTimelineModal.tsx index 8d74c6f3c9f..9b80fcf1bc5 100644 --- a/frontend/src/metabase/timelines/collections/containers/DeleteTimelineModal/DeleteTimelineModal.tsx +++ b/frontend/src/metabase/timelines/collections/containers/DeleteTimelineModal/DeleteTimelineModal.tsx @@ -3,27 +3,25 @@ import { goBack, push } from "react-router-redux"; import _ from "underscore"; import * as Urls from "metabase/lib/urls"; import Timelines from "metabase/entities/timelines"; -import Collections from "metabase/entities/collections"; -import { Collection, Timeline } from "metabase-types/api"; +import { Timeline } from "metabase-types/api"; import { State } from "metabase-types/store"; -import DeleteTimelineModal from "../../components/DeleteTimelineModal"; -import { ModalProps } from "../../types"; +import DeleteTimelineModal from "metabase/timelines/common/components/DeleteTimelineModal"; +import { ModalParams } from "../../types"; + +interface DeleteTimelineModalProps { + params: ModalParams; +} const timelineProps = { - id: (state: State, props: ModalProps) => + id: (state: State, props: DeleteTimelineModalProps) => Urls.extractEntityId(props.params.timelineId), query: { include: "events" }, }; -const collectionProps = { - id: (state: State, props: ModalProps) => - Urls.extractCollectionId(props.params.slug), -}; - const mapDispatchToProps = (dispatch: any) => ({ - onSubmit: async (timeline: Timeline, collection: Collection) => { + onSubmit: async (timeline: Timeline) => { await dispatch(Timelines.actions.delete(timeline)); - dispatch(push(Urls.timelinesArchiveInCollection(collection))); + dispatch(push(Urls.timelinesArchiveInCollection(timeline.collection))); }, onCancel: () => { dispatch(goBack()); @@ -32,6 +30,5 @@ const mapDispatchToProps = (dispatch: any) => ({ export default _.compose( Timelines.load(timelineProps), - Collections.load(collectionProps), connect(null, mapDispatchToProps), )(DeleteTimelineModal); diff --git a/frontend/src/metabase/timelines/collections/containers/EditEventModal/EditEventModal.tsx b/frontend/src/metabase/timelines/collections/containers/EditEventModal/EditEventModal.tsx index 32963d2817f..36f59412592 100644 --- a/frontend/src/metabase/timelines/collections/containers/EditEventModal/EditEventModal.tsx +++ b/frontend/src/metabase/timelines/collections/containers/EditEventModal/EditEventModal.tsx @@ -2,50 +2,40 @@ import { connect } from "react-redux"; import { goBack, push } from "react-router-redux"; import _ from "underscore"; import * as Urls from "metabase/lib/urls"; -import Collections from "metabase/entities/collections"; import Timelines from "metabase/entities/timelines"; import TimelineEvents from "metabase/entities/timeline-events"; -import { Collection, Timeline, TimelineEvent } from "metabase-types/api"; +import EditEventModal from "metabase/timelines/common/components/EditEventModal"; +import { Timeline, TimelineEvent } from "metabase-types/api"; import { State } from "metabase-types/store"; -import EditEventModal from "../../components/EditEventModal"; import LoadingAndErrorWrapper from "../../components/LoadingAndErrorWrapper"; -import { ModalProps } from "../../types"; +import { ModalParams } from "../../types"; + +interface EditEventModalProps { + params: ModalParams; +} const timelineProps = { - id: (state: State, props: ModalProps) => + id: (state: State, props: EditEventModalProps) => Urls.extractEntityId(props.params.timelineId), query: { include: "events" }, + LoadingAndErrorWrapper, }; const timelineEventProps = { - id: (state: State, props: ModalProps) => + id: (state: State, props: EditEventModalProps) => Urls.extractEntityId(props.params.timelineEventId), entityAlias: "event", LoadingAndErrorWrapper, }; -const collectionProps = { - id: (state: State, props: ModalProps) => - Urls.extractCollectionId(props.params.slug), - LoadingAndErrorWrapper, -}; - const mapDispatchToProps = (dispatch: any) => ({ - onSubmit: async ( - event: TimelineEvent, - timeline: Timeline, - collection: Collection, - ) => { + onSubmit: async (event: TimelineEvent, timeline?: Timeline) => { await dispatch(TimelineEvents.actions.update(event)); - dispatch(push(Urls.timelineInCollection(timeline, collection))); + timeline && dispatch(push(Urls.timelineInCollection(timeline))); }, - onArchive: async ( - event: TimelineEvent, - timeline: Timeline, - collection: Collection, - ) => { + onArchive: async (event: TimelineEvent, timeline?: Timeline) => { await dispatch(TimelineEvents.actions.setArchived(event, true)); - dispatch(push(Urls.timelineInCollection(timeline, collection))); + timeline && dispatch(push(Urls.timelineInCollection(timeline))); }, onCancel: () => { dispatch(goBack()); @@ -55,6 +45,5 @@ const mapDispatchToProps = (dispatch: any) => ({ export default _.compose( Timelines.load(timelineProps), TimelineEvents.load(timelineEventProps), - Collections.load(collectionProps), connect(null, mapDispatchToProps), )(EditEventModal); diff --git a/frontend/src/metabase/timelines/collections/containers/EditTimelineModal/EditTimelineModal.tsx b/frontend/src/metabase/timelines/collections/containers/EditTimelineModal/EditTimelineModal.tsx index 3529ef0df65..dbb5a599256 100644 --- a/frontend/src/metabase/timelines/collections/containers/EditTimelineModal/EditTimelineModal.tsx +++ b/frontend/src/metabase/timelines/collections/containers/EditTimelineModal/EditTimelineModal.tsx @@ -2,35 +2,32 @@ import { connect } from "react-redux"; import { goBack, push } from "react-router-redux"; import _ from "underscore"; import * as Urls from "metabase/lib/urls"; -import Collections from "metabase/entities/collections"; import Timelines from "metabase/entities/timelines"; -import { Collection, Timeline } from "metabase-types/api"; +import EditTimelineModal from "metabase/timelines/common/components/EditTimelineModal"; +import { Timeline } from "metabase-types/api"; import { State } from "metabase-types/store"; -import EditTimelineModal from "../../components/EditTimelineModal"; import LoadingAndErrorWrapper from "../../components/LoadingAndErrorWrapper"; -import { ModalProps } from "../../types"; +import { ModalParams } from "../../types"; + +interface EditTimelineModalProps { + params: ModalParams; +} const timelineProps = { - id: (state: State, props: ModalProps) => + id: (state: State, props: EditTimelineModalProps) => Urls.extractEntityId(props.params.timelineId), query: { include: "events" }, LoadingAndErrorWrapper, }; -const collectionProps = { - id: (state: State, props: ModalProps) => - Urls.extractCollectionId(props.params.slug), - LoadingAndErrorWrapper, -}; - const mapDispatchToProps = (dispatch: any) => ({ - onSubmit: async (timeline: Timeline, collection: Collection) => { + onSubmit: async (timeline: Timeline) => { await dispatch(Timelines.actions.update(timeline)); - dispatch(push(Urls.timelineInCollection(timeline, collection))); + dispatch(push(Urls.timelineInCollection(timeline))); }, - onArchive: async (timeline: Timeline, collection: Collection) => { + onArchive: async (timeline: Timeline) => { await dispatch(Timelines.actions.setArchived(timeline, true)); - dispatch(push(Urls.timelinesInCollection(collection))); + dispatch(push(Urls.timelinesInCollection(timeline.collection))); }, onCancel: () => { dispatch(goBack()); @@ -39,6 +36,5 @@ const mapDispatchToProps = (dispatch: any) => ({ export default _.compose( Timelines.load(timelineProps), - Collections.load(collectionProps), connect(null, mapDispatchToProps), )(EditTimelineModal); diff --git a/frontend/src/metabase/timelines/collections/containers/MoveEventModal/MoveEventModal.tsx b/frontend/src/metabase/timelines/collections/containers/MoveEventModal/MoveEventModal.tsx new file mode 100644 index 00000000000..bf4a9744d78 --- /dev/null +++ b/frontend/src/metabase/timelines/collections/containers/MoveEventModal/MoveEventModal.tsx @@ -0,0 +1,55 @@ +import { connect } from "react-redux"; +import { goBack, push } from "react-router-redux"; +import _ from "underscore"; +import * as Urls from "metabase/lib/urls"; +import Collections from "metabase/entities/collections"; +import Timelines from "metabase/entities/timelines"; +import TimelineEvents from "metabase/entities/timeline-events"; +import MoveEventModal from "metabase/timelines/common/components/MoveEventModal"; +import { Timeline, TimelineEvent } from "metabase-types/api"; +import { State } from "metabase-types/store"; +import LoadingAndErrorWrapper from "../../components/LoadingAndErrorWrapper"; +import { ModalParams } from "../../types"; + +interface MoveEventModalProps { + params: ModalParams; +} + +const timelinesProps = { + query: { include: "events" }, + LoadingAndErrorWrapper, +}; + +const timelineEventProps = { + id: (state: State, props: MoveEventModalProps) => + Urls.extractEntityId(props.params.timelineEventId), + entityAlias: "event", + LoadingAndErrorWrapper, +}; + +const collectionProps = { + id: (state: State, props: MoveEventModalProps) => + Urls.extractCollectionId(props.params.slug), + LoadingAndErrorWrapper, +}; + +const mapDispatchToProps = (dispatch: any) => ({ + onSubmit: async ( + event: TimelineEvent, + newTimeline: Timeline, + oldTimeline: Timeline, + ) => { + await dispatch(TimelineEvents.actions.setTimeline(event, newTimeline)); + dispatch(push(Urls.timelineInCollection(oldTimeline))); + }, + onCancel: () => { + dispatch(goBack()); + }, +}); + +export default _.compose( + Timelines.loadList(timelinesProps), + TimelineEvents.load(timelineEventProps), + Collections.load(collectionProps), + connect(null, mapDispatchToProps), +)(MoveEventModal); diff --git a/frontend/src/metabase/timelines/collections/containers/MoveEventModal/index.ts b/frontend/src/metabase/timelines/collections/containers/MoveEventModal/index.ts new file mode 100644 index 00000000000..369d23ed2ee --- /dev/null +++ b/frontend/src/metabase/timelines/collections/containers/MoveEventModal/index.ts @@ -0,0 +1 @@ +export { default } from "./MoveEventModal"; diff --git a/frontend/src/metabase/timelines/collections/containers/NewEventModal/NewEventModal.tsx b/frontend/src/metabase/timelines/collections/containers/NewEventModal/NewEventModal.tsx index fa83471b4d7..4b360e78b67 100644 --- a/frontend/src/metabase/timelines/collections/containers/NewEventModal/NewEventModal.tsx +++ b/frontend/src/metabase/timelines/collections/containers/NewEventModal/NewEventModal.tsx @@ -2,27 +2,30 @@ import { connect } from "react-redux"; import { goBack, push } from "react-router-redux"; import _ from "underscore"; import * as Urls from "metabase/lib/urls"; -import Collections from "metabase/entities/collections"; import Timelines from "metabase/entities/timelines"; import TimelineEvents from "metabase/entities/timeline-events"; +import NewEventModal from "metabase/timelines/common/components/NewEventModal"; import { Collection, Timeline, TimelineEvent } from "metabase-types/api"; import { State } from "metabase-types/store"; -import NewEventModal from "../../components/NewEventModal"; import LoadingAndErrorWrapper from "../../components/LoadingAndErrorWrapper"; -import { ModalProps } from "../../types"; +import { ModalParams } from "../../types"; + +interface NewEventModalProps { + params: ModalParams; + timeline: Timeline; +} const timelineProps = { - id: (state: State, props: ModalProps) => + id: (state: State, props: NewEventModalProps) => Urls.extractEntityId(props.params.timelineId), query: { include: "events" }, LoadingAndErrorWrapper, }; -const collectionProps = { - id: (state: State, props: ModalProps) => - Urls.extractCollectionId(props.params.slug), - LoadingAndErrorWrapper, -}; +const mapStateToProps = (state: State, { timeline }: NewEventModalProps) => ({ + source: "collections", + timelines: [timeline], +}); const mapDispatchToProps = (dispatch: any) => ({ onSubmit: async ( @@ -31,7 +34,7 @@ const mapDispatchToProps = (dispatch: any) => ({ timeline: Timeline, ) => { await dispatch(TimelineEvents.actions.create(values)); - dispatch(push(Urls.timelineInCollection(timeline, collection))); + dispatch(push(Urls.timelineInCollection(timeline))); }, onCancel: () => { dispatch(goBack()); @@ -40,6 +43,5 @@ const mapDispatchToProps = (dispatch: any) => ({ export default _.compose( Timelines.load(timelineProps), - Collections.load(collectionProps), - connect(null, mapDispatchToProps), + connect(mapStateToProps, mapDispatchToProps), )(NewEventModal); diff --git a/frontend/src/metabase/timelines/collections/containers/NewEventWithTimelineModal/NewEventWithTimelineModal.tsx b/frontend/src/metabase/timelines/collections/containers/NewEventWithTimelineModal/NewEventWithTimelineModal.tsx index c304021085a..6019e060838 100644 --- a/frontend/src/metabase/timelines/collections/containers/NewEventWithTimelineModal/NewEventWithTimelineModal.tsx +++ b/frontend/src/metabase/timelines/collections/containers/NewEventWithTimelineModal/NewEventWithTimelineModal.tsx @@ -4,14 +4,18 @@ import _ from "underscore"; import * as Urls from "metabase/lib/urls"; import Collections from "metabase/entities/collections"; import Timelines from "metabase/entities/timelines"; +import NewEventModal from "metabase/timelines/common/components/NewEventModal"; import { Collection, TimelineEvent } from "metabase-types/api"; import { State } from "metabase-types/store"; -import NewEventModal from "../../components/NewEventModal"; import LoadingAndErrorWrapper from "../../components/LoadingAndErrorWrapper"; -import { ModalProps } from "../../types"; +import { ModalParams } from "../../types"; + +interface NewEventWithTimelineModalProps { + params: ModalParams; +} const collectionProps = { - id: (state: State, props: ModalProps) => + id: (state: State, props: NewEventWithTimelineModalProps) => Urls.extractCollectionId(props.params.slug), LoadingAndErrorWrapper, }; diff --git a/frontend/src/metabase/timelines/collections/containers/NewTimelineModal/NewTimelineModal.tsx b/frontend/src/metabase/timelines/collections/containers/NewTimelineModal/NewTimelineModal.tsx index 64fc36693f1..41131a4710f 100644 --- a/frontend/src/metabase/timelines/collections/containers/NewTimelineModal/NewTimelineModal.tsx +++ b/frontend/src/metabase/timelines/collections/containers/NewTimelineModal/NewTimelineModal.tsx @@ -4,24 +4,28 @@ import _ from "underscore"; import * as Urls from "metabase/lib/urls"; import Collections from "metabase/entities/collections"; import Timelines from "metabase/entities/timelines"; -import { Collection, Timeline } from "metabase-types/api"; +import NewTimelineModal from "metabase/timelines/common/components/NewTimelineModal"; +import { Timeline } from "metabase-types/api"; import { State } from "metabase-types/store"; -import NewTimelineModal from "../../components/NewTimelineModal"; import LoadingAndErrorWrapper from "../../components/LoadingAndErrorWrapper"; -import { ModalProps } from "../../types"; +import { ModalParams } from "../../types"; + +interface NewTimelineModalProps { + params: ModalParams; +} const collectionProps = { - id: (state: State, props: ModalProps) => + id: (state: State, props: NewTimelineModalProps) => Urls.extractCollectionId(props.params.slug), LoadingAndErrorWrapper, }; const mapDispatchToProps = (dispatch: any) => ({ - onSubmit: async (values: Partial<Timeline>, collection: Collection) => { + onSubmit: async (values: Partial<Timeline>) => { const action = Timelines.actions.create(values); const response = await dispatch(action); const timeline = Timelines.HACK_getObjectFromAction(response); - dispatch(push(Urls.timelineInCollection(timeline, collection))); + dispatch(push(Urls.timelineInCollection(timeline))); }, onCancel: () => { dispatch(goBack()); diff --git a/frontend/src/metabase/timelines/collections/containers/TimelineArchiveModal/TimelineArchiveModal.tsx b/frontend/src/metabase/timelines/collections/containers/TimelineArchiveModal/TimelineArchiveModal.tsx index aff7fc4fa4b..8481aaeae42 100644 --- a/frontend/src/metabase/timelines/collections/containers/TimelineArchiveModal/TimelineArchiveModal.tsx +++ b/frontend/src/metabase/timelines/collections/containers/TimelineArchiveModal/TimelineArchiveModal.tsx @@ -2,28 +2,25 @@ import { connect } from "react-redux"; import { push } from "react-router-redux"; import _ from "underscore"; import * as Urls from "metabase/lib/urls"; -import Collections from "metabase/entities/collections"; import Timelines from "metabase/entities/timelines"; import TimelineEvents from "metabase/entities/timeline-events"; -import { Collection, Timeline, TimelineEvent } from "metabase-types/api"; +import { Timeline, TimelineEvent } from "metabase-types/api"; import { State } from "metabase-types/store"; import TimelineDetailsModal from "../../components/TimelineDetailsModal"; import LoadingAndErrorWrapper from "../../components/LoadingAndErrorWrapper"; -import { ModalProps } from "../../types"; +import { ModalParams } from "../../types"; + +interface TimelineArchiveModalProps { + params: ModalParams; +} const timelineProps = { - id: (state: State, props: ModalProps) => + id: (state: State, props: TimelineArchiveModalProps) => Urls.extractEntityId(props.params.timelineId), query: { include: "events", archived: true }, LoadingAndErrorWrapper, }; -const collectionProps = { - id: (state: State, props: ModalProps) => - Urls.extractCollectionId(props.params.slug), - LoadingAndErrorWrapper, -}; - const mapStateToProps = () => ({ isArchive: true, }); @@ -32,13 +29,12 @@ const mapDispatchToProps = (dispatch: any) => ({ onUnarchive: async (event: TimelineEvent) => { await dispatch(TimelineEvents.actions.setArchived(event, false)); }, - onGoBack: (timeline: Timeline, collection: Collection) => { - dispatch(push(Urls.timelineInCollection(timeline, collection))); + onGoBack: (timeline: Timeline) => { + dispatch(push(Urls.timelineInCollection(timeline))); }, }); export default _.compose( Timelines.load(timelineProps), - Collections.load(collectionProps), connect(mapStateToProps, mapDispatchToProps), )(TimelineDetailsModal); diff --git a/frontend/src/metabase/timelines/collections/containers/TimelineDetailsModal/TimelineDetailsModal.tsx b/frontend/src/metabase/timelines/collections/containers/TimelineDetailsModal/TimelineDetailsModal.tsx index e810aa08e6a..aeaf1e8ac4d 100644 --- a/frontend/src/metabase/timelines/collections/containers/TimelineDetailsModal/TimelineDetailsModal.tsx +++ b/frontend/src/metabase/timelines/collections/containers/TimelineDetailsModal/TimelineDetailsModal.tsx @@ -2,7 +2,6 @@ import { connect } from "react-redux"; import { push } from "react-router-redux"; import _ from "underscore"; import * as Urls from "metabase/lib/urls"; -import Collections from "metabase/entities/collections"; import Timelines from "metabase/entities/timelines"; import TimelineEvents from "metabase/entities/timeline-events"; import { Collection, Timeline, TimelineEvent } from "metabase-types/api"; @@ -11,34 +10,29 @@ import TimelineDetailsModal from "../../components/TimelineDetailsModal"; import LoadingAndErrorWrapper from "../../components/LoadingAndErrorWrapper"; import { ModalParams } from "../../types"; -interface ModalProps { +interface TimelineDetailsModalProps { params: ModalParams; timelines: Timeline[]; } const timelineProps = { - id: (state: State, props: ModalProps) => + id: (state: State, props: TimelineDetailsModalProps) => Urls.extractEntityId(props.params.timelineId), query: { include: "events" }, LoadingAndErrorWrapper, }; const timelinesProps = { - query: (state: State, props: ModalProps) => ({ + query: (state: State, props: TimelineDetailsModalProps) => ({ collectionId: Urls.extractCollectionId(props.params.slug), }), LoadingAndErrorWrapper, }; -const mapStateToProps = (state: State, { timelines }: ModalProps) => ({ - isOnlyTimeline: timelines.length === 1, +const mapStateToProps = (state: State, props: TimelineDetailsModalProps) => ({ + isOnlyTimeline: props.timelines.length === 1, }); -const collectionProps = { - id: (state: State, props: ModalProps) => - Urls.extractCollectionId(props.params.slug), -}; - const mapDispatchToProps = (dispatch: any) => ({ onArchive: async (event: TimelineEvent) => { await dispatch(TimelineEvents.actions.setArchived(event, true)); @@ -51,6 +45,5 @@ const mapDispatchToProps = (dispatch: any) => ({ export default _.compose( Timelines.load(timelineProps), Timelines.loadList(timelinesProps), - Collections.load(collectionProps), connect(mapStateToProps, mapDispatchToProps), )(TimelineDetailsModal); diff --git a/frontend/src/metabase/timelines/collections/containers/TimelineIndexModal/TimelineIndexModal.tsx b/frontend/src/metabase/timelines/collections/containers/TimelineIndexModal/TimelineIndexModal.tsx index 9a0316b370c..66dda2ffebc 100644 --- a/frontend/src/metabase/timelines/collections/containers/TimelineIndexModal/TimelineIndexModal.tsx +++ b/frontend/src/metabase/timelines/collections/containers/TimelineIndexModal/TimelineIndexModal.tsx @@ -2,13 +2,19 @@ import * as Urls from "metabase/lib/urls"; import Timelines from "metabase/entities/timelines"; import { State } from "metabase-types/store"; import TimelineIndexModal from "../../components/TimelineIndexModal"; -import { ModalProps } from "../../types"; +import LoadingAndErrorWrapper from "../../components/LoadingAndErrorWrapper"; +import { ModalParams } from "../../types"; + +interface TimelineIndexModalProps { + params: ModalParams; +} const timelineProps = { - query: (state: State, props: ModalProps) => ({ + query: (state: State, props: TimelineIndexModalProps) => ({ collectionId: Urls.extractCollectionId(props.params.slug), include: "events", }), + LoadingAndErrorWrapper, }; export default Timelines.loadList(timelineProps)(TimelineIndexModal); diff --git a/frontend/src/metabase/timelines/collections/containers/TimelineListArchiveModal/TimelineListArchiveModal.tsx b/frontend/src/metabase/timelines/collections/containers/TimelineListArchiveModal/TimelineListArchiveModal.tsx index 502cd816d32..286290e6bce 100644 --- a/frontend/src/metabase/timelines/collections/containers/TimelineListArchiveModal/TimelineListArchiveModal.tsx +++ b/frontend/src/metabase/timelines/collections/containers/TimelineListArchiveModal/TimelineListArchiveModal.tsx @@ -7,19 +7,26 @@ import Timelines from "metabase/entities/timelines"; import { Collection, TimelineEvent } from "metabase-types/api"; import { State } from "metabase-types/store"; import TimelineListModal from "../../components/TimelineListModal"; -import { ModalProps } from "../../types"; +import LoadingAndErrorWrapper from "../../components/LoadingAndErrorWrapper"; +import { ModalParams } from "../../types"; + +interface TimelineListArchiveModalProps { + params: ModalParams; +} const timelineProps = { - query: (state: State, props: ModalProps) => ({ + query: (state: State, props: TimelineListArchiveModalProps) => ({ collectionId: Urls.extractCollectionId(props.params.slug), archived: true, include: "events", }), + LoadingAndErrorWrapper, }; const collectionProps = { - id: (state: State, props: ModalProps) => + id: (state: State, props: TimelineListArchiveModalProps) => Urls.extractCollectionId(props.params.slug), + LoadingAndErrorWrapper, }; const mapStateToProps = () => ({ diff --git a/frontend/src/metabase/timelines/collections/containers/TimelineListModal/TimelineListModal.tsx b/frontend/src/metabase/timelines/collections/containers/TimelineListModal/TimelineListModal.tsx index 01f1bb87604..8c06ec65902 100644 --- a/frontend/src/metabase/timelines/collections/containers/TimelineListModal/TimelineListModal.tsx +++ b/frontend/src/metabase/timelines/collections/containers/TimelineListModal/TimelineListModal.tsx @@ -4,18 +4,25 @@ import Collections from "metabase/entities/collections"; import Timelines from "metabase/entities/timelines"; import { State } from "metabase-types/store"; import TimelineListModal from "../../components/TimelineListModal"; -import { ModalProps } from "../../types"; +import LoadingAndErrorWrapper from "../../components/LoadingAndErrorWrapper"; +import { ModalParams } from "../../types"; + +interface TimelineListModalProps { + params: ModalParams; +} const timelineProps = { - query: (state: State, props: ModalProps) => ({ + query: (state: State, props: TimelineListModalProps) => ({ collectionId: Urls.extractCollectionId(props.params.slug), include: "events", }), + LoadingAndErrorWrapper, }; const collectionProps = { - id: (state: State, props: ModalProps) => + id: (state: State, props: TimelineListModalProps) => Urls.extractCollectionId(props.params.slug), + LoadingAndErrorWrapper, }; export default _.compose( diff --git a/frontend/src/metabase/timelines/collections/routes.tsx b/frontend/src/metabase/timelines/collections/routes.tsx index 32abd01b77f..c02fe6658be 100644 --- a/frontend/src/metabase/timelines/collections/routes.tsx +++ b/frontend/src/metabase/timelines/collections/routes.tsx @@ -4,6 +4,7 @@ import DeleteEventModal from "./containers/DeleteEventModal"; import DeleteTimelineModal from "./containers/DeleteTimelineModal"; import EditEventModal from "./containers/EditEventModal"; import EditTimelineModal from "./containers/EditTimelineModal"; +import MoveEventModal from "./containers/MoveEventModal"; import NewEventModal from "./containers/NewEventModal"; import NewEventWithTimelineModal from "./containers/NewEventWithTimelineModal"; import NewTimelineModal from "./containers/NewTimelineModal"; @@ -85,6 +86,13 @@ const getRoutes = () => { modalProps: { enableTransition: false }, }} /> + <ModalRoute + {...{ + path: "timelines/:timelineId/events/:timelineEventId/move", + modal: MoveEventModal, + modalProps: { enableTransition: false }, + }} + /> <ModalRoute {...{ path: "timelines/:timelineId/events/:timelineEventId/delete", diff --git a/frontend/src/metabase/timelines/collections/types.ts b/frontend/src/metabase/timelines/collections/types.ts index 3ca07dee24b..1e7f0bc2f34 100644 --- a/frontend/src/metabase/timelines/collections/types.ts +++ b/frontend/src/metabase/timelines/collections/types.ts @@ -9,7 +9,3 @@ export interface ModalParams { timelineId?: string; timelineEventId?: string; } - -export interface ModalProps { - params: ModalParams; -} diff --git a/frontend/src/metabase/timelines/common/components/DeleteEventModal/DeleteEventModal.tsx b/frontend/src/metabase/timelines/common/components/DeleteEventModal/DeleteEventModal.tsx new file mode 100644 index 00000000000..136c7220170 --- /dev/null +++ b/frontend/src/metabase/timelines/common/components/DeleteEventModal/DeleteEventModal.tsx @@ -0,0 +1,41 @@ +import React, { useCallback } from "react"; +import { t } from "ttag"; +import Button from "metabase/core/components/Button"; +import { Timeline, TimelineEvent } from "metabase-types/api"; +import ModalHeader from "../ModalHeader"; +import ModalFooter from "../ModalFooter"; + +export interface DeleteEventModalProps { + event: TimelineEvent; + timeline: Timeline; + onSubmit: (event: TimelineEvent, timeline: Timeline) => void; + onSubmitSuccess?: () => void; + onCancel?: () => void; + onClose?: () => void; +} + +const DeleteEventModal = ({ + event, + timeline, + onSubmit, + onSubmitSuccess, + onCancel, + onClose, +}: DeleteEventModalProps): JSX.Element => { + const handleSubmit = useCallback(async () => { + await onSubmit(event, timeline); + onSubmitSuccess?.(); + }, [event, timeline, onSubmit, onSubmitSuccess]); + + return ( + <div> + <ModalHeader title={t`Delete ${event?.name}?`} onClose={onClose} /> + <ModalFooter hasPadding> + <Button onClick={onCancel}>{t`Cancel`}</Button> + <Button danger onClick={handleSubmit}>{t`Delete`}</Button> + </ModalFooter> + </div> + ); +}; + +export default DeleteEventModal; diff --git a/frontend/src/metabase/timelines/collections/components/DeleteEventModal/DeleteEventModal.unit.spec.tsx b/frontend/src/metabase/timelines/common/components/DeleteEventModal/DeleteEventModal.unit.spec.tsx similarity index 92% rename from frontend/src/metabase/timelines/collections/components/DeleteEventModal/DeleteEventModal.unit.spec.tsx rename to frontend/src/metabase/timelines/common/components/DeleteEventModal/DeleteEventModal.unit.spec.tsx index 51200403351..5e1cf23061e 100644 --- a/frontend/src/metabase/timelines/collections/components/DeleteEventModal/DeleteEventModal.unit.spec.tsx +++ b/frontend/src/metabase/timelines/common/components/DeleteEventModal/DeleteEventModal.unit.spec.tsx @@ -2,7 +2,6 @@ import React from "react"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { - createMockCollection, createMockTimeline, createMockTimelineEvent, } from "metabase-types/api/mocks"; @@ -24,7 +23,6 @@ const getProps = ( ): DeleteEventModalProps => ({ event: createMockTimelineEvent(), timeline: createMockTimeline(), - collection: createMockCollection(), onSubmit: jest.fn(), onCancel: jest.fn(), onClose: jest.fn(), diff --git a/frontend/src/metabase/timelines/collections/components/DeleteEventModal/index.ts b/frontend/src/metabase/timelines/common/components/DeleteEventModal/index.ts similarity index 100% rename from frontend/src/metabase/timelines/collections/components/DeleteEventModal/index.ts rename to frontend/src/metabase/timelines/common/components/DeleteEventModal/index.ts diff --git a/frontend/src/metabase/timelines/common/components/DeleteTimelineModal/DeleteTimelineModal.tsx b/frontend/src/metabase/timelines/common/components/DeleteTimelineModal/DeleteTimelineModal.tsx new file mode 100644 index 00000000000..e76832743ab --- /dev/null +++ b/frontend/src/metabase/timelines/common/components/DeleteTimelineModal/DeleteTimelineModal.tsx @@ -0,0 +1,39 @@ +import React, { useCallback } from "react"; +import { t } from "ttag"; +import Button from "metabase/core/components/Button/Button"; +import { Timeline } from "metabase-types/api"; +import ModalHeader from "../ModalHeader"; +import ModalFooter from "../ModalFooter"; + +export interface DeleteTimelineModalProps { + timeline: Timeline; + onSubmit: (timeline: Timeline) => void; + onSubmitSuccess?: () => void; + onCancel?: () => void; + onClose?: () => void; +} + +const DeleteTimelineModal = ({ + timeline, + onSubmit, + onSubmitSuccess, + onCancel, + onClose, +}: DeleteTimelineModalProps): JSX.Element => { + const handleSubmit = useCallback(async () => { + await onSubmit(timeline); + onSubmitSuccess?.(); + }, [timeline, onSubmit, onSubmitSuccess]); + + return ( + <div> + <ModalHeader title={t`Delete ${timeline?.name}?`} onClose={onClose} /> + <ModalFooter hasPadding> + <Button onClick={onCancel}>{t`Cancel`}</Button> + <Button danger onClick={handleSubmit}>{t`Delete`}</Button> + </ModalFooter> + </div> + ); +}; + +export default DeleteTimelineModal; diff --git a/frontend/src/metabase/timelines/collections/components/DeleteTimelineModal/index.ts b/frontend/src/metabase/timelines/common/components/DeleteTimelineModal/index.ts similarity index 100% rename from frontend/src/metabase/timelines/collections/components/DeleteTimelineModal/index.ts rename to frontend/src/metabase/timelines/common/components/DeleteTimelineModal/index.ts diff --git a/frontend/src/metabase/timelines/collections/components/EditEventModal/EditEventModal.tsx b/frontend/src/metabase/timelines/common/components/EditEventModal/EditEventModal.tsx similarity index 57% rename from frontend/src/metabase/timelines/collections/components/EditEventModal/EditEventModal.tsx rename to frontend/src/metabase/timelines/common/components/EditEventModal/EditEventModal.tsx index e1ce0910b74..6df46fadbe1 100644 --- a/frontend/src/metabase/timelines/collections/components/EditEventModal/EditEventModal.tsx +++ b/frontend/src/metabase/timelines/common/components/EditEventModal/EditEventModal.tsx @@ -2,34 +2,29 @@ import React, { useCallback, useMemo } from "react"; import { t } from "ttag"; import Form from "metabase/containers/Form"; import forms from "metabase/entities/timeline-events/forms"; -import ModalHeader from "metabase/timelines/common/components/ModalHeader"; -import { Collection, Timeline, TimelineEvent } from "metabase-types/api"; -import { ModalBody, ModalDangerButton } from "./EditEventModal.styled"; +import { Timeline, TimelineEvent } from "metabase-types/api"; +import ModalBody from "../ModalBody"; +import ModalDangerButton from "../ModalDangerButton"; +import ModalHeader from "../ModalHeader"; export interface EditEventModalProps { event: TimelineEvent; - timeline: Timeline; - collection: Collection; - onSubmit: ( - event: TimelineEvent, - timeline: Timeline, - collection: Collection, - ) => void; - onArchive: ( - event: TimelineEvent, - timeline: Timeline, - collection: Collection, - ) => void; - onCancel: () => void; + timeline?: Timeline; + onSubmit: (event: TimelineEvent, timeline?: Timeline) => void; + onSubmitSuccess?: () => void; + onArchive: (event: TimelineEvent, timeline?: Timeline) => void; + onArchiveSuccess?: () => void; + onCancel?: () => void; onClose?: () => void; } const EditEventModal = ({ event, timeline, - collection, onSubmit, + onSubmitSuccess, onArchive, + onArchiveSuccess, onCancel, onClose, }: EditEventModalProps): JSX.Element => { @@ -37,14 +32,16 @@ const EditEventModal = ({ const handleSubmit = useCallback( async (event: TimelineEvent) => { - await onSubmit(event, timeline, collection); + await onSubmit(event, timeline); + onSubmitSuccess?.(); }, - [timeline, collection, onSubmit], + [timeline, onSubmit, onSubmitSuccess], ); const handleArchive = useCallback(async () => { - await onArchive(event, timeline, collection); - }, [event, timeline, collection, onArchive]); + await onArchive(event, timeline); + onArchiveSuccess?.(); + }, [event, timeline, onArchive, onArchiveSuccess]); return ( <div> @@ -57,7 +54,7 @@ const EditEventModal = ({ onSubmit={handleSubmit} onClose={onCancel} footerExtraButtons={ - <ModalDangerButton type="button" borderless onClick={handleArchive}> + <ModalDangerButton onClick={handleArchive}> {t`Archive event`} </ModalDangerButton> } diff --git a/frontend/src/metabase/timelines/collections/components/EditEventModal/EditEventModal.unit.spec.tsx b/frontend/src/metabase/timelines/common/components/EditEventModal/EditEventModal.unit.spec.tsx similarity index 94% rename from frontend/src/metabase/timelines/collections/components/EditEventModal/EditEventModal.unit.spec.tsx rename to frontend/src/metabase/timelines/common/components/EditEventModal/EditEventModal.unit.spec.tsx index 961f8c0b65e..8bad3d3ed00 100644 --- a/frontend/src/metabase/timelines/collections/components/EditEventModal/EditEventModal.unit.spec.tsx +++ b/frontend/src/metabase/timelines/common/components/EditEventModal/EditEventModal.unit.spec.tsx @@ -2,7 +2,6 @@ import React, { FormHTMLAttributes } from "react"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { - createMockCollection, createMockTimeline, createMockTimelineEvent, } from "metabase-types/api/mocks"; @@ -32,7 +31,6 @@ const getProps = ( ): EditEventModalProps => ({ event: createMockTimelineEvent(), timeline: createMockTimeline(), - collection: createMockCollection(), onSubmit: jest.fn(), onArchive: jest.fn(), onCancel: jest.fn(), diff --git a/frontend/src/metabase/timelines/collections/components/EditEventModal/index.ts b/frontend/src/metabase/timelines/common/components/EditEventModal/index.ts similarity index 100% rename from frontend/src/metabase/timelines/collections/components/EditEventModal/index.ts rename to frontend/src/metabase/timelines/common/components/EditEventModal/index.ts diff --git a/frontend/src/metabase/timelines/collections/components/EditTimelineModal/EditTimelineModal.tsx b/frontend/src/metabase/timelines/common/components/EditTimelineModal/EditTimelineModal.tsx similarity index 63% rename from frontend/src/metabase/timelines/collections/components/EditTimelineModal/EditTimelineModal.tsx rename to frontend/src/metabase/timelines/common/components/EditTimelineModal/EditTimelineModal.tsx index 3be1a1a0d6e..ddf898d621e 100644 --- a/frontend/src/metabase/timelines/collections/components/EditTimelineModal/EditTimelineModal.tsx +++ b/frontend/src/metabase/timelines/common/components/EditTimelineModal/EditTimelineModal.tsx @@ -2,24 +2,27 @@ import React, { useCallback, useMemo } from "react"; import { t } from "ttag"; import Form from "metabase/containers/Form"; import forms from "metabase/entities/timelines/forms"; -import ModalHeader from "metabase/timelines/common/components/ModalHeader"; -import { Collection, Timeline } from "metabase-types/api"; -import { ModalDangerButton, ModalBody } from "./EditTimelineModal.styled"; +import { Timeline } from "metabase-types/api"; +import ModalBody from "../ModalBody"; +import ModalDangerButton from "../ModalDangerButton"; +import ModalHeader from "../ModalHeader"; export interface EditTimelineModalProps { timeline: Timeline; - collection: Collection; - onSubmit: (values: Partial<Timeline>, collection: Collection) => void; - onArchive: (timeline: Timeline, collection: Collection) => void; - onCancel: () => void; + onSubmit: (values: Partial<Timeline>) => void; + onSubmitSuccess?: () => void; + onArchive: (timeline: Timeline) => void; + onArchiveSuccess?: () => void; + onCancel?: () => void; onClose?: () => void; } const EditTimelineModal = ({ timeline, - collection, onSubmit, + onSubmitSuccess, onArchive, + onArchiveSuccess, onCancel, onClose, }: EditTimelineModalProps): JSX.Element => { @@ -29,14 +32,16 @@ const EditTimelineModal = ({ const handleSubmit = useCallback( async (values: Partial<Timeline>) => { - await onSubmit(values, collection); + await onSubmit(values); + onSubmitSuccess?.(); }, - [collection, onSubmit], + [onSubmit, onSubmitSuccess], ); const handleArchive = useCallback(async () => { - await onArchive(timeline, collection); - }, [timeline, collection, onArchive]); + await onArchive(timeline); + onArchiveSuccess?.(); + }, [timeline, onArchive, onArchiveSuccess]); return ( <div> @@ -49,7 +54,7 @@ const EditTimelineModal = ({ onSubmit={handleSubmit} onClose={onCancel} footerExtraButtons={ - <ModalDangerButton type="button" borderless onClick={handleArchive}> + <ModalDangerButton onClick={handleArchive}> {t`Archive timeline and all events`} </ModalDangerButton> } diff --git a/frontend/src/metabase/timelines/collections/components/EditTimelineModal/EditTimelineModal.unit.spec.tsx b/frontend/src/metabase/timelines/common/components/EditTimelineModal/EditTimelineModal.unit.spec.tsx similarity index 87% rename from frontend/src/metabase/timelines/collections/components/EditTimelineModal/EditTimelineModal.unit.spec.tsx rename to frontend/src/metabase/timelines/common/components/EditTimelineModal/EditTimelineModal.unit.spec.tsx index 9ad1d233865..6a79e56ea25 100644 --- a/frontend/src/metabase/timelines/collections/components/EditTimelineModal/EditTimelineModal.unit.spec.tsx +++ b/frontend/src/metabase/timelines/common/components/EditTimelineModal/EditTimelineModal.unit.spec.tsx @@ -1,10 +1,7 @@ import React, { FormHTMLAttributes } from "react"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { - createMockCollection, - createMockTimeline, -} from "metabase-types/api/mocks"; +import { createMockTimeline } from "metabase-types/api/mocks"; import EditTimelineModal, { EditTimelineModalProps } from "./EditTimelineModal"; const FormMock = (props: FormHTMLAttributes<HTMLFormElement>) => ( @@ -30,7 +27,6 @@ const getProps = ( opts?: Partial<EditTimelineModalProps>, ): EditTimelineModalProps => ({ timeline: createMockTimeline(), - collection: createMockCollection(), onSubmit: jest.fn(), onArchive: jest.fn(), onCancel: jest.fn(), diff --git a/frontend/src/metabase/timelines/collections/components/EditTimelineModal/index.ts b/frontend/src/metabase/timelines/common/components/EditTimelineModal/index.ts similarity index 100% rename from frontend/src/metabase/timelines/collections/components/EditTimelineModal/index.ts rename to frontend/src/metabase/timelines/common/components/EditTimelineModal/index.ts diff --git a/frontend/src/metabase/timelines/collections/components/NewEventModal/NewEventModal.styled.tsx b/frontend/src/metabase/timelines/common/components/ModalBody/ModalBody.styled.tsx similarity index 61% rename from frontend/src/metabase/timelines/collections/components/NewEventModal/NewEventModal.styled.tsx rename to frontend/src/metabase/timelines/common/components/ModalBody/ModalBody.styled.tsx index 591d8b9a749..080d4ee70f5 100644 --- a/frontend/src/metabase/timelines/collections/components/NewEventModal/NewEventModal.styled.tsx +++ b/frontend/src/metabase/timelines/common/components/ModalBody/ModalBody.styled.tsx @@ -1,5 +1,5 @@ import styled from "@emotion/styled"; -export const ModalBody = styled.div` +export const BodyRoot = styled.div` padding: 2rem; `; diff --git a/frontend/src/metabase/timelines/common/components/ModalBody/ModalBody.tsx b/frontend/src/metabase/timelines/common/components/ModalBody/ModalBody.tsx new file mode 100644 index 00000000000..f1c3999e058 --- /dev/null +++ b/frontend/src/metabase/timelines/common/components/ModalBody/ModalBody.tsx @@ -0,0 +1,12 @@ +import React, { ReactNode } from "react"; +import { BodyRoot } from "./ModalBody.styled"; + +export interface ModalBodyProps { + children?: ReactNode; +} + +const ModalBody = ({ children }: ModalBodyProps): JSX.Element => { + return <BodyRoot>{children}</BodyRoot>; +}; + +export default ModalBody; diff --git a/frontend/src/metabase/timelines/common/components/ModalBody/index.ts b/frontend/src/metabase/timelines/common/components/ModalBody/index.ts new file mode 100644 index 00000000000..a359bbd630d --- /dev/null +++ b/frontend/src/metabase/timelines/common/components/ModalBody/index.ts @@ -0,0 +1 @@ +export { default } from "./ModalBody"; diff --git a/frontend/src/metabase/timelines/questions/components/EditEventModal/EditEventModal.styled.tsx b/frontend/src/metabase/timelines/common/components/ModalDangerButton/ModalDangerButton.styled.tsx similarity index 73% rename from frontend/src/metabase/timelines/questions/components/EditEventModal/EditEventModal.styled.tsx rename to frontend/src/metabase/timelines/common/components/ModalDangerButton/ModalDangerButton.styled.tsx index 5d29609249e..3815f182af9 100644 --- a/frontend/src/metabase/timelines/questions/components/EditEventModal/EditEventModal.styled.tsx +++ b/frontend/src/metabase/timelines/common/components/ModalDangerButton/ModalDangerButton.styled.tsx @@ -2,11 +2,7 @@ import styled from "@emotion/styled"; import { color } from "metabase/lib/colors"; import Button from "metabase/core/components/Button/Button"; -export const ModalBody = styled.div` - padding: 2rem; -`; - -export const ModalDangerButton = styled(Button)` +export const DangerButton = styled(Button)` color: ${color("danger")}; padding-left: 0; padding-right: 0; diff --git a/frontend/src/metabase/timelines/common/components/ModalDangerButton/ModalDangerButton.tsx b/frontend/src/metabase/timelines/common/components/ModalDangerButton/ModalDangerButton.tsx new file mode 100644 index 00000000000..819e8c8ebcd --- /dev/null +++ b/frontend/src/metabase/timelines/common/components/ModalDangerButton/ModalDangerButton.tsx @@ -0,0 +1,20 @@ +import React, { MouseEvent, ReactNode } from "react"; +import { DangerButton } from "./ModalDangerButton.styled"; + +export interface ModalDangerButtonProps { + children?: ReactNode; + onClick?: (event: MouseEvent) => void; +} + +const ModalDangerButton = ({ + children, + onClick, +}: ModalDangerButtonProps): JSX.Element => { + return ( + <DangerButton type="button" borderless onClick={onClick}> + {children} + </DangerButton> + ); +}; + +export default ModalDangerButton; diff --git a/frontend/src/metabase/timelines/common/components/ModalDangerButton/index.ts b/frontend/src/metabase/timelines/common/components/ModalDangerButton/index.ts new file mode 100644 index 00000000000..1138f188221 --- /dev/null +++ b/frontend/src/metabase/timelines/common/components/ModalDangerButton/index.ts @@ -0,0 +1 @@ +export { default } from "./ModalDangerButton"; diff --git a/frontend/src/metabase/timelines/common/components/ModalFooter/ModalFooter.styled.tsx b/frontend/src/metabase/timelines/common/components/ModalFooter/ModalFooter.styled.tsx new file mode 100644 index 00000000000..5cc8c122818 --- /dev/null +++ b/frontend/src/metabase/timelines/common/components/ModalFooter/ModalFooter.styled.tsx @@ -0,0 +1,12 @@ +import styled from "@emotion/styled"; + +export interface FooterProps { + hasPadding?: boolean; +} + +export const FooterRoot = styled.div<FooterProps>` + display: flex; + gap: 1rem; + justify-content: flex-end; + padding: ${props => (props.hasPadding ? "2rem" : "0")} 2rem 2rem; +`; diff --git a/frontend/src/metabase/timelines/common/components/ModalFooter/ModalFooter.tsx b/frontend/src/metabase/timelines/common/components/ModalFooter/ModalFooter.tsx new file mode 100644 index 00000000000..2c3b4a27ebf --- /dev/null +++ b/frontend/src/metabase/timelines/common/components/ModalFooter/ModalFooter.tsx @@ -0,0 +1,16 @@ +import React, { ReactNode } from "react"; +import { FooterRoot } from "./ModalFooter.styled"; + +export interface ModalFooterProps { + hasPadding?: boolean; + children?: ReactNode; +} + +const ModalFooter = ({ + hasPadding, + children, +}: ModalFooterProps): JSX.Element => { + return <FooterRoot hasPadding={hasPadding}>{children}</FooterRoot>; +}; + +export default ModalFooter; diff --git a/frontend/src/metabase/timelines/common/components/ModalFooter/index.ts b/frontend/src/metabase/timelines/common/components/ModalFooter/index.ts new file mode 100644 index 00000000000..6a36137070f --- /dev/null +++ b/frontend/src/metabase/timelines/common/components/ModalFooter/index.ts @@ -0,0 +1 @@ +export { default } from "./ModalFooter"; diff --git a/frontend/src/metabase/timelines/common/components/MoveEventModal/MoveEventModal.styled.tsx b/frontend/src/metabase/timelines/common/components/MoveEventModal/MoveEventModal.styled.tsx new file mode 100644 index 00000000000..f03ddd0b9ed --- /dev/null +++ b/frontend/src/metabase/timelines/common/components/MoveEventModal/MoveEventModal.styled.tsx @@ -0,0 +1,21 @@ +import styled from "@emotion/styled"; + +export const ModalRoot = styled.div` + display: flex; + flex-direction: column; + min-height: 573px; + max-height: 90vh; +`; + +export interface ModalBodyProps { + isTopAligned?: boolean; +} + +export const ModalBody = styled.div<ModalBodyProps>` + display: flex; + flex-direction: column; + flex: 1 1 auto; + margin: 1rem 0; + padding: 1rem 2rem; + overflow-y: auto; +`; diff --git a/frontend/src/metabase/timelines/common/components/MoveEventModal/MoveEventModal.tsx b/frontend/src/metabase/timelines/common/components/MoveEventModal/MoveEventModal.tsx new file mode 100644 index 00000000000..9e5d35a3c06 --- /dev/null +++ b/frontend/src/metabase/timelines/common/components/MoveEventModal/MoveEventModal.tsx @@ -0,0 +1,67 @@ +import React, { useCallback, useMemo, useState } from "react"; +import { t } from "ttag"; +import { getSortedTimelines } from "metabase/lib/timelines"; +import Button from "metabase/core/components/Button/Button"; +import { Collection, Timeline, TimelineEvent } from "metabase-types/api"; +import ModalHeader from "../ModalHeader"; +import ModalFooter from "../ModalFooter"; +import TimelinePicker from "../TimelinePicker"; +import { ModalRoot, ModalBody } from "./MoveEventModal.styled"; + +export interface MoveEventModalProps { + event: TimelineEvent; + timelines: Timeline[]; + collection?: Collection; + onSubmit: ( + event: TimelineEvent, + newTimeline?: Timeline, + oldTimeline?: Timeline, + ) => void; + onSubmitSuccess?: () => void; + onCancel?: () => void; + onClose?: () => void; +} + +const MoveEventModal = ({ + event, + timelines, + collection, + onSubmit, + onSubmitSuccess, + onCancel, + onClose, +}: MoveEventModalProps): JSX.Element => { + const oldTimeline = timelines.find(t => t.id === event.timeline_id); + const [newTimeline, setNewTimeline] = useState(oldTimeline); + const isEnabled = newTimeline?.id !== oldTimeline?.id; + + const sortedTimelines = useMemo(() => { + return getSortedTimelines(timelines, collection); + }, [timelines, collection]); + + const handleSubmit = useCallback(async () => { + await onSubmit(event, newTimeline, oldTimeline); + onSubmitSuccess?.(); + }, [event, newTimeline, oldTimeline, onSubmit, onSubmitSuccess]); + + return ( + <ModalRoot> + <ModalHeader title={t`Move ${event.name}`} onClose={onClose} /> + <ModalBody> + <TimelinePicker + value={newTimeline} + options={sortedTimelines} + onChange={setNewTimeline} + /> + </ModalBody> + <ModalFooter> + <Button onClick={onCancel}>{t`Cancel`}</Button> + <Button primary disabled={!isEnabled} onClick={handleSubmit}> + {t`Move`} + </Button> + </ModalFooter> + </ModalRoot> + ); +}; + +export default MoveEventModal; diff --git a/frontend/src/metabase/timelines/common/components/MoveEventModal/MoveEventModal.unit.spec.tsx b/frontend/src/metabase/timelines/common/components/MoveEventModal/MoveEventModal.unit.spec.tsx new file mode 100644 index 00000000000..a8f7e08399e --- /dev/null +++ b/frontend/src/metabase/timelines/common/components/MoveEventModal/MoveEventModal.unit.spec.tsx @@ -0,0 +1,43 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { + createMockTimeline, + createMockTimelineEvent, +} from "metabase-types/api/mocks"; +import MoveEventModal, { MoveEventModalProps } from "./MoveEventModal"; + +describe("MoveEventModal", () => { + it("should move an event to a different timeline", () => { + const event = createMockTimelineEvent({ timeline_id: 1 }); + const oldTimeline = createMockTimeline({ id: 1, name: "Builds" }); + const newTimeline = createMockTimeline({ id: 2, name: "Releases" }); + + const props = getProps({ + event, + timelines: [oldTimeline, newTimeline], + }); + + render(<MoveEventModal {...props} />); + expect(screen.getByText("Move")).toBeDisabled(); + + userEvent.click(screen.getByText(newTimeline.name)); + userEvent.click(screen.getByText("Move")); + expect(props.onSubmit).toHaveBeenLastCalledWith( + event, + newTimeline, + oldTimeline, + ); + }); +}); + +const getProps = ( + opts?: Partial<MoveEventModalProps>, +): MoveEventModalProps => ({ + event: createMockTimelineEvent(), + timelines: [], + onSubmit: jest.fn(), + onCancel: jest.fn(), + onClose: jest.fn(), + ...opts, +}); diff --git a/frontend/src/metabase/timelines/common/components/MoveEventModal/index.ts b/frontend/src/metabase/timelines/common/components/MoveEventModal/index.ts new file mode 100644 index 00000000000..369d23ed2ee --- /dev/null +++ b/frontend/src/metabase/timelines/common/components/MoveEventModal/index.ts @@ -0,0 +1 @@ +export { default } from "./MoveEventModal"; diff --git a/frontend/src/metabase/timelines/questions/components/NewEventModal/NewEventModal.tsx b/frontend/src/metabase/timelines/common/components/NewEventModal/NewEventModal.tsx similarity index 68% rename from frontend/src/metabase/timelines/questions/components/NewEventModal/NewEventModal.tsx rename to frontend/src/metabase/timelines/common/components/NewEventModal/NewEventModal.tsx index b41bea50eb7..41c8fb91708 100644 --- a/frontend/src/metabase/timelines/questions/components/NewEventModal/NewEventModal.tsx +++ b/frontend/src/metabase/timelines/common/components/NewEventModal/NewEventModal.tsx @@ -4,23 +4,38 @@ import { getDefaultTimezone } from "metabase/lib/time"; import { getDefaultTimelineIcon } from "metabase/lib/timelines"; import Form from "metabase/containers/Form"; import forms from "metabase/entities/timeline-events/forms"; -import ModalHeader from "metabase/timelines/common/components/ModalHeader"; -import { Collection, Timeline, TimelineEvent } from "metabase-types/api"; -import { ModalBody } from "./NewEventModal.styled"; +import { + Collection, + Timeline, + TimelineEvent, + TimelineEventSource, +} from "metabase-types/api"; +import ModalBody from "../ModalBody"; +import ModalHeader from "../ModalHeader"; export interface NewEventModalProps { - cardId?: number; timelines?: Timeline[]; - collection: Collection; - onSubmit: (values: Partial<TimelineEvent>, collection: Collection) => void; + collection?: Collection; + cardId?: number; + source: TimelineEventSource; + onSubmit: ( + values: Partial<TimelineEvent>, + collection?: Collection, + timeline?: Timeline, + ) => void; + onSubmitSuccess?: () => void; + onCancel?: () => void; onClose?: () => void; } const NewEventModal = ({ - cardId, timelines = [], collection, + cardId, + source, onSubmit, + onSubmitSuccess, + onCancel, onClose, }: NewEventModalProps): JSX.Element => { const availableTimelines = useMemo(() => { @@ -39,18 +54,19 @@ const NewEventModal = ({ timeline_id: defaultTimeline ? defaultTimeline.id : null, icon: hasOneTimeline ? defaultTimeline.icon : getDefaultTimelineIcon(), timezone: getDefaultTimezone(), - source: "question", + source, question_id: cardId, time_matters: false, }; - }, [cardId, availableTimelines]); + }, [cardId, source, availableTimelines]); const handleSubmit = useCallback( async (values: Partial<TimelineEvent>) => { - await onSubmit(values, collection); - onClose?.(); + const timeline = timelines.find(t => t.id === values.timeline_id); + await onSubmit(values, collection, timeline); + onSubmitSuccess?.(); }, - [collection, onSubmit, onClose], + [collection, timelines, onSubmit, onSubmitSuccess], ); return ( @@ -62,7 +78,7 @@ const NewEventModal = ({ initialValues={initialValues} isModal={true} onSubmit={handleSubmit} - onClose={onClose} + onClose={onCancel} /> </ModalBody> </div> diff --git a/frontend/src/metabase/timelines/collections/components/NewEventModal/index.ts b/frontend/src/metabase/timelines/common/components/NewEventModal/index.ts similarity index 100% rename from frontend/src/metabase/timelines/collections/components/NewEventModal/index.ts rename to frontend/src/metabase/timelines/common/components/NewEventModal/index.ts diff --git a/frontend/src/metabase/timelines/collections/components/NewTimelineModal/NewTimelineModal.tsx b/frontend/src/metabase/timelines/common/components/NewTimelineModal/NewTimelineModal.tsx similarity index 85% rename from frontend/src/metabase/timelines/collections/components/NewTimelineModal/NewTimelineModal.tsx rename to frontend/src/metabase/timelines/common/components/NewTimelineModal/NewTimelineModal.tsx index 2319f8dd973..a614269beb3 100644 --- a/frontend/src/metabase/timelines/collections/components/NewTimelineModal/NewTimelineModal.tsx +++ b/frontend/src/metabase/timelines/common/components/NewTimelineModal/NewTimelineModal.tsx @@ -4,20 +4,22 @@ import Form from "metabase/containers/Form"; import forms from "metabase/entities/timelines/forms"; import { getDefaultTimelineIcon } from "metabase/lib/timelines"; import { canonicalCollectionId } from "metabase/collections/utils"; -import ModalHeader from "metabase/timelines/common/components/ModalHeader"; import { Collection, Timeline } from "metabase-types/api"; -import { ModalBody } from "./NewTimelineModal.styled"; +import ModalBody from "../ModalBody"; +import ModalHeader from "../ModalHeader"; export interface NewTimelineModalProps { collection: Collection; onSubmit: (values: Partial<Timeline>, collection: Collection) => void; - onCancel: () => void; + onSubmitSuccess?: () => void; + onCancel?: () => void; onClose?: () => void; } const NewTimelineModal = ({ collection, onSubmit, + onSubmitSuccess, onCancel, onClose, }: NewTimelineModalProps): JSX.Element => { @@ -32,8 +34,9 @@ const NewTimelineModal = ({ const handleSubmit = useCallback( async (values: Partial<Timeline>) => { await onSubmit(values, collection); + onSubmitSuccess?.(); }, - [collection, onSubmit], + [collection, onSubmit, onSubmitSuccess], ); return ( diff --git a/frontend/src/metabase/timelines/collections/components/NewTimelineModal/NewTimelineModal.unit.spec.tsx b/frontend/src/metabase/timelines/common/components/NewTimelineModal/NewTimelineModal.unit.spec.tsx similarity index 100% rename from frontend/src/metabase/timelines/collections/components/NewTimelineModal/NewTimelineModal.unit.spec.tsx rename to frontend/src/metabase/timelines/common/components/NewTimelineModal/NewTimelineModal.unit.spec.tsx diff --git a/frontend/src/metabase/timelines/collections/components/NewTimelineModal/index.ts b/frontend/src/metabase/timelines/common/components/NewTimelineModal/index.ts similarity index 100% rename from frontend/src/metabase/timelines/collections/components/NewTimelineModal/index.ts rename to frontend/src/metabase/timelines/common/components/NewTimelineModal/index.ts diff --git a/frontend/src/metabase/timelines/common/components/TimelinePicker/TimelinePicker.stories.tsx b/frontend/src/metabase/timelines/common/components/TimelinePicker/TimelinePicker.stories.tsx new file mode 100644 index 00000000000..1d53cba56f8 --- /dev/null +++ b/frontend/src/metabase/timelines/common/components/TimelinePicker/TimelinePicker.stories.tsx @@ -0,0 +1,45 @@ +import React, { useState } from "react"; +import { ComponentStory } from "@storybook/react"; +import { + createMockCollection, + createMockTimeline, +} from "metabase-types/api/mocks"; +import TimelinePicker from "./TimelinePicker"; +import { Timeline } from "metabase-types/api"; + +export default { + title: "Timelines/TimelinePicker", + component: TimelinePicker, +}; + +const Template: ComponentStory<typeof TimelinePicker> = args => { + const [value, setValue] = useState<Timeline>(); + return <TimelinePicker {...args} value={value} onChange={setValue} />; +}; + +export const Default = Template.bind({}); +Default.args = { + options: [ + createMockTimeline({ + id: 1, + name: "Product communications", + collection: createMockCollection({ + name: "Our analytics", + }), + }), + createMockTimeline({ + id: 2, + name: "Releases", + collection: createMockCollection({ + name: "Our analytics", + }), + }), + createMockTimeline({ + id: 3, + name: "Our analytics events", + collection: createMockCollection({ + name: "Our analytics", + }), + }), + ], +}; diff --git a/frontend/src/metabase/timelines/common/components/TimelinePicker/TimelinePicker.styled.tsx b/frontend/src/metabase/timelines/common/components/TimelinePicker/TimelinePicker.styled.tsx new file mode 100644 index 00000000000..45dc646c88e --- /dev/null +++ b/frontend/src/metabase/timelines/common/components/TimelinePicker/TimelinePicker.styled.tsx @@ -0,0 +1,89 @@ +import styled from "@emotion/styled"; +import { color } from "metabase/lib/colors"; +import Icon from "metabase/components/Icon"; +import { css } from "@emotion/react"; + +export const ListRoot = styled.div` + display: block; +`; + +export const CardBody = styled.div` + flex: 1 1 auto; + margin: 0 1rem; + min-width: 0; +`; + +export const CardTitle = styled.div` + color: ${color("text-dark")}; + font-size: 1rem; + font-weight: bold; + margin-bottom: 0.125rem; + word-wrap: break-word; +`; + +export const CardDescription = styled.div` + color: ${color("text-medium")}; + font-size: 0.75rem; + word-wrap: break-word; +`; + +export const CardIcon = styled(Icon)` + color: ${color("text-dark")}; + width: 1rem; + height: 1rem; +`; + +export const CardIconContainer = styled.div` + display: flex; + flex: 0 0 auto; + justify-content: center; + align-items: center; + width: 2rem; + height: 2rem; + border: 1px solid ${color("border")}; + border-radius: 1rem; +`; + +export const CardAside = styled.div` + flex: 0 0 auto; + color: ${color("text-dark")}; + font-size: 0.75rem; +`; + +export interface CardProps { + isSelected?: boolean; +} + +const selectedStyles = css` + background-color: ${color("brand")}; + + ${CardTitle}, ${CardDescription}, ${CardAside} { + color: ${color("white")}; + } + + ${CardIcon} { + color: ${color("brand")}; + } + + ${CardIconContainer} { + border-color: ${color("white")}; + background-color: ${color("white")}; + } +`; + +export const CardRoot = styled.div<CardProps>` + display: flex; + align-items: center; + padding: 1rem; + border-radius: 0.25rem; + cursor: pointer; + ${props => props.isSelected && selectedStyles} + + &:hover { + ${selectedStyles} + } + + &:not(:last-child) { + margin-bottom: 0.5rem; + } +`; diff --git a/frontend/src/metabase/timelines/common/components/TimelinePicker/TimelinePicker.tsx b/frontend/src/metabase/timelines/common/components/TimelinePicker/TimelinePicker.tsx new file mode 100644 index 00000000000..cba16bbb4fd --- /dev/null +++ b/frontend/src/metabase/timelines/common/components/TimelinePicker/TimelinePicker.tsx @@ -0,0 +1,74 @@ +import React, { useCallback } from "react"; +import { msgid, ngettext } from "ttag"; +import { getEventCount } from "metabase/lib/timelines"; +import { Timeline } from "metabase-types/api"; +import { + CardAside, + CardBody, + CardDescription, + CardIcon, + CardIconContainer, + CardRoot, + CardTitle, + ListRoot, +} from "./TimelinePicker.styled"; + +export interface TimelinePickerProps { + value?: Timeline; + options: Timeline[]; + onChange?: (value: Timeline) => void; +} + +const TimelinePicker = ({ value, options, onChange }: TimelinePickerProps) => { + return ( + <ListRoot> + {options.map(option => ( + <TimelineCard + key={option.id} + timeline={option} + isSelected={option.id === value?.id} + onChange={onChange} + /> + ))} + </ListRoot> + ); +}; + +interface TimelineCardProps { + timeline: Timeline; + isSelected: boolean; + onChange?: (value: Timeline) => void; +} + +const TimelineCard = ({ + timeline, + isSelected, + onChange, +}: TimelineCardProps): JSX.Element => { + const eventCount = getEventCount(timeline); + + const handleClick = useCallback(() => { + onChange?.(timeline); + }, [timeline, onChange]); + + return ( + <CardRoot key={timeline.id} isSelected={isSelected} onClick={handleClick}> + <CardIconContainer> + <CardIcon name={timeline.icon} /> + </CardIconContainer> + <CardBody> + <CardTitle>{timeline.name}</CardTitle> + <CardDescription>{timeline.description}</CardDescription> + </CardBody> + <CardAside> + {ngettext( + msgid`${eventCount} event`, + `${eventCount} events`, + eventCount, + )} + </CardAside> + </CardRoot> + ); +}; + +export default TimelinePicker; diff --git a/frontend/src/metabase/timelines/common/components/TimelinePicker/index.ts b/frontend/src/metabase/timelines/common/components/TimelinePicker/index.ts new file mode 100644 index 00000000000..0878b8e1497 --- /dev/null +++ b/frontend/src/metabase/timelines/common/components/TimelinePicker/index.ts @@ -0,0 +1 @@ +export { default } from "./TimelinePicker"; diff --git a/frontend/src/metabase/timelines/questions/components/EditEventModal/EditEventModal.tsx b/frontend/src/metabase/timelines/questions/components/EditEventModal/EditEventModal.tsx deleted file mode 100644 index 6b74ca85c02..00000000000 --- a/frontend/src/metabase/timelines/questions/components/EditEventModal/EditEventModal.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import React, { useCallback, useMemo } from "react"; -import { t } from "ttag"; -import Form from "metabase/containers/Form"; -import forms from "metabase/entities/timeline-events/forms"; -import { TimelineEvent } from "metabase-types/api"; -import ModalHeader from "metabase/timelines/common/components/ModalHeader"; -import { ModalBody, ModalDangerButton } from "./EditEventModal.styled"; - -export interface EditEventModalProps { - event: TimelineEvent; - onSubmit: (event: TimelineEvent) => void; - onArchive: (event: TimelineEvent) => void; - onClose?: () => void; -} - -const EditEventModal = ({ - event, - onSubmit, - onArchive, - onClose, -}: EditEventModalProps): JSX.Element => { - const form = useMemo(() => forms.details(), []); - - const handleSubmit = useCallback( - async (event: TimelineEvent) => { - await onSubmit(event); - onClose?.(); - }, - [onSubmit, onClose], - ); - - const handleArchive = useCallback(async () => { - await onArchive(event); - onClose?.(); - }, [event, onArchive, onClose]); - - return ( - <div> - <ModalHeader title={t`Edit event`} onClose={onClose} /> - <ModalBody> - <Form - form={form} - initialValues={event} - isModal={true} - onSubmit={handleSubmit} - onClose={onClose} - footerExtraButtons={ - <ModalDangerButton type="button" borderless onClick={handleArchive}> - {t`Archive event`} - </ModalDangerButton> - } - /> - </ModalBody> - </div> - ); -}; - -export default EditEventModal; diff --git a/frontend/src/metabase/timelines/questions/components/EditEventModal/EditEventModal.unit.spec.tsx b/frontend/src/metabase/timelines/questions/components/EditEventModal/EditEventModal.unit.spec.tsx deleted file mode 100644 index 70780fdb895..00000000000 --- a/frontend/src/metabase/timelines/questions/components/EditEventModal/EditEventModal.unit.spec.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import React, { FormHTMLAttributes } from "react"; -import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { createMockTimelineEvent } from "metabase-types/api/mocks"; -import EditEventModal, { EditEventModalProps } from "./EditEventModal"; - -const FormMock = (props: FormHTMLAttributes<HTMLFormElement>) => ( - <form {...props}> - <button>Update</button> - </form> -); - -jest.mock("metabase/containers/Form", () => FormMock); - -describe("EditEventModal", () => { - it("should submit modal", () => { - const props = getProps(); - - render(<EditEventModal {...props} />); - userEvent.click(screen.getByText("Update")); - - expect(props.onSubmit).toHaveBeenCalled(); - }); -}); - -const getProps = ( - opts?: Partial<EditEventModalProps>, -): EditEventModalProps => ({ - event: createMockTimelineEvent(), - onSubmit: jest.fn(), - onArchive: jest.fn(), - onClose: jest.fn(), - ...opts, -}); diff --git a/frontend/src/metabase/timelines/questions/components/EditEventModal/index.ts b/frontend/src/metabase/timelines/questions/components/EditEventModal/index.ts deleted file mode 100644 index 993e930fb2e..00000000000 --- a/frontend/src/metabase/timelines/questions/components/EditEventModal/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from "./EditEventModal"; diff --git a/frontend/src/metabase/timelines/questions/components/EventCard/EventCard.tsx b/frontend/src/metabase/timelines/questions/components/EventCard/EventCard.tsx index b38752870b4..21665a5b683 100644 --- a/frontend/src/metabase/timelines/questions/components/EventCard/EventCard.tsx +++ b/frontend/src/metabase/timelines/questions/components/EventCard/EventCard.tsx @@ -23,6 +23,7 @@ export interface EventCardProps { timeline: Timeline; isSelected?: boolean; onEdit?: (event: TimelineEvent) => void; + onMove?: (event: TimelineEvent) => void; onArchive?: (event: TimelineEvent) => void; onToggle?: (event: TimelineEvent, isSelected: boolean) => void; } @@ -32,11 +33,12 @@ const EventCard = ({ timeline, isSelected, onEdit, + onMove, onArchive, onToggle, }: EventCardProps): JSX.Element => { const selectedRef = useScrollOnMount(); - const menuItems = getMenuItems(event, timeline, onEdit, onArchive); + const menuItems = getMenuItems(event, timeline, onEdit, onMove, onArchive); const dateMessage = getDateMessage(event); const creatorMessage = getCreatorMessage(event); @@ -78,6 +80,7 @@ const getMenuItems = ( event: TimelineEvent, timeline: Timeline, onEdit?: (event: TimelineEvent) => void, + onMove?: (event: TimelineEvent) => void, onArchive?: (event: TimelineEvent) => void, ) => { if (!timeline.collection?.can_write) { @@ -89,6 +92,10 @@ const getMenuItems = ( title: t`Edit event`, action: () => onEdit?.(event), }, + { + title: t`Move event`, + action: () => onMove?.(event), + }, { title: t`Archive event`, action: () => onArchive?.(event), diff --git a/frontend/src/metabase/timelines/questions/components/NewEventModal/NewEventModal.styled.tsx b/frontend/src/metabase/timelines/questions/components/NewEventModal/NewEventModal.styled.tsx deleted file mode 100644 index 591d8b9a749..00000000000 --- a/frontend/src/metabase/timelines/questions/components/NewEventModal/NewEventModal.styled.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import styled from "@emotion/styled"; - -export const ModalBody = styled.div` - padding: 2rem; -`; diff --git a/frontend/src/metabase/timelines/questions/components/NewEventModal/NewEventModal.unit.spec.tsx b/frontend/src/metabase/timelines/questions/components/NewEventModal/NewEventModal.unit.spec.tsx deleted file mode 100644 index 56524ff81ae..00000000000 --- a/frontend/src/metabase/timelines/questions/components/NewEventModal/NewEventModal.unit.spec.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import React, { FormHTMLAttributes } from "react"; -import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { createMockCollection } from "metabase-types/api/mocks"; -import NewEventModal, { NewEventModalProps } from "./NewEventModal"; - -const FormMock = (props: FormHTMLAttributes<HTMLFormElement>) => ( - <form {...props}> - <button>Create</button> - </form> -); - -jest.mock("metabase/containers/Form", () => FormMock); - -describe("NewEventModal", () => { - it("should submit modal", () => { - const props = getProps(); - - render(<NewEventModal {...props} />); - userEvent.click(screen.getByText("Create")); - - expect(props.onSubmit).toHaveBeenCalled(); - }); -}); - -const getProps = (opts?: Partial<NewEventModalProps>): NewEventModalProps => ({ - collection: createMockCollection(), - onSubmit: jest.fn(), - onClose: jest.fn(), - ...opts, -}); diff --git a/frontend/src/metabase/timelines/questions/components/NewEventModal/index.ts b/frontend/src/metabase/timelines/questions/components/NewEventModal/index.ts deleted file mode 100644 index 58a19000dc2..00000000000 --- a/frontend/src/metabase/timelines/questions/components/NewEventModal/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from "./NewEventModal"; diff --git a/frontend/src/metabase/timelines/questions/components/TimelineCard/TimelineCard.tsx b/frontend/src/metabase/timelines/questions/components/TimelineCard/TimelineCard.tsx index 40e5c29fb1d..247321ccf24 100644 --- a/frontend/src/metabase/timelines/questions/components/TimelineCard/TimelineCard.tsx +++ b/frontend/src/metabase/timelines/questions/components/TimelineCard/TimelineCard.tsx @@ -26,6 +26,7 @@ export interface TimelineCardProps { isVisible?: boolean; selectedEventIds?: number[]; onEditEvent?: (event: TimelineEvent) => void; + onMoveEvent?: (event: TimelineEvent) => void; onArchiveEvent?: (event: TimelineEvent) => void; onToggleEvent?: (event: TimelineEvent, isSelected: boolean) => void; onToggleTimeline?: (timeline: Timeline, isVisible: boolean) => void; @@ -37,6 +38,7 @@ const TimelineCard = ({ isVisible, selectedEventIds = [], onEditEvent, + onMoveEvent, onArchiveEvent, onToggleEvent, onToggleTimeline, @@ -90,6 +92,7 @@ const TimelineCard = ({ timeline={timeline} isSelected={selectedEventIds.includes(event.id)} onEdit={onEditEvent} + onMove={onMoveEvent} onArchive={onArchiveEvent} onToggle={onToggleEvent} /> diff --git a/frontend/src/metabase/timelines/questions/components/TimelineList/TimelineList.tsx b/frontend/src/metabase/timelines/questions/components/TimelineList/TimelineList.tsx index 588078b3b00..7ff2ff9e3e8 100644 --- a/frontend/src/metabase/timelines/questions/components/TimelineList/TimelineList.tsx +++ b/frontend/src/metabase/timelines/questions/components/TimelineList/TimelineList.tsx @@ -7,6 +7,7 @@ export interface TimelineListProps { visibleTimelineIds?: number[]; selectedEventIds?: number[]; onEditEvent?: (event: TimelineEvent) => void; + onMoveEvent?: (event: TimelineEvent) => void; onArchiveEvent?: (event: TimelineEvent) => void; onToggleEvent?: (event: TimelineEvent, isSelected: boolean) => void; onToggleTimeline?: (timeline: Timeline, isVisible: boolean) => void; @@ -17,6 +18,7 @@ const TimelineList = ({ visibleTimelineIds = [], selectedEventIds = [], onEditEvent, + onMoveEvent, onArchiveEvent, onToggleEvent, onToggleTimeline, @@ -32,6 +34,7 @@ const TimelineList = ({ selectedEventIds={selectedEventIds} onToggleTimeline={onToggleTimeline} onEditEvent={onEditEvent} + onMoveEvent={onMoveEvent} onToggleEvent={onToggleEvent} onArchiveEvent={onArchiveEvent} /> diff --git a/frontend/src/metabase/timelines/questions/components/TimelinePanel/TimelinePanel.tsx b/frontend/src/metabase/timelines/questions/components/TimelinePanel/TimelinePanel.tsx index cafeb38927f..cb80623be7f 100644 --- a/frontend/src/metabase/timelines/questions/components/TimelinePanel/TimelinePanel.tsx +++ b/frontend/src/metabase/timelines/questions/components/TimelinePanel/TimelinePanel.tsx @@ -13,6 +13,7 @@ export interface TimelinePanelProps { selectedEventIds?: number[]; onNewEvent?: () => void; onEditEvent?: (event: TimelineEvent) => void; + onMoveEvent?: (event: TimelineEvent) => void; onArchiveEvent?: (event: TimelineEvent) => void; onToggleEvent?: (event: TimelineEvent, isSelected: boolean) => void; onToggleTimeline?: (timeline: Timeline, isVisible: boolean) => void; @@ -25,6 +26,7 @@ const TimelinePanel = ({ selectedEventIds, onNewEvent, onEditEvent, + onMoveEvent, onArchiveEvent, onToggleEvent, onToggleTimeline, @@ -46,6 +48,7 @@ const TimelinePanel = ({ selectedEventIds={selectedEventIds} onToggleTimeline={onToggleTimeline} onEditEvent={onEditEvent} + onMoveEvent={onMoveEvent} onToggleEvent={onToggleEvent} onArchiveEvent={onArchiveEvent} /> diff --git a/frontend/src/metabase/timelines/questions/containers/EditEventModal/EditEventModal.tsx b/frontend/src/metabase/timelines/questions/containers/EditEventModal/EditEventModal.tsx index 67327b68842..dff7532639c 100644 --- a/frontend/src/metabase/timelines/questions/containers/EditEventModal/EditEventModal.tsx +++ b/frontend/src/metabase/timelines/questions/containers/EditEventModal/EditEventModal.tsx @@ -3,12 +3,13 @@ import { t } from "ttag"; import _ from "underscore"; import TimelineEvents from "metabase/entities/timeline-events"; import { addUndo } from "metabase/redux/undo"; -import { TimelineEvent } from "metabase-types/api"; +import EditEventModal from "metabase/timelines/common/components/EditEventModal"; +import { Timeline, TimelineEvent } from "metabase-types/api"; import { State } from "metabase-types/store"; -import EditEventModal from "../../components/EditEventModal"; -export interface EditEventModalProps { +interface EditEventModalProps { eventId: number; + onClose?: () => void; } const timelineEventProps = { @@ -16,8 +17,14 @@ const timelineEventProps = { entityAlias: "event", }; +const mapStateToProps = (state: State, { onClose }: EditEventModalProps) => ({ + onSubmitSuccess: onClose, + onArchiveSuccess: onClose, + onCancel: onClose, +}); + const mapDispatchToProps = (dispatch: any) => ({ - onSubmit: async (event: TimelineEvent) => { + onSubmit: async (event: TimelineEvent, timeline?: Timeline) => { await dispatch(TimelineEvents.actions.update(event)); dispatch(addUndo({ message: t`Updated event` })); }, @@ -28,5 +35,5 @@ const mapDispatchToProps = (dispatch: any) => ({ export default _.compose( TimelineEvents.load(timelineEventProps), - connect(null, mapDispatchToProps), + connect(mapStateToProps, mapDispatchToProps), )(EditEventModal); diff --git a/frontend/src/metabase/timelines/questions/containers/MoveEventModal/MoveEventModal.tsx b/frontend/src/metabase/timelines/questions/containers/MoveEventModal/MoveEventModal.tsx new file mode 100644 index 00000000000..796cffd8bc9 --- /dev/null +++ b/frontend/src/metabase/timelines/questions/containers/MoveEventModal/MoveEventModal.tsx @@ -0,0 +1,53 @@ +import { connect } from "react-redux"; +import _ from "underscore"; +import Collections, { ROOT_COLLECTION } from "metabase/entities/collections"; +import Timelines from "metabase/entities/timelines"; +import TimelineEvents from "metabase/entities/timeline-events"; +import MoveEventModal from "metabase/timelines/common/components/MoveEventModal"; +import { Timeline, TimelineEvent } from "metabase-types/api"; +import { State } from "metabase-types/store"; + +interface MoveEventModalProps { + eventId: number; + collectionId?: number; + onClose?: () => void; +} + +const timelinesProps = { + query: { include: "events" }, +}; + +const timelineEventProps = { + id: (state: State, props: MoveEventModalProps) => props.eventId, + entityAlias: "event", +}; + +const collectionProps = { + id: (state: State, props: MoveEventModalProps) => { + return props.collectionId ?? ROOT_COLLECTION.id; + }, +}; + +const mapStateToProps = (state: State, { onClose }: MoveEventModalProps) => ({ + onSubmitSuccess: onClose, + onCancel: onClose, +}); + +const mapDispatchToProps = (dispatch: any) => ({ + onSubmit: async ( + event: TimelineEvent, + newTimeline: Timeline, + oldTimeline: Timeline, + onClose?: () => void, + ) => { + await dispatch(TimelineEvents.actions.setTimeline(event, newTimeline)); + onClose?.(); + }, +}); + +export default _.compose( + Timelines.loadList(timelinesProps), + TimelineEvents.load(timelineEventProps), + Collections.load(collectionProps), + connect(mapStateToProps, mapDispatchToProps), +)(MoveEventModal); diff --git a/frontend/src/metabase/timelines/questions/containers/MoveEventModal/index.ts b/frontend/src/metabase/timelines/questions/containers/MoveEventModal/index.ts new file mode 100644 index 00000000000..369d23ed2ee --- /dev/null +++ b/frontend/src/metabase/timelines/questions/containers/MoveEventModal/index.ts @@ -0,0 +1 @@ +export { default } from "./MoveEventModal"; diff --git a/frontend/src/metabase/timelines/questions/containers/NewEventModal/NewEventModal.tsx b/frontend/src/metabase/timelines/questions/containers/NewEventModal/NewEventModal.tsx index 1ec656a2631..68049348b36 100644 --- a/frontend/src/metabase/timelines/questions/containers/NewEventModal/NewEventModal.tsx +++ b/frontend/src/metabase/timelines/questions/containers/NewEventModal/NewEventModal.tsx @@ -5,13 +5,14 @@ import Collections, { ROOT_COLLECTION } from "metabase/entities/collections"; import Timelines from "metabase/entities/timelines"; import TimelineEvents from "metabase/entities/timeline-events"; import { addUndo } from "metabase/redux/undo"; +import NewEventModal from "metabase/timelines/common/components/NewEventModal"; import { Collection, TimelineEvent } from "metabase-types/api"; import { State } from "metabase-types/store"; -import NewEventModal from "../../components/NewEventModal"; -interface TimelinePanelProps { +interface NewEventModalProps { cardId?: number; collectionId?: number; + onClose?: () => void; } const timelineProps = { @@ -19,11 +20,17 @@ const timelineProps = { }; const collectionProps = { - id: (state: State, props: TimelinePanelProps) => { + id: (state: State, props: NewEventModalProps) => { return props.collectionId ?? ROOT_COLLECTION.id; }, }; +const mapStateToProps = (state: State, { onClose }: NewEventModalProps) => ({ + source: "question", + onSubmitSuccess: onClose, + onCancel: onClose, +}); + const mapDispatchToProps = (dispatch: any) => ({ onSubmit: async (values: Partial<TimelineEvent>, collection: Collection) => { if (values.timeline_id) { @@ -39,5 +46,5 @@ const mapDispatchToProps = (dispatch: any) => ({ export default _.compose( Timelines.loadList(timelineProps), Collections.load(collectionProps), - connect(null, mapDispatchToProps), + connect(mapStateToProps, mapDispatchToProps), )(NewEventModal); diff --git a/frontend/test/metabase-visual/collections/timelines.cy.spec.js b/frontend/test/metabase-visual/collections/timelines.cy.spec.js index b2ee8a57362..c29c59776f7 100644 --- a/frontend/test/metabase-visual/collections/timelines.cy.spec.js +++ b/frontend/test/metabase-visual/collections/timelines.cy.spec.js @@ -15,7 +15,7 @@ describe("timelines", () => { it("should display empty state", () => { cy.visit("/collection/root/timelines"); - cy.findByText("Our analytics events"); + cy.findByText("Our analytics events").should("be.visible"); cy.percySnapshot(); }); @@ -23,7 +23,7 @@ describe("timelines", () => { cy.createTimelineWithEvents({ events: EVENTS }).then(({ timeline }) => { cy.visit(`/collection/root/timelines/${timeline.id}`); - cy.findByText("Timeline"); + cy.findByText("Timeline").should("be.visible"); cy.percySnapshot(); }); }); diff --git a/frontend/test/metabase/scenarios/collections/timelines.cy.spec.js b/frontend/test/metabase/scenarios/collections/timelines.cy.spec.js index 61d6cb07813..9f87918b35c 100644 --- a/frontend/test/metabase/scenarios/collections/timelines.cy.spec.js +++ b/frontend/test/metabase/scenarios/collections/timelines.cy.spec.js @@ -179,6 +179,55 @@ describe("scenarios > collections > timelines", () => { cy.findByText("RC2").should("be.visible"); }); + it("should move an event", () => { + cy.createTimelineWithEvents({ + timeline: { name: "Releases" }, + events: [{ name: "RC1" }], + }); + cy.createTimelineWithEvents({ + timeline: { name: "Metrics" }, + events: [{ name: "RC2" }], + }); + + cy.visit("/collection/root/timelines"); + cy.findByText("Metrics").click(); + openMenu("RC2"); + cy.findByText("Move event").click(); + cy.findByText("Releases").click(); + getModal().within(() => cy.button("Move").click()); + cy.wait("@updateEvent"); + cy.findByText("RC2").should("not.exist"); + + cy.icon("chevronleft").click(); + cy.findByText("Releases").click(); + cy.findByText("RC1"); + cy.findByText("RC2"); + }); + + it("should move an event and undo", () => { + cy.createTimelineWithEvents({ + timeline: { name: "Releases" }, + events: [{ name: "RC1" }], + }); + cy.createTimelineWithEvents({ + timeline: { name: "Metrics" }, + events: [{ name: "RC2" }], + }); + + cy.visit("/collection/root/timelines"); + cy.findByText("Metrics").click(); + openMenu("RC2"); + cy.findByText("Move event").click(); + cy.findByText("Releases").click(); + getModal().within(() => cy.button("Move").click()); + cy.wait("@updateEvent"); + cy.findByText("RC2").should("not.exist"); + + cy.findByText("Undo").click(); + cy.wait("@updateEvent"); + cy.findByText("RC2").should("be.visible"); + }); + it("should archive an event when editing this event", () => { cy.createTimelineWithEvents({ timeline: { name: "Releases" }, diff --git a/frontend/test/metabase/scenarios/question/timelines.cy.spec.js b/frontend/test/metabase/scenarios/question/timelines.cy.spec.js index 96082e5873a..dec9b690cdf 100644 --- a/frontend/test/metabase/scenarios/question/timelines.cy.spec.js +++ b/frontend/test/metabase/scenarios/question/timelines.cy.spec.js @@ -16,18 +16,18 @@ describe("scenarios > collections > timelines", () => { it("should create the first event and timeline", () => { visitQuestion(3); cy.wait("@getCollection"); - cy.findByTextEnsureVisible("Visualization"); + cy.findByText("Visualization").should("be.visible"); cy.icon("calendar").click(); - cy.findByTextEnsureVisible("Add an event").click(); + cy.findByText("Add an event").click(); cy.findByLabelText("Event name").type("RC1"); cy.findByLabelText("Date").type("10/20/2018"); cy.button("Create").click(); cy.wait("@createEvent"); - cy.findByTextEnsureVisible("Our analytics events"); - cy.findByText("RC1"); + cy.findByText("Our analytics events").should("be.visible"); + cy.findByText("RC1").should("be.visible"); }); it("should create an event within the default timeline", () => { @@ -38,19 +38,44 @@ describe("scenarios > collections > timelines", () => { visitQuestion(3); cy.wait("@getCollection"); - cy.findByTextEnsureVisible("Visualization"); + cy.findByText("Visualization").should("be.visible"); cy.icon("calendar").click(); - cy.findByTextEnsureVisible("Add an event").click(); + cy.findByText("Add an event").click(); cy.findByLabelText("Event name").type("RC2"); cy.findByLabelText("Date").type("10/30/2018"); cy.button("Create").click(); cy.wait("@createEvent"); - cy.findByTextEnsureVisible("Releases"); - cy.findByText("RC1"); - cy.findByText("RC2"); + cy.findByText("Releases").should("be.visible"); + cy.findByText("RC1").should("be.visible"); + cy.findByText("RC2").should("be.visible"); + }); + + it("should display all events in data view", () => { + cy.createTimelineWithEvents({ + timeline: { name: "Releases" }, + events: [ + { name: "v1", timestamp: "2015-01-01T00:00:00Z" }, + { name: "v2", timestamp: "2017-01-01T00:00:00Z" }, + { name: "v3", timestamp: "2020-01-01T00:00:00Z" }, + ], + }); + + visitQuestion(3); + cy.wait("@getCollection"); + cy.findByText("Visualization").should("be.visible"); + + cy.findByLabelText("calendar icon").click(); + cy.findByText("v1").should("not.exist"); + cy.findByText("v2").should("be.visible"); + cy.findByText("v3").should("be.visible"); + + cy.findByLabelText("table2 icon").click(); + cy.findByText("v1").should("be.visible"); + cy.findByText("v2").should("be.visible"); + cy.findByText("v3").should("be.visible"); }); it("should edit an event", () => { @@ -61,12 +86,12 @@ describe("scenarios > collections > timelines", () => { visitQuestion(3); cy.wait("@getCollection"); - cy.findByTextEnsureVisible("Visualization"); + cy.findByText("Visualization").should("be.visible"); cy.icon("calendar").click(); - cy.findByText("Releases"); + cy.findByText("Releases").should("be.visible"); sidebar().within(() => cy.icon("ellipsis").click()); - cy.findByTextEnsureVisible("Edit event").click(); + cy.findByText("Edit event").click(); cy.findByLabelText("Event name") .clear() @@ -74,33 +99,33 @@ describe("scenarios > collections > timelines", () => { cy.findByText("Update").click(); cy.wait("@updateEvent"); - cy.findByTextEnsureVisible("Releases"); - cy.findByText("RC2"); + cy.findByText("Releases").should("be.visible"); + cy.findByText("RC2").should("be.visible"); }); - it("should display all events in data view", () => { + it("should move an event", () => { + cy.createTimeline({ + name: "Releases", + }); cy.createTimelineWithEvents({ - timeline: { name: "Releases" }, - events: [ - { name: "v1", timestamp: "2015-01-01T00:00:00Z" }, - { name: "v2", timestamp: "2017-01-01T00:00:00Z" }, - { name: "v3", timestamp: "2020-01-01T00:00:00Z" }, - ], + timeline: { name: "Builds" }, + events: [{ name: "RC2", timestamp: "2018-10-20T00:00:00Z" }], }); visitQuestion(3); cy.wait("@getCollection"); - cy.findByTextEnsureVisible("Visualization"); + cy.findByText("Visualization").should("be.visible"); - cy.findByLabelText("calendar icon").click(); - cy.findByText("v1").should("not.exist"); - cy.findByText("v2").should("be.visible"); - cy.findByText("v3").should("be.visible"); + cy.icon("calendar").click(); + cy.findByText("Builds").should("be.visible"); + sidebar().within(() => cy.icon("ellipsis").click()); + cy.findByText("Move event").click(); + cy.findByText("Releases").click(); + cy.button("Move").click(); + cy.wait("@updateEvent"); - cy.findByLabelText("table2 icon").click(); - cy.findByText("v1").should("be.visible"); - cy.findByText("v2").should("be.visible"); - cy.findByText("v3").should("be.visible"); + cy.findByText("Builds").should("not.exist"); + cy.findByText("Releases").should("be.visible"); }); it("should archive and unarchive an event", () => { @@ -111,18 +136,18 @@ describe("scenarios > collections > timelines", () => { visitQuestion(3); cy.wait("@getCollection"); - cy.findByTextEnsureVisible("Visualization"); + cy.findByText("Visualization").should("be.visible"); cy.icon("calendar").click(); - cy.findByText("Releases"); + cy.findByText("Releases").should("be.visible"); sidebar().within(() => cy.icon("ellipsis").click()); - cy.findByTextEnsureVisible("Archive event").click(); + cy.findByText("Archive event").click(); cy.wait("@updateEvent"); cy.findByText("RC1").should("not.exist"); cy.findByText("Undo").click(); cy.wait("@updateEvent"); - cy.findByText("RC1"); + cy.findByText("RC1").should("be.visible"); }); it("should support markdown in event description", () => { @@ -150,10 +175,10 @@ describe("scenarios > collections > timelines", () => { it("should not allow creating default timelines", () => { cy.signIn("readonly"); visitQuestion(3); - cy.findByTextEnsureVisible("Created At"); + cy.findByText("Created At").should("be.visible"); cy.icon("calendar").click(); - cy.findByTextEnsureVisible(/Events in Metabase/); + cy.findByText(/Events in Metabase/); cy.findByText("Add an event").should("not.exist"); }); @@ -166,10 +191,10 @@ describe("scenarios > collections > timelines", () => { cy.signOut(); cy.signIn("readonly"); visitQuestion(3); - cy.findByTextEnsureVisible("Created At"); + cy.findByText("Created At").should("be.visible"); cy.icon("calendar").click(); - cy.findByTextEnsureVisible("Releases"); + cy.findByText("Releases").should("be.visible"); cy.findByText("Add an event").should("not.exist"); sidebar().within(() => cy.icon("ellipsis").should("not.exist")); }); -- GitLab