Skip to content
Snippets Groups Projects
Unverified Commit 60830612 authored by Alexander Polyankin's avatar Alexander Polyankin Committed by GitHub
Browse files

Move timelines between collections (#21569)

parent 6d623cb1
No related branches found
No related tags found
No related merge requests found
Showing
with 167 additions and 8 deletions
...@@ -59,6 +59,7 @@ export default class ItemPicker extends React.Component { ...@@ -59,6 +59,7 @@ export default class ItemPicker extends React.Component {
value: PropTypes.number, value: PropTypes.number,
types: PropTypes.array, types: PropTypes.array,
showSearch: PropTypes.bool, showSearch: PropTypes.bool,
showScroll: PropTypes.bool,
}; };
// returns a list of "crumbs" starting with the "root" collection // returns a list of "crumbs" starting with the "root" collection
...@@ -116,6 +117,7 @@ export default class ItemPicker extends React.Component { ...@@ -116,6 +117,7 @@ export default class ItemPicker extends React.Component {
style, style,
className, className,
showSearch = true, showSearch = true,
showScroll = true,
} = this.props; } = this.props;
const { parentId, searchMode, searchString } = this.state; const { parentId, searchMode, searchString } = this.state;
...@@ -156,7 +158,10 @@ export default class ItemPicker extends React.Component { ...@@ -156,7 +158,10 @@ export default class ItemPicker extends React.Component {
(models.size === 1 || item.model === value.model); (models.size === 1 || item.model === value.model);
return ( return (
<LoadingAndErrorWrapper loading={!collectionsById} className="scroll-y"> <LoadingAndErrorWrapper
loading={!collectionsById}
className={cx({ "scroll-y": showScroll })}
>
<div style={style} className={cx(className, "scroll-y")}> <div style={style} className={cx(className, "scroll-y")}>
{searchMode ? ( {searchMode ? (
<ItemPickerHeader <ItemPickerHeader
......
...@@ -5,6 +5,7 @@ import { TimelineSchema } from "metabase/schema"; ...@@ -5,6 +5,7 @@ import { TimelineSchema } from "metabase/schema";
import { TimelineApi, TimelineEventApi } from "metabase/services"; import { TimelineApi, TimelineEventApi } from "metabase/services";
import { createEntity, undo } from "metabase/lib/entities"; import { createEntity, undo } from "metabase/lib/entities";
import { getDefaultTimeline } from "metabase/lib/timelines"; import { getDefaultTimeline } from "metabase/lib/timelines";
import { canonicalCollectionId } from "metabase/collections/utils";
import TimelineEvents from "./timeline-events"; import TimelineEvents from "./timeline-events";
import forms from "./timelines/forms"; import forms from "./timelines/forms";
...@@ -34,6 +35,14 @@ const Timelines = createEntity({ ...@@ -34,6 +35,14 @@ const Timelines = createEntity({
}, },
objectActions: { 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) => setArchived: ({ id }, archived, opts) =>
Timelines.actions.update( Timelines.actions.update(
{ id }, { id },
......
...@@ -317,6 +317,10 @@ export function editTimelineInCollection(timeline) { ...@@ -317,6 +317,10 @@ export function editTimelineInCollection(timeline) {
return `${timelineInCollection(timeline)}/edit`; return `${timelineInCollection(timeline)}/edit`;
} }
export function moveTimelineInCollection(timeline) {
return `${timelineInCollection(timeline)}/move`;
}
export function timelineArchiveInCollection(timeline) { export function timelineArchiveInCollection(timeline) {
return `${timelineInCollection(timeline)}/archive`; return `${timelineInCollection(timeline)}/archive`;
} }
......
...@@ -151,6 +151,10 @@ const getMenuItems = ( ...@@ -151,6 +151,10 @@ const getMenuItems = (
title: t`Edit timeline details`, title: t`Edit timeline details`,
link: Urls.editTimelineInCollection(timeline), link: Urls.editTimelineInCollection(timeline),
}, },
{
title: t`Move timeline`,
link: Urls.moveTimelineInCollection(timeline),
},
); );
} }
......
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);
export { default } from "./MoveTimelineModal";
...@@ -25,12 +25,13 @@ const timelineProps = { ...@@ -25,12 +25,13 @@ const timelineProps = {
const timelinesProps = { const timelinesProps = {
query: (state: State, props: TimelineDetailsModalProps) => ({ query: (state: State, props: TimelineDetailsModalProps) => ({
collectionId: Urls.extractCollectionId(props.params.slug), collectionId: Urls.extractCollectionId(props.params.slug),
include: "events",
}), }),
LoadingAndErrorWrapper, LoadingAndErrorWrapper,
}; };
const mapStateToProps = (state: State, props: TimelineDetailsModalProps) => ({ const mapStateToProps = (state: State, props: TimelineDetailsModalProps) => ({
isOnlyTimeline: props.timelines.length === 1, isOnlyTimeline: props.timelines.length <= 1,
}); });
const mapDispatchToProps = (dispatch: any) => ({ const mapDispatchToProps = (dispatch: any) => ({
......
...@@ -5,6 +5,7 @@ import DeleteTimelineModal from "./containers/DeleteTimelineModal"; ...@@ -5,6 +5,7 @@ import DeleteTimelineModal from "./containers/DeleteTimelineModal";
import EditEventModal from "./containers/EditEventModal"; import EditEventModal from "./containers/EditEventModal";
import EditTimelineModal from "./containers/EditTimelineModal"; import EditTimelineModal from "./containers/EditTimelineModal";
import MoveEventModal from "./containers/MoveEventModal"; import MoveEventModal from "./containers/MoveEventModal";
import MoveTimelineModal from "./containers/MoveTimelineModal";
import NewEventModal from "./containers/NewEventModal"; import NewEventModal from "./containers/NewEventModal";
import NewEventWithTimelineModal from "./containers/NewEventWithTimelineModal"; import NewEventWithTimelineModal from "./containers/NewEventWithTimelineModal";
import NewTimelineModal from "./containers/NewTimelineModal"; import NewTimelineModal from "./containers/NewTimelineModal";
...@@ -51,6 +52,13 @@ const getRoutes = () => { ...@@ -51,6 +52,13 @@ const getRoutes = () => {
modalProps: { enableTransition: false }, modalProps: { enableTransition: false },
}} }}
/> />
<ModalRoute
{...{
path: "timelines/:timelineId/move",
modal: MoveTimelineModal,
modalProps: { enableTransition: false },
}}
/>
<ModalRoute <ModalRoute
{...{ {...{
path: "timelines/:timelineId/archive", path: "timelines/:timelineId/archive",
......
...@@ -7,11 +7,7 @@ export const ModalRoot = styled.div` ...@@ -7,11 +7,7 @@ export const ModalRoot = styled.div`
max-height: 90vh; max-height: 90vh;
`; `;
export interface ModalBodyProps { export const ModalBody = styled.div`
isTopAligned?: boolean;
}
export const ModalBody = styled.div<ModalBodyProps>`
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex: 1 1 auto; flex: 1 1 auto;
......
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;
`;
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;
export { default } from "./MoveTimelineModal";
...@@ -358,6 +358,25 @@ describe("scenarios > collections > timelines", () => { ...@@ -358,6 +358,25 @@ describe("scenarios > collections > timelines", () => {
cy.findByText("Launches").should("be.visible"); 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", () => { it("should archive a timeline and undo", () => {
cy.createTimelineWithEvents({ cy.createTimelineWithEvents({
timeline: { name: "Releases" }, timeline: { name: "Releases" },
......
...@@ -90,7 +90,7 @@ ...@@ -90,7 +90,7 @@
:non-nil #{:name})) :non-nil #{:name}))
(when (and (some? archived) (not= current-archived archived)) (when (and (some? archived) (not= current-archived archived))
(db/update-where! TimelineEvent {:timeline_id id} :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" (api/defendpoint DELETE "/:id"
"Delete a [[Timeline]]. Will cascade delete its events as well." "Delete a [[Timeline]]. Will cascade delete its events as well."
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment