diff --git a/frontend/src/metabase-types/api/dashboard.ts b/frontend/src/metabase-types/api/dashboard.ts index dc335949d4152d60d1acc6eb0b48208aad961edd..e40f371cc85384b067cce4164aff114addfa8eb2 100644 --- a/frontend/src/metabase-types/api/dashboard.ts +++ b/frontend/src/metabase-types/api/dashboard.ts @@ -72,7 +72,7 @@ export type VirtualCard = Partial<Card> & { }; export type DashboardCard = BaseDashboardCard & { - card_id: CardId | null; + card_id: CardId | null; // will be null for virtual card card: Card; parameter_mappings?: DashboardParameterMapping[] | null; series?: Card[]; diff --git a/frontend/src/metabase/dashboard/actions/cards.js b/frontend/src/metabase/dashboard/actions/cards.js index a9f767a2bb9acb2bee68117c3b26d03ec7891a8f..20ac2ab884635031e4f9dcc8d62b1a39b3076780 100644 --- a/frontend/src/metabase/dashboard/actions/cards.js +++ b/frontend/src/metabase/dashboard/actions/cards.js @@ -30,7 +30,7 @@ export const markNewCardSeen = createAction(MARK_NEW_CARD_SEEN); let tempId = -1; -function generateTemporaryDashcardId() { +export function generateTemporaryDashcardId() { return tempId--; } diff --git a/frontend/src/metabase/dashboard/components/DashCard/DashCardActionsPanel/DashCardActionsPanel.tsx b/frontend/src/metabase/dashboard/components/DashCard/DashCardActionsPanel/DashCardActionsPanel.tsx index 4b7dedfe81b35d63954252185c178b1666af8edb..440c22b16962de8e6a791de7c00868587171ee8b 100644 --- a/frontend/src/metabase/dashboard/components/DashCard/DashCardActionsPanel/DashCardActionsPanel.tsx +++ b/frontend/src/metabase/dashboard/components/DashCard/DashCardActionsPanel/DashCardActionsPanel.tsx @@ -16,6 +16,7 @@ import type { import { isActionDashCard } from "metabase/actions/utils"; import { isLinkDashCard, isVirtualDashCard } from "metabase/dashboard/utils"; +import { useDuplicateDashCard } from "./use-duplicate-dashcard"; import { ChartSettingsButton } from "./ChartSettingsButton/ChartSettingsButton"; import { DashCardTabMenu } from "./DashCardTabMenu/DashCardTabMenu"; import { DashCardActionButton } from "./DashCardActionButton/DashCardActionButton"; @@ -71,10 +72,10 @@ export function DashCardActionsPanel({ disableClickBehavior, } = getVisualizationRaw(series) ?? {}; - const [isDashCardTabMenuOpen, setIsDashCardTabMenuOpen] = useState(false); - const buttons = []; + const [isDashCardTabMenuOpen, setIsDashCardTabMenuOpen] = useState(false); + if (dashcard) { buttons.push( <DashCardTabMenu @@ -145,6 +146,20 @@ export function DashCardActionsPanel({ ); } + const duplicateDashcard = useDuplicateDashCard({ dashboard, dashcard }); + if (!isLoading && dashcard) { + buttons.push( + <DashCardActionButton + key="duplicate-question" + aria-label={t`Duplicate`} + tooltip={t`Duplicate`} + onClick={duplicateDashcard} + > + <Icon name="copy" /> + </DashCardActionButton>, + ); + } + if (!isLoading && !hasError) { if (supportsSeries) { buttons.push( diff --git a/frontend/src/metabase/dashboard/components/DashCard/DashCardActionsPanel/use-duplicate-dashcard.ts b/frontend/src/metabase/dashboard/components/DashCard/DashCardActionsPanel/use-duplicate-dashcard.ts new file mode 100644 index 0000000000000000000000000000000000000000..c0a4b6039648b87441659a97a5f990a062128900 --- /dev/null +++ b/frontend/src/metabase/dashboard/components/DashCard/DashCardActionsPanel/use-duplicate-dashcard.ts @@ -0,0 +1,80 @@ +import { useCallback } from "react"; + +import { createAction, useDispatch, useSelector } from "metabase/lib/redux"; +import { getPositionForNewDashCard } from "metabase/lib/dashboard_grid"; +import { + getCardData, + getDashboards, + getDashcards, + getSelectedTabId, +} from "metabase/dashboard/selectors"; +import { + FETCH_CARD_DATA, + addDashCardToDashboard, + generateTemporaryDashcardId, +} from "metabase/dashboard/actions"; +import { getExistingDashCards } from "metabase/dashboard/actions/utils"; +import { isVirtualDashCard } from "metabase/dashboard/utils"; +import type { Dashboard, DashboardCard } from "metabase-types/api"; + +export function useDuplicateDashCard({ + dashboard, + dashcard, +}: { + dashboard: Dashboard; + dashcard: DashboardCard | undefined; +}) { + const dispatch = useDispatch(); + const dashboards = useSelector(getDashboards); + const dashcards = useSelector(getDashcards); + const selectedTabId = useSelector(getSelectedTabId); + const dashcardDataMap = useSelector(getCardData); + + return useCallback(() => { + if (!dashcard) { + return () => { + throw new Error( + "duplicateDashcard was called with an undefined dashcard", + ); + }; + } + + const newId = generateTemporaryDashcardId(); + const { id: _id, ...newDashcard } = dashcard; + + const position = getPositionForNewDashCard( + getExistingDashCards(dashboards, dashcards, dashboard.id, selectedTabId), + dashcard.size_x, + dashcard.size_y, + ); + + dispatch( + addDashCardToDashboard({ + dashId: dashboard.id, + dashcardOverrides: { id: newId, ...newDashcard, ...position }, + tabId: selectedTabId, + }), + ); + + // We don't have card (question) data for virtual dashcards (text, heading, link, action) + if (!isVirtualDashCard(dashcard) && dashcard.card_id !== null) { + dispatch( + // Manually copying the card data by dispatching the `FETCH_CARD_DATA` action directly, + // as opposed to using the `fetchCardData` thunk, will send a request to re-fetch the data + createAction(FETCH_CARD_DATA)({ + dashcard_id: newId, + card_id: dashcard.card_id, + result: dashcardDataMap[dashcard.id][dashcard?.card_id], + }), + ); + } + }, [ + dispatch, + dashboard.id, + dashboards, + dashcard, + dashcards, + dashcardDataMap, + selectedTabId, + ]); +}