From 65bde7705ac0c80a933d657bfb5911d0173aad49 Mon Sep 17 00:00:00 2001 From: MarkRx <MarkRx@users.noreply.github.com> Date: Mon, 19 Nov 2018 16:36:14 -0800 Subject: [PATCH] Add ability to copy a dashboard :page_facing_up: --- .gitignore | 1 + .../metabase/components/CollectionLanding.jsx | 116 ++++++++++++++---- .../src/metabase/components/EntityItem.jsx | 9 +- .../metabase/components/form/StandardForm.jsx | 3 +- .../components/DashboardCopyModal.jsx | 55 +++++++++ .../dashboard/components/DashboardHeader.jsx | 36 ++++-- .../entities/containers/EntityCopyModal.jsx | 32 +++++ frontend/src/metabase/entities/dashboards.js | 46 +++++++ frontend/src/metabase/icon_paths.js | 4 +- frontend/src/metabase/routes.jsx | 2 + src/metabase/api/dashboard.clj | 39 ++++++ test/metabase/api/dashboard_test.clj | 54 ++++++++ 12 files changed, 358 insertions(+), 39 deletions(-) create mode 100644 frontend/src/metabase/dashboard/components/DashboardCopyModal.jsx create mode 100644 frontend/src/metabase/entities/containers/EntityCopyModal.jsx diff --git a/.gitignore b/.gitignore index 70e402a9dfd..5133c000b4e 100644 --- a/.gitignore +++ b/.gitignore @@ -55,3 +55,4 @@ bin/node_modules/ *.log *.trace.db /backend-checksums.txt +.vscode diff --git a/frontend/src/metabase/components/CollectionLanding.jsx b/frontend/src/metabase/components/CollectionLanding.jsx index 6b7e11cd7dd..c52fa787530 100644 --- a/frontend/src/metabase/components/CollectionLanding.jsx +++ b/frontend/src/metabase/components/CollectionLanding.jsx @@ -5,6 +5,9 @@ import { connect } from "react-redux"; import { withRouter } from "react-router"; import _ from "underscore"; import cx from "classnames"; +import { dissoc } from "icepick"; + +import withToast from "metabase/hoc/Toast"; import listSelect from "metabase/hoc/ListSelect"; import BulkActionBar from "metabase/components/BulkActionBar"; @@ -29,7 +32,9 @@ import CollectionEmptyState from "metabase/components/CollectionEmptyState"; import Tooltip from "metabase/components/Tooltip"; import CollectionMoveModal from "metabase/containers/CollectionMoveModal"; +import EntityCopyModal from "metabase/entities/containers/EntityCopyModal"; import { entityObjectLoader } from "metabase/entities/containers/EntityObjectLoader"; +import { entityTypeForObject } from "metabase/schema"; import CollectionList from "metabase/components/CollectionList"; @@ -122,7 +127,8 @@ import { entityListLoader } from "metabase/entities/containers/EntityListLoader" @withRouter class DefaultLanding extends React.Component { state = { - moveItems: null, + selectedItems: null, + selectedAction: null, }; handleBulkArchive = async () => { @@ -136,15 +142,18 @@ class DefaultLanding extends React.Component { }; handleBulkMoveStart = () => { - this.setState({ moveItems: this.props.selected }); + this.setState({ + selectedItems: this.props.selected, + selectedAction: "move", + }); }; handleBulkMove = async collection => { try { await Promise.all( - this.state.moveItems.map(item => item.setCollection(collection)), + this.state.selectedItems.map(item => item.setCollection(collection)), ); - this.setState({ moveItems: null }); + this.setState({ selectedItems: null, selectedAction: null }); } finally { this.handleBulkActionSuccess(); } @@ -174,7 +183,7 @@ class DefaultLanding extends React.Component { onToggleSelected, location, } = this.props; - const { moveItems } = this.state; + const { selectedItems, selectedAction } = this.state; const collectionWidth = unpinned.length > 0 ? [1, 1 / 3] : 1; const itemWidth = unpinned.length > 0 ? [1, 2 / 3] : 0; @@ -288,7 +297,7 @@ class DefaultLanding extends React.Component { > <ItemDragSource item={item} collection={collection}> <PinnedItem - key={`${item.type}:${item.id}`} + key={`${item.model}:${item.id}`} index={index} item={item} collection={collection} @@ -377,13 +386,22 @@ class DefaultLanding extends React.Component { collection={collection} > <NormalItem - key={`${item.type}:${item.id}`} + key={`${item.model}:${item.id}`} item={item} collection={collection} selection={selection} onToggleSelected={onToggleSelected} - onMove={moveItems => - this.setState({ moveItems }) + onMove={selectedItems => + this.setState({ + selectedItems, + selectedAction: "move", + }) + } + onCopy={selectedItems => + this.setState({ + selectedItems, + selectedAction: "copy", + }) } /> </ItemDragSource> @@ -479,18 +497,37 @@ class DefaultLanding extends React.Component { </BulkActionBar> </Box> </Box> - {!_.isEmpty(moveItems) && ( - <Modal> - <CollectionMoveModal - title={ - moveItems.length > 1 - ? t`Move ${moveItems.length} items?` - : t`Move "${moveItems[0].getName()}"?` - } - onClose={() => this.setState({ moveItems: null })} - onMove={this.handleBulkMove} - /> - </Modal> + {!_.isEmpty(selectedItems) && + selectedAction == "copy" && ( + <Modal> + <CollectionCopyEntityModal + entityObject={selectedItems[0]} + onClose={() => + this.setState({ selectedItems: null, selectedAction: null }) + } + onSaved={newEntityObject => { + this.setState({ selectedItems: null, selectedAction: null }); + this.handleBulkActionSuccess(); + }} + /> + </Modal> + )} + {!_.isEmpty(selectedItems) && + selectedAction == "move" && ( + <Modal> + <CollectionMoveModal + title={ + selectedItems.length > 1 + ? t`Move ${selectedItems.length} items?` + : t`Move "${selectedItems[0].getName()}"?` + } + onClose={() => + this.setState({ selectedItems: null, selectedAction: null }) + } + onMove={this.handleBulkMove} + /> + </Modal> + )} )} <ItemsDragLayer selected={selected} /> </Box> @@ -504,6 +541,7 @@ export const NormalItem = ({ selection = new Set(), onToggleSelected, onMove, + onCopy, }) => ( <Link to={item.getUrl()} @@ -515,7 +553,7 @@ export const NormalItem = ({ showSelect={selection.size > 0} selectable item={item} - type={item.type} + type={entityTypeForObject(item)} name={item.getName()} iconName={item.getIcon()} iconColor={item.getColor()} @@ -531,6 +569,7 @@ export const NormalItem = ({ onMove={ collection.can_write && item.setCollection ? () => onMove([item]) : null } + onCopy={item.copy ? () => onCopy([item]) : null} onArchive={ collection.can_write && item.setArchived ? () => item.setArchived(true) @@ -685,5 +724,38 @@ const CollectionBurgerMenu = () => ( triggerIcon="burger" /> ); +@withToast +class CollectionCopyEntityModal extends React.Component { + render() { + const { entityObject, onClose, onSaved, triggerToast } = this.props; + + return ( + <EntityCopyModal + entityType={entityTypeForObject(entityObject)} + entityObject={entityObject} + copy={async values => { + return entityObject.copy(dissoc(values, "id")); + }} + onClose={onClose} + onSaved={newEntityObject => { + triggerToast( + <div className="flex align-center"> + {t`Duplicated ${entityObject.model}`} + <Link + className="link text-bold ml1" + to={Urls.modelToUrl(entityObject.model, newEntityObject.id)} + > + {t`See it`} + </Link> + </div>, + { icon: entityObject.model }, + ); + + onSaved(newEntityObject); + }} + /> + ); + } +} export default CollectionLanding; diff --git a/frontend/src/metabase/components/EntityItem.jsx b/frontend/src/metabase/components/EntityItem.jsx index b17278d09ff..a0864485846 100644 --- a/frontend/src/metabase/components/EntityItem.jsx +++ b/frontend/src/metabase/components/EntityItem.jsx @@ -30,6 +30,7 @@ const EntityItem = ({ onPin, onFavorite, onMove, + onCopy, onArchive, selected, onToggleSelected, @@ -50,8 +51,14 @@ const EntityItem = ({ action: onMove, event: `${analyticsContext};Entity Item;Move Item;${item.model}`, }, + onCopy && { + title: t`Duplicate this item`, + icon: "clone", + action: onCopy, + event: `${analyticsContext};Entity Item;Copy Item;${item.model}`, + }, onArchive && { - title: t`Archive`, + title: t`Archive this item`, icon: "archive", action: onArchive, event: `${analyticsContext};Entity Item;Archive Item;${item.model}`, diff --git a/frontend/src/metabase/components/form/StandardForm.jsx b/frontend/src/metabase/components/form/StandardForm.jsx index d13f1cc0155..5ac98c39c5f 100644 --- a/frontend/src/metabase/components/form/StandardForm.jsx +++ b/frontend/src/metabase/components/form/StandardForm.jsx @@ -20,6 +20,7 @@ const StandardForm = ({ handleSubmit, resetForm, + submitTitle, formDef: form, className, resetButton = false, @@ -64,7 +65,7 @@ const StandardForm = ({ disabled={submitting || invalid} className="mr1" > - {values.id != null ? t`Update` : t`Create`} + {submitTitle || (values.id != null ? t`Update` : t`Create`)} </Button> {resetButton && ( <Button diff --git a/frontend/src/metabase/dashboard/components/DashboardCopyModal.jsx b/frontend/src/metabase/dashboard/components/DashboardCopyModal.jsx new file mode 100644 index 00000000000..ff7af4b8c94 --- /dev/null +++ b/frontend/src/metabase/dashboard/components/DashboardCopyModal.jsx @@ -0,0 +1,55 @@ +import React from "react"; +import { withRouter } from "react-router"; +import { connect } from "react-redux"; +import { dissoc } from "icepick"; + +import { replace } from "react-router-redux"; +import * as Urls from "metabase/lib/urls"; + +import Dashboards from "metabase/entities/dashboards"; + +import EntityCopyModal from "metabase/entities/containers/EntityCopyModal"; + +import { getDashboardComplete } from "../selectors"; + +const mapStateToProps = (state, props) => { + return { + dashboard: getDashboardComplete(state, props), + }; +}; + +const mapDispatchToProps = { + copyDashboard: Dashboards.actions.copy, + onReplaceLocation: replace, +}; + +@withRouter +@connect(mapStateToProps, mapDispatchToProps) +class DashboardCopyModal extends React.Component { + render() { + const { + onClose, + onReplaceLocation, + copyDashboard, + dashboard, + ...props + } = this.props; + return ( + <EntityCopyModal + entityType="dashboards" + entityObject={dashboard} + copy={async values => { + return await copyDashboard( + { id: this.props.params.dashboardId }, + dissoc(values, "id"), + ); + }} + onClose={onClose} + onSaved={dashboard => onReplaceLocation(Urls.dashboard(dashboard.id))} + {...props} + /> + ); + } +} + +export default DashboardCopyModal; diff --git a/frontend/src/metabase/dashboard/components/DashboardHeader.jsx b/frontend/src/metabase/dashboard/components/DashboardHeader.jsx index 3a3380e9e1b..aa6c4d4e151 100644 --- a/frontend/src/metabase/dashboard/components/DashboardHeader.jsx +++ b/frontend/src/metabase/dashboard/components/DashboardHeader.jsx @@ -297,19 +297,6 @@ export default class DashboardHeader extends Component { ); } - if (!isFullscreen) { - buttons.push( - <Tooltip key="new-dashboard" tooltip={t`Move dashboard`}> - <Link - to={location.pathname + "/move"} - data-metabase-event={"Dashboard;Move"} - > - <Icon className="text-brand-hover" name="move" size={18} /> - </Link> - </Tooltip>, - ); - } - if (!isFullscreen && !isEditing && canEdit) { buttons.push( <Tooltip key="edit-dashboard" tooltip={t`Edit dashboard`}> @@ -326,6 +313,29 @@ export default class DashboardHeader extends Component { ); } + if (!isFullscreen && !isEditing) { + buttons.push( + <Tooltip key="new-dashboard" tooltip={t`Move dashboard`}> + <Link + to={location.pathname + "/move"} + data-metabase-event={"Dashboard;Move"} + > + <Icon className="text-brand-hover" name="move" size={18} /> + </Link> + </Tooltip>, + ); + buttons.push( + <Tooltip key="copy-dashboard" tooltip={t`Duplicate dashboard`}> + <Link + to={location.pathname + "/copy"} + data-metabase-event={"Dashboard;Copy"} + > + <Icon className="text-brand-hover" name="clone" size={18} /> + </Link> + </Tooltip>, + ); + } + if ( !isFullscreen && ((isPublicLinksEnabled && (isAdmin || dashboard.public_uuid)) || diff --git a/frontend/src/metabase/entities/containers/EntityCopyModal.jsx b/frontend/src/metabase/entities/containers/EntityCopyModal.jsx new file mode 100644 index 00000000000..cb4944ef502 --- /dev/null +++ b/frontend/src/metabase/entities/containers/EntityCopyModal.jsx @@ -0,0 +1,32 @@ +import React from "react"; +import { dissoc } from "icepick"; +import { t } from "c-3po"; + +import EntityForm from "metabase/entities/containers/EntityForm"; +import ModalContent from "metabase/components/ModalContent"; + +const EntityCopyModal = ({ + entityType, + entityObject, + copy, + onClose, + onSaved, + ...props +}) => ( + <ModalContent title={t`Duplicate "${entityObject.name}"`} onClose={onClose}> + <EntityForm + entityType={entityType} + entityObject={{ + ...dissoc(entityObject, "id"), + name: entityObject.name + " - " + t`Duplicate`, + }} + create={copy} + onClose={onClose} + onSaved={onSaved} + submitTitle={t`Duplicate`} + {...props} + /> + </ModalContent> +); + +export default EntityCopyModal; diff --git a/frontend/src/metabase/entities/dashboards.js b/frontend/src/metabase/entities/dashboards.js index 232872c4ebc..1528b25b5eb 100644 --- a/frontend/src/metabase/entities/dashboards.js +++ b/frontend/src/metabase/entities/dashboards.js @@ -1,11 +1,17 @@ /* @flow */ +import { createThunkAction } from "metabase/lib/redux"; +import { setRequestState } from "metabase/redux/requests"; +import { normalize } from "normalizr"; + import { createEntity, undo } from "metabase/lib/entities"; import * as Urls from "metabase/lib/urls"; import { normal } from "metabase/lib/colors"; import { assocIn } from "icepick"; import { t } from "c-3po"; +import { addUndo } from "metabase/redux/undo"; + import { POST, DELETE } from "metabase/lib/api"; import { canonicalCollectionId, @@ -14,6 +20,7 @@ import { const FAVORITE_ACTION = `metabase/entities/dashboards/FAVORITE`; const UNFAVORITE_ACTION = `metabase/entities/dashboards/UNFAVORITE`; +const COPY_ACTION = `metabase/entities/dashboards/COPY`; const Dashboards = createEntity({ name: "dashboards", @@ -23,6 +30,7 @@ const Dashboards = createEntity({ favorite: POST("/api/dashboard/:id/favorite"), unfavorite: DELETE("/api/dashboard/:id/favorite"), save: POST("/api/dashboard/save"), + copy: POST("/api/dashboard/:id/copy"), }, objectActions: { @@ -59,6 +67,9 @@ const Dashboards = createEntity({ return { type: UNFAVORITE_ACTION, payload: id }; } }, + + copy: ({ id }, overrides, opts) => + Dashboards.actions.copy({ id }, overrides, opts), }, actions: { @@ -70,6 +81,39 @@ const Dashboards = createEntity({ payload: savedDashboard, }; }, + + // TODO move into more common area as copy is implemented for more entities + copy: createThunkAction( + COPY_ACTION, + (entityObject, overrides, { notify } = {}) => async ( + dispatch, + getState, + ) => { + const statePath = [ + "entities", + entityObject.name, + entityObject.id, + "copy", + ]; + try { + dispatch(setRequestState({ statePath, state: "LOADING" })); + const result = normalize( + await Dashboards.api.copy({ id: entityObject.id, ...overrides }), + Dashboards.schema, + ); + dispatch(setRequestState({ statePath, state: "LOADED" })); + if (notify) { + dispatch(addUndo(notify)); + } + dispatch({ type: Dashboards.actionTypes.INVALIDATE_LISTS_ACTION }); + return result; + } catch (error) { + console.error(`${COPY_ACTION} failed:`, error); + dispatch(setRequestState({ statePath, error })); + throw error; + } + }, + ), }, reducer: (state = {}, { type, payload, error }) => { @@ -77,6 +121,8 @@ const Dashboards = createEntity({ return assocIn(state, [payload, "favorite"], true); } else if (type === UNFAVORITE_ACTION && !error) { return assocIn(state, [payload, "favorite"], false); + } else if (type === COPY_ACTION && !error && state[""]) { + return { ...state, "": state[""].concat([payload.result]) }; } return state; }, diff --git a/frontend/src/metabase/icon_paths.js b/frontend/src/metabase/icon_paths.js index 38ef292c3e4..43f250ca01b 100644 --- a/frontend/src/metabase/icon_paths.js +++ b/frontend/src/metabase/icon_paths.js @@ -75,8 +75,8 @@ export const ICON_PATHS = { "M16 0 A16 16 0 0 0 0 16 A16 16 0 0 0 16 32 A16 16 0 0 0 32 16 A16 16 0 0 0 16 0 M16 4 A12 12 0 0 1 28 16 A12 12 0 0 1 16 28 A12 12 0 0 1 4 16 A12 12 0 0 1 16 4 M14 6 L14 17.25 L22 22 L24.25 18.5 L18 14.75 L18 6z", clone: { path: - "M12,11 L16,11 L16,0 L5,0 L5,3 L12,3 L12,11 L12,11 Z M0,4 L11,4 L11,15 L0,15 L0,4 Z", - attrs: { viewBox: "0 0 16 15" }, + "M24,22 L32,22 L32,0 L10,0 L10,6 L24,6 L24,22 L24,22 Z M0,8 L22,8 L22,30 L0,30 L0,8 Z", + attrs: { viewBox: "0 0 32 30" }, }, close: "M4 8 L8 4 L16 12 L24 4 L28 8 L20 16 L28 24 L24 28 L16 20 L8 28 L4 24 L12 16 z ", diff --git a/frontend/src/metabase/routes.jsx b/frontend/src/metabase/routes.jsx index bec70959aba..943d0c0db17 100644 --- a/frontend/src/metabase/routes.jsx +++ b/frontend/src/metabase/routes.jsx @@ -87,6 +87,7 @@ import PublicQuestion from "metabase/public/containers/PublicQuestion.jsx"; import PublicDashboard from "metabase/public/containers/PublicDashboard.jsx"; import { DashboardHistoryModal } from "metabase/dashboard/components/DashboardHistoryModal"; import DashboardMoveModal from "metabase/dashboard/components/DashboardMoveModal"; +import DashboardCopyModal from "metabase/dashboard/components/DashboardCopyModal"; import { ModalRoute } from "metabase/hoc/ModalRoute"; import CollectionLanding from "metabase/components/CollectionLanding"; @@ -219,6 +220,7 @@ export const getRoutes = store => ( > <ModalRoute path="history" modal={DashboardHistoryModal} /> <ModalRoute path="move" modal={DashboardMoveModal} /> + <ModalRoute path="copy" modal={DashboardCopyModal} /> </Route> <Route path="/question"> diff --git a/src/metabase/api/dashboard.clj b/src/metabase/api/dashboard.clj index 47e6f822194..4cbf7a920fc 100644 --- a/src/metabase/api/dashboard.clj +++ b/src/metabase/api/dashboard.clj @@ -188,6 +188,45 @@ (update dashboard :ordered_cards add-query-average-duration-to-dashcards)) +(defn- get-dashboard + "Get `Dashboard` with ID." + [id] + (-> (Dashboard id) + api/check-404 + (hydrate [:ordered_cards :card :series] :can_write) + api/read-check + api/check-not-archived + hide-unreadable-cards + add-query-average-durations)) + + +(api/defendpoint POST "/:from-dashboard-id/copy" + "Copy a `Dashboard`." + [from-dashboard-id :as {{:keys [name description collection_id collection_position], :as dashboard} :body}] + {name (s/maybe su/NonBlankString) + description (s/maybe s/Str) + collection_id (s/maybe su/IntGreaterThanZero) + collection_position (s/maybe su/IntGreaterThanZero)} + ;; if we're trying to save the new dashboard in a Collection make sure we have permissions to do that + (collection/check-write-perms-for-collection collection_id) + (let [existing-dashboard (get-dashboard from-dashboard-id) + dashboard-data {:name (or name (:name existing-dashboard)) + :description (or description (:description existing-dashboard)) + :parameters (or (:parameters existing-dashboard) []) + :creator_id api/*current-user-id* + :collection_id collection_id + :collection_position collection_position} + dashboard (db/transaction + ;; Adding a new dashboard at `collection_position` could cause other dashboards in this collection to change + ;; position, check that and fix up if needed + (api/maybe-reconcile-collection-position! dashboard-data) + ;; Ok, now save the Dashboard + (u/prog1 (db/insert! Dashboard dashboard-data) + ;; Get cards from existing dashboard and associate to copied dashboard + (doseq [card (:ordered_cards existing-dashboard)] + (api/check-500 (dashboard/add-dashcard! <> (:card_id card) card)))))] + (events/publish-event! :dashboard-create dashboard))) + ;;; --------------------------------------------- Fetching/Updating/Etc. --------------------------------------------- diff --git a/test/metabase/api/dashboard_test.clj b/test/metabase/api/dashboard_test.clj index a2626777817..df062100c1a 100644 --- a/test/metabase/api/dashboard_test.clj +++ b/test/metabase/api/dashboard_test.clj @@ -543,6 +543,60 @@ (Dashboard dashboard-id)]))) +;;; +----------------------------------------------------------------------------------------------------------------+ +;;; | COPY /api/dashboard/:id/copy | +;;; +----------------------------------------------------------------------------------------------------------------+ + +;; A plain copy with nothing special +(expect (merge dashboard-defaults + {:name "Test Dashboard" + :description "A description" + :creator_id (user->id :rasta) + :collection_id false}) + (tt/with-temp Dashboard [{dashboard-id :id} {:name "Test Dashboard" + :description "A description" + :creator_id (user->id :rasta)}] + (tu/with-model-cleanup [Dashboard] + (dashboard-response ((user->client :rasta) :post 200 (format "dashboard/%d/copy" dashboard-id)))))) + +;; Ensure name / description / user set when copying +(expect (merge dashboard-defaults + {:name "Test Dashboard - Duplicate" + :description "A new description" + :creator_id (user->id :crowberto) + :collection_id false}) + (tt/with-temp Dashboard [{dashboard-id :id} {:name "Test Dashboard" + :description "An old description"}] + (tu/with-model-cleanup [Dashboard] + (dashboard-response ((user->client :crowberto) :post 200 (format "dashboard/%d/copy" dashboard-id) + {:name "Test Dashboard - Duplicate" + :description "A new description"}))))) + +;; Ensure dashboard cards are copied +(expect + 2 + (tt/with-temp* [Dashboard [{dashboard-id :id} {:name "Test Dashboard"}] + Card [{card-id :id}] + Card [{card-id2 :id}] + DashboardCard [{dashcard-id :id} {:dashboard_id dashboard-id, :card_id card-id}] + DashboardCard [{dashcard-id :id} {:dashboard_id dashboard-id, :card_id card-id2}]] + (tu/with-model-cleanup [Dashboard] + (count (db/select-ids DashboardCard, :dashboard_id + (u/get-id ((user->client :rasta) :post 200 (format "dashboard/%d/copy" dashboard-id)))))))) + +;; Ensure the correct collection is set when copying +(expect + (tu/with-model-cleanup [Dashboard] + (dashboard-test/with-dash-in-collection [db collection dash] + (tt/with-temp Collection [new-collection] + ;; grant Permissions for both new and old collections + (doseq [coll [collection new-collection]] + (perms/grant-collection-readwrite-permissions! (group/all-users) coll)) + ;; Check to make sure the ID of the collection is correct + (= (db/select-one-field :collection_id Dashboard :id + (u/get-id ((user->client :rasta) :post 200 (format "dashboard/%d/copy" (u/get-id dash)) {:collection_id (u/get-id new-collection)}))) + (u/get-id new-collection)))))) + ;;; +----------------------------------------------------------------------------------------------------------------+ ;;; | POST /api/dashboard/:id/cards | ;;; +----------------------------------------------------------------------------------------------------------------+ -- GitLab