Skip to content
Snippets Groups Projects
Unverified Commit 65bde770 authored by MarkRx's avatar MarkRx Committed by Cam Saul
Browse files

Add ability to copy a dashboard :page_facing_up:

parent af1dd5c8
No related branches found
No related tags found
No related merge requests found
Showing with 358 additions and 39 deletions
......@@ -55,3 +55,4 @@ bin/node_modules/
*.log
*.trace.db
/backend-checksums.txt
.vscode
......@@ -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;
......@@ -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}`,
......
......@@ -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
......
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;
......@@ -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)) ||
......
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;
/* @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;
},
......
......@@ -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 ",
......
......@@ -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">
......
......@@ -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. ---------------------------------------------
......
......@@ -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 |
;;; +----------------------------------------------------------------------------------------------------------------+
......
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