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