From 60830612f54f2b5264c70a0b0b08b06ea11a0c46 Mon Sep 17 00:00:00 2001 From: Alexander Polyankin <alexander.polyankin@metabase.com> Date: Mon, 11 Apr 2022 14:42:08 +0300 Subject: [PATCH] Move timelines between collections (#21569) --- .../src/metabase/containers/ItemPicker.jsx | 7 ++- frontend/src/metabase/entities/timelines.js | 9 +++ frontend/src/metabase/lib/urls.js | 4 ++ .../TimelineDetailsModal.tsx | 4 ++ .../MoveTimelineModal/MoveTimelineModal.tsx | 37 ++++++++++++ .../containers/MoveTimelineModal/index.ts | 1 + .../TimelineDetailsModal.tsx | 3 +- .../metabase/timelines/collections/routes.tsx | 8 +++ .../MoveEventModal/MoveEventModal.styled.tsx | 6 +- .../MoveTimelineModal.styled.tsx | 17 ++++++ .../MoveTimelineModal/MoveTimelineModal.tsx | 57 +++++++++++++++++++ .../components/MoveTimelineModal/index.ts | 1 + .../collections/timelines.cy.spec.js | 19 +++++++ src/metabase/api/timeline.clj | 2 +- 14 files changed, 167 insertions(+), 8 deletions(-) create mode 100644 frontend/src/metabase/timelines/collections/containers/MoveTimelineModal/MoveTimelineModal.tsx create mode 100644 frontend/src/metabase/timelines/collections/containers/MoveTimelineModal/index.ts create mode 100644 frontend/src/metabase/timelines/common/components/MoveTimelineModal/MoveTimelineModal.styled.tsx create mode 100644 frontend/src/metabase/timelines/common/components/MoveTimelineModal/MoveTimelineModal.tsx create mode 100644 frontend/src/metabase/timelines/common/components/MoveTimelineModal/index.ts diff --git a/frontend/src/metabase/containers/ItemPicker.jsx b/frontend/src/metabase/containers/ItemPicker.jsx index 425474847ba..16cbbc0ae4c 100644 --- a/frontend/src/metabase/containers/ItemPicker.jsx +++ b/frontend/src/metabase/containers/ItemPicker.jsx @@ -59,6 +59,7 @@ export default class ItemPicker extends React.Component { value: PropTypes.number, types: PropTypes.array, showSearch: PropTypes.bool, + showScroll: PropTypes.bool, }; // returns a list of "crumbs" starting with the "root" collection @@ -116,6 +117,7 @@ export default class ItemPicker extends React.Component { style, className, showSearch = true, + showScroll = true, } = this.props; const { parentId, searchMode, searchString } = this.state; @@ -156,7 +158,10 @@ export default class ItemPicker extends React.Component { (models.size === 1 || item.model === value.model); return ( - <LoadingAndErrorWrapper loading={!collectionsById} className="scroll-y"> + <LoadingAndErrorWrapper + loading={!collectionsById} + className={cx({ "scroll-y": showScroll })} + > <div style={style} className={cx(className, "scroll-y")}> {searchMode ? ( <ItemPickerHeader diff --git a/frontend/src/metabase/entities/timelines.js b/frontend/src/metabase/entities/timelines.js index 4ef0f84e9ed..caeac217684 100644 --- a/frontend/src/metabase/entities/timelines.js +++ b/frontend/src/metabase/entities/timelines.js @@ -5,6 +5,7 @@ import { TimelineSchema } from "metabase/schema"; import { TimelineApi, TimelineEventApi } from "metabase/services"; import { createEntity, undo } from "metabase/lib/entities"; import { getDefaultTimeline } from "metabase/lib/timelines"; +import { canonicalCollectionId } from "metabase/collections/utils"; import TimelineEvents from "./timeline-events"; import forms from "./timelines/forms"; @@ -34,6 +35,14 @@ const Timelines = createEntity({ }, objectActions: { + setCollection: ({ id }, collection, opts) => { + return Timelines.actions.update( + { id }, + { collection_id: canonicalCollectionId(collection && collection.id) }, + undo(opts, t`timeline`, t`moved`), + ); + }, + setArchived: ({ id }, archived, opts) => Timelines.actions.update( { id }, diff --git a/frontend/src/metabase/lib/urls.js b/frontend/src/metabase/lib/urls.js index a044c40ad30..77390de3bfa 100644 --- a/frontend/src/metabase/lib/urls.js +++ b/frontend/src/metabase/lib/urls.js @@ -317,6 +317,10 @@ export function editTimelineInCollection(timeline) { return `${timelineInCollection(timeline)}/edit`; } +export function moveTimelineInCollection(timeline) { + return `${timelineInCollection(timeline)}/move`; +} + export function timelineArchiveInCollection(timeline) { return `${timelineInCollection(timeline)}/archive`; } diff --git a/frontend/src/metabase/timelines/collections/components/TimelineDetailsModal/TimelineDetailsModal.tsx b/frontend/src/metabase/timelines/collections/components/TimelineDetailsModal/TimelineDetailsModal.tsx index 1e4054d4e08..db4af5b0733 100644 --- a/frontend/src/metabase/timelines/collections/components/TimelineDetailsModal/TimelineDetailsModal.tsx +++ b/frontend/src/metabase/timelines/collections/components/TimelineDetailsModal/TimelineDetailsModal.tsx @@ -151,6 +151,10 @@ const getMenuItems = ( title: t`Edit timeline details`, link: Urls.editTimelineInCollection(timeline), }, + { + title: t`Move timeline`, + link: Urls.moveTimelineInCollection(timeline), + }, ); } diff --git a/frontend/src/metabase/timelines/collections/containers/MoveTimelineModal/MoveTimelineModal.tsx b/frontend/src/metabase/timelines/collections/containers/MoveTimelineModal/MoveTimelineModal.tsx new file mode 100644 index 00000000000..a0f5e9b0271 --- /dev/null +++ b/frontend/src/metabase/timelines/collections/containers/MoveTimelineModal/MoveTimelineModal.tsx @@ -0,0 +1,37 @@ +import { connect } from "react-redux"; +import { goBack, push } from "react-router-redux"; +import _ from "underscore"; +import * as Urls from "metabase/lib/urls"; +import Timelines from "metabase/entities/timelines"; +import MoveTimelineModal from "metabase/timelines/common/components/MoveTimelineModal"; +import { Timeline } from "metabase-types/api"; +import { State } from "metabase-types/store"; +import LoadingAndErrorWrapper from "../../components/LoadingAndErrorWrapper"; +import { ModalParams } from "../../types"; + +interface MoveTimelineModalProps { + params: ModalParams; +} + +const timelineProps = { + id: (state: State, props: MoveTimelineModalProps) => + Urls.extractEntityId(props.params.timelineId), + query: { include: "events" }, + LoadingAndErrorWrapper, +}; + +const mapDispatchToProps = (dispatch: any) => ({ + onSubmit: async (timeline: Timeline, collectionId: number | null) => { + const collection = { id: collectionId }; + await dispatch(Timelines.actions.setCollection(timeline, collection)); + dispatch(push(Urls.timelineInCollection(timeline))); + }, + onCancel: () => { + dispatch(goBack()); + }, +}); + +export default _.compose( + Timelines.load(timelineProps), + connect(null, mapDispatchToProps), +)(MoveTimelineModal); diff --git a/frontend/src/metabase/timelines/collections/containers/MoveTimelineModal/index.ts b/frontend/src/metabase/timelines/collections/containers/MoveTimelineModal/index.ts new file mode 100644 index 00000000000..fcf6a4a3a3b --- /dev/null +++ b/frontend/src/metabase/timelines/collections/containers/MoveTimelineModal/index.ts @@ -0,0 +1 @@ +export { default } from "./MoveTimelineModal"; diff --git a/frontend/src/metabase/timelines/collections/containers/TimelineDetailsModal/TimelineDetailsModal.tsx b/frontend/src/metabase/timelines/collections/containers/TimelineDetailsModal/TimelineDetailsModal.tsx index aeaf1e8ac4d..f7faeca21a6 100644 --- a/frontend/src/metabase/timelines/collections/containers/TimelineDetailsModal/TimelineDetailsModal.tsx +++ b/frontend/src/metabase/timelines/collections/containers/TimelineDetailsModal/TimelineDetailsModal.tsx @@ -25,12 +25,13 @@ const timelineProps = { const timelinesProps = { query: (state: State, props: TimelineDetailsModalProps) => ({ collectionId: Urls.extractCollectionId(props.params.slug), + include: "events", }), LoadingAndErrorWrapper, }; const mapStateToProps = (state: State, props: TimelineDetailsModalProps) => ({ - isOnlyTimeline: props.timelines.length === 1, + isOnlyTimeline: props.timelines.length <= 1, }); const mapDispatchToProps = (dispatch: any) => ({ diff --git a/frontend/src/metabase/timelines/collections/routes.tsx b/frontend/src/metabase/timelines/collections/routes.tsx index c02fe6658be..bcddcbc6ce2 100644 --- a/frontend/src/metabase/timelines/collections/routes.tsx +++ b/frontend/src/metabase/timelines/collections/routes.tsx @@ -5,6 +5,7 @@ import DeleteTimelineModal from "./containers/DeleteTimelineModal"; import EditEventModal from "./containers/EditEventModal"; import EditTimelineModal from "./containers/EditTimelineModal"; import MoveEventModal from "./containers/MoveEventModal"; +import MoveTimelineModal from "./containers/MoveTimelineModal"; import NewEventModal from "./containers/NewEventModal"; import NewEventWithTimelineModal from "./containers/NewEventWithTimelineModal"; import NewTimelineModal from "./containers/NewTimelineModal"; @@ -51,6 +52,13 @@ const getRoutes = () => { modalProps: { enableTransition: false }, }} /> + <ModalRoute + {...{ + path: "timelines/:timelineId/move", + modal: MoveTimelineModal, + modalProps: { enableTransition: false }, + }} + /> <ModalRoute {...{ path: "timelines/:timelineId/archive", diff --git a/frontend/src/metabase/timelines/common/components/MoveEventModal/MoveEventModal.styled.tsx b/frontend/src/metabase/timelines/common/components/MoveEventModal/MoveEventModal.styled.tsx index f03ddd0b9ed..108836a3323 100644 --- a/frontend/src/metabase/timelines/common/components/MoveEventModal/MoveEventModal.styled.tsx +++ b/frontend/src/metabase/timelines/common/components/MoveEventModal/MoveEventModal.styled.tsx @@ -7,11 +7,7 @@ export const ModalRoot = styled.div` max-height: 90vh; `; -export interface ModalBodyProps { - isTopAligned?: boolean; -} - -export const ModalBody = styled.div<ModalBodyProps>` +export const ModalBody = styled.div` display: flex; flex-direction: column; flex: 1 1 auto; diff --git a/frontend/src/metabase/timelines/common/components/MoveTimelineModal/MoveTimelineModal.styled.tsx b/frontend/src/metabase/timelines/common/components/MoveTimelineModal/MoveTimelineModal.styled.tsx new file mode 100644 index 00000000000..108836a3323 --- /dev/null +++ b/frontend/src/metabase/timelines/common/components/MoveTimelineModal/MoveTimelineModal.styled.tsx @@ -0,0 +1,17 @@ +import styled from "@emotion/styled"; + +export const ModalRoot = styled.div` + display: flex; + flex-direction: column; + min-height: 573px; + max-height: 90vh; +`; + +export const ModalBody = styled.div` + 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/MoveTimelineModal/MoveTimelineModal.tsx b/frontend/src/metabase/timelines/common/components/MoveTimelineModal/MoveTimelineModal.tsx new file mode 100644 index 00000000000..675dfa94381 --- /dev/null +++ b/frontend/src/metabase/timelines/common/components/MoveTimelineModal/MoveTimelineModal.tsx @@ -0,0 +1,57 @@ +import React, { useCallback, useState } from "react"; +import { t } from "ttag"; +import { getTimelineName } from "metabase/lib/timelines"; +import Button from "metabase/core/components/Button/Button"; +import CollectionPicker from "metabase/containers/CollectionPicker"; +import { Timeline } from "metabase-types/api"; +import ModalHeader from "../ModalHeader"; +import ModalFooter from "../ModalFooter"; +import { ModalBody, ModalRoot } from "./MoveTimelineModal.styled"; + +export interface MoveTimelineModalProps { + timeline: Timeline; + onSubmit: (timeline: Timeline, collectionId: number | null) => void; + onSubmitSuccess?: () => void; + onCancel?: () => void; + onClose?: () => void; +} + +const MoveTimelineModal = ({ + timeline, + onSubmit, + onSubmitSuccess, + onCancel, + onClose, +}: MoveTimelineModalProps): JSX.Element => { + const [collectionId, setCollectionId] = useState(timeline.collection_id); + const isEnabled = timeline.collection_id !== collectionId; + + const handleSubmit = useCallback(async () => { + await onSubmit(timeline, collectionId); + onSubmitSuccess?.(); + }, [timeline, collectionId, onSubmit, onSubmitSuccess]); + + return ( + <ModalRoot> + <ModalHeader + title={t`Move ${getTimelineName(timeline)}`} + onClose={onClose} + /> + <ModalBody> + <CollectionPicker + value={collectionId} + showScroll={false} + onChange={setCollectionId} + /> + </ModalBody> + <ModalFooter> + <Button onClick={onCancel}>{t`Cancel`}</Button> + <Button primary disabled={!isEnabled} onClick={handleSubmit}> + {t`Move`} + </Button> + </ModalFooter> + </ModalRoot> + ); +}; + +export default MoveTimelineModal; diff --git a/frontend/src/metabase/timelines/common/components/MoveTimelineModal/index.ts b/frontend/src/metabase/timelines/common/components/MoveTimelineModal/index.ts new file mode 100644 index 00000000000..fcf6a4a3a3b --- /dev/null +++ b/frontend/src/metabase/timelines/common/components/MoveTimelineModal/index.ts @@ -0,0 +1 @@ +export { default } from "./MoveTimelineModal"; diff --git a/frontend/test/metabase/scenarios/collections/timelines.cy.spec.js b/frontend/test/metabase/scenarios/collections/timelines.cy.spec.js index 9f87918b35c..a863bbe968b 100644 --- a/frontend/test/metabase/scenarios/collections/timelines.cy.spec.js +++ b/frontend/test/metabase/scenarios/collections/timelines.cy.spec.js @@ -358,6 +358,25 @@ describe("scenarios > collections > timelines", () => { cy.findByText("Launches").should("be.visible"); }); + it("should move a timeline", () => { + cy.createTimelineWithEvents({ + timeline: { name: "Our analytics events", default: true }, + events: [{ name: "RC1" }], + }); + + cy.visit("/collection/root/timelines"); + openMenu("Our analytics events"); + cy.findByText("Move timeline").click(); + + getModal().within(() => { + cy.findByText("First collection").click(); + cy.button("Move").click(); + cy.wait("@updateTimeline"); + }); + + cy.findByText("First collection events").should("be.visible"); + }); + it("should archive a timeline and undo", () => { cy.createTimelineWithEvents({ timeline: { name: "Releases" }, diff --git a/src/metabase/api/timeline.clj b/src/metabase/api/timeline.clj index 3bb68956c4e..828d2282200 100644 --- a/src/metabase/api/timeline.clj +++ b/src/metabase/api/timeline.clj @@ -90,7 +90,7 @@ :non-nil #{:name})) (when (and (some? archived) (not= current-archived archived)) (db/update-where! TimelineEvent {:timeline_id id} :archived archived)) - (hydrate (Timeline id) :creator))) + (hydrate (Timeline id) :creator [:collection :can_write]))) (api/defendpoint DELETE "/:id" "Delete a [[Timeline]]. Will cascade delete its events as well." -- GitLab