From 49d10f7ad0f58b577ed72c2a46a062af263ff225 Mon Sep 17 00:00:00 2001 From: Meier Lukas Date: Sat, 4 Jan 2025 19:53:57 +0100 Subject: [PATCH] feat(board): add board duplication (#1856) Co-authored-by: Manuel <30572287+manuel-rw@users.noreply.github.com> --- .../_components/board-card-menu-dropdown.tsx | 25 +++- packages/api/src/router/board.ts | 109 +++++++++++++++++- .../src/boards/duplicate-board-modal.tsx | 96 +++++++++++++++ .../modals-collection/src/boards/index.ts | 1 + packages/translation/src/lang/en.json | 17 +++ packages/validation/src/board.ts | 6 + 6 files changed, 251 insertions(+), 3 deletions(-) create mode 100644 packages/modals-collection/src/boards/duplicate-board-modal.tsx diff --git a/apps/nextjs/src/app/[locale]/manage/boards/_components/board-card-menu-dropdown.tsx b/apps/nextjs/src/app/[locale]/manage/boards/_components/board-card-menu-dropdown.tsx index dd7794db8..eef7dedb2 100644 --- a/apps/nextjs/src/app/[locale]/manage/boards/_components/board-card-menu-dropdown.tsx +++ b/apps/nextjs/src/app/[locale]/manage/boards/_components/board-card-menu-dropdown.tsx @@ -3,12 +3,14 @@ import { useCallback } from "react"; import Link from "next/link"; import { Menu } from "@mantine/core"; -import { IconHome, IconSettings, IconTrash } from "@tabler/icons-react"; +import { IconCopy, IconHome, IconSettings, IconTrash } from "@tabler/icons-react"; import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; +import { useSession } from "@homarr/auth/client"; import { revalidatePathActionAsync } from "@homarr/common/client"; -import { useConfirmModal } from "@homarr/modals"; +import { useConfirmModal, useModalAction } from "@homarr/modals"; +import { DuplicateBoardModal } from "@homarr/modals-collection"; import { useScopedI18n } from "@homarr/translation/client"; import { useBoardPermissions } from "~/components/board/permissions/client"; @@ -30,8 +32,10 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) => const tCommon = useScopedI18n("common"); const { hasFullAccess, hasChangeAccess } = useBoardPermissions(board); + const { data: session } = useSession(); const { openConfirmModal } = useConfirmModal(); + const { openModal: openDuplicateModal } = useModalAction(DuplicateBoardModal); const setHomeBoardMutation = clientApi.board.setHomeBoard.useMutation({ onSettled: async () => { @@ -64,11 +68,28 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) => await setHomeBoardMutation.mutateAsync({ id: board.id }); }, [board.id, setHomeBoardMutation]); + const handleDuplicateBoard = useCallback(() => { + openDuplicateModal({ + board: { + id: board.id, + name: board.name, + }, + onSuccess: async () => { + await revalidatePathActionAsync("/manage/boards"); + }, + }); + }, [board.id, board.name, openDuplicateModal]); + return ( }> {t("setHomeBoard.label")} + {session?.user.permissions.includes("board-create") && ( + }> + {t("duplicate.label")} + + )} {hasChangeAccess && ( <> diff --git a/packages/api/src/router/board.ts b/packages/api/src/router/board.ts index 291ce54a4..182fc90d4 100644 --- a/packages/api/src/router/board.ts +++ b/packages/api/src/router/board.ts @@ -2,7 +2,7 @@ import { TRPCError } from "@trpc/server"; import superjson from "superjson"; import { constructBoardPermissions } from "@homarr/auth/shared"; -import type { Database, SQL } from "@homarr/db"; +import type { Database, InferInsertModel, SQL } from "@homarr/db"; import { and, createId, eq, inArray, like, or } from "@homarr/db"; import { getServerSettingByKeyAsync } from "@homarr/db/queries"; import { @@ -11,7 +11,9 @@ import { boardUserPermissions, groupMembers, groupPermissions, + integrationGroupPermissions, integrationItems, + integrationUserPermissions, items, sections, users, @@ -216,6 +218,111 @@ export const boardRouter = createTRPCRouter({ }); }); }), + duplicateBoard: permissionRequiredProcedure + .requiresPermission("board-create") + .input(validation.board.duplicate) + .mutation(async ({ ctx, input }) => { + await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "view"); + await noBoardWithSimilarNameAsync(ctx.db, input.name); + + const board = await ctx.db.query.boards.findFirst({ + where: eq(boards.id, input.id), + with: { + sections: { + with: { + items: { + with: { + integrations: true, + }, + }, + }, + }, + }, + }); + + if (!board) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Board not found", + }); + } + + const { sections: boardSections, ...boardProps } = board; + + const newBoardId = createId(); + const sectionMap = new Map(boardSections.map((section) => [section.id, createId()])); + const sectionsToInsert: InferInsertModel[] = boardSections.map(({ items: _, ...section }) => ({ + ...section, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + id: sectionMap.get(section.id)!, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + parentSectionId: section.parentSectionId ? sectionMap.get(section.parentSectionId)! : null, + boardId: newBoardId, + })); + const flatItems = boardSections.flatMap((section) => section.items); + const itemMap = new Map(flatItems.map((item) => [item.id, createId()])); + const itemsToInsert: InferInsertModel[] = flatItems.map(({ integrations: _, ...item }) => ({ + ...item, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + id: itemMap.get(item.id)!, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + sectionId: sectionMap.get(item.sectionId)!, + })); + + // Creates a list with all integration ids the user has access to + const hasAccessForAll = ctx.session.user.permissions.includes("integration-use-all"); + const integrationIdsWithAccess = hasAccessForAll + ? [] + : await ctx.db + .selectDistinct({ + id: integrationGroupPermissions.integrationId, + }) + .from(integrationGroupPermissions) + .leftJoin(groupMembers, eq(integrationGroupPermissions.groupId, groupMembers.groupId)) + .where(eq(groupMembers.userId, ctx.session.user.id)) + .union( + ctx.db + .selectDistinct({ id: integrationUserPermissions.integrationId }) + .from(integrationUserPermissions) + .where(eq(integrationUserPermissions.userId, ctx.session.user.id)), + ) + .then((result) => result.map((row) => row.id)); + + const itemIntegrationsToInsert = flatItems.flatMap((item) => + item.integrations + // Restrict integrations to only those the user has access to + .filter(({ integrationId }) => integrationIdsWithAccess.includes(integrationId) || hasAccessForAll) + .map((integration) => ({ + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + itemId: itemMap.get(item.id)!, + integrationId: integration.integrationId, + })), + ); + + ctx.db.transaction((transaction) => { + transaction + .insert(boards) + .values({ + ...boardProps, + id: newBoardId, + name: input.name, + creatorId: ctx.session.user.id, + }) + .run(); + + if (sectionsToInsert.length > 0) { + transaction.insert(sections).values(sectionsToInsert).run(); + } + + if (itemsToInsert.length > 0) { + transaction.insert(items).values(itemsToInsert).run(); + } + + if (itemIntegrationsToInsert.length > 0) { + transaction.insert(integrationItems).values(itemIntegrationsToInsert).run(); + } + }); + }), renameBoard: protectedProcedure.input(validation.board.rename).mutation(async ({ ctx, input }) => { await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "full"); diff --git a/packages/modals-collection/src/boards/duplicate-board-modal.tsx b/packages/modals-collection/src/boards/duplicate-board-modal.tsx new file mode 100644 index 000000000..083cb716b --- /dev/null +++ b/packages/modals-collection/src/boards/duplicate-board-modal.tsx @@ -0,0 +1,96 @@ +import { Button, Group, Stack, Text, TextInput } from "@mantine/core"; + +import { clientApi } from "@homarr/api/client"; +import type { MaybePromise } from "@homarr/common/types"; +import { useZodForm } from "@homarr/form"; +import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; +import { useI18n } from "@homarr/translation/client"; +import { validation } from "@homarr/validation"; + +import { createModal } from "../../../modals/src/creator"; +import { useBoardNameStatus } from "./add-board-modal"; + +interface InnerProps { + board: { + id: string; + name: string; + }; + onSuccess: () => MaybePromise; +} + +export const DuplicateBoardModal = createModal(({ actions, innerProps }) => { + const t = useI18n(); + const form = useZodForm(validation.board.duplicate.omit({ id: true }), { + mode: "controlled", + initialValues: { + name: innerProps.board.name, + }, + }); + const boardNameStatus = useBoardNameStatus(form.values.name); + const { mutateAsync, isPending } = clientApi.board.duplicateBoard.useMutation(); + + return ( +
{ + // Prevent submit before name availability check + if (!boardNameStatus.canSubmit) return; + await mutateAsync( + { + ...values, + id: innerProps.board.id, + }, + { + async onSuccess() { + actions.closeModal(); + showSuccessNotification({ + title: t("board.action.duplicate.notification.success.title"), + message: t("board.action.duplicate.notification.success.message"), + }); + await innerProps.onSuccess(); + }, + onError() { + showErrorNotification({ + title: t("board.action.duplicate.notification.error.title"), + message: t("board.action.duplicate.notification.error.message"), + }); + }, + }, + ); + })} + > + + + {t("board.action.duplicate.message", { name: innerProps.board.name })} + + + + {boardNameStatus.description.icon ? : null} + {boardNameStatus.description.label} + + ) : null + } + withAsterisk + /> + + + + + + +
+ ); +}).withOptions({ + defaultTitle(t) { + return t("board.action.duplicate.title"); + }, +}); diff --git a/packages/modals-collection/src/boards/index.ts b/packages/modals-collection/src/boards/index.ts index 3da35c853..8594bb424 100644 --- a/packages/modals-collection/src/boards/index.ts +++ b/packages/modals-collection/src/boards/index.ts @@ -1,2 +1,3 @@ export { AddBoardModal } from "./add-board-modal"; export { ImportBoardModal } from "./import-board-modal"; +export { DuplicateBoardModal } from "./duplicate-board-modal"; diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 9a87ad643..5e0754650 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -1760,6 +1760,20 @@ }, "board": { "action": { + "duplicate": { + "title": "Duplicate board", + "message": "This will duplicate the board {name} with all its content. If widgets reference integrations, that you are not allowed to use, they will be removed.", + "notification": { + "success": { + "title": "Board duplicated", + "message": "The board was successfully duplicated" + }, + "error": { + "title": "Unable to duplicate board", + "message": "The board could not be duplicated" + } + } + }, "edit": { "notification": { "success": { @@ -2125,6 +2139,9 @@ "tooltip": "This board will show as your home board" } }, + "duplicate": { + "label": "Duplicate board" + }, "delete": { "label": "Delete permanently", "confirm": { diff --git a/packages/validation/src/board.ts b/packages/validation/src/board.ts index 2783f7a88..1fd121a95 100644 --- a/packages/validation/src/board.ts +++ b/packages/validation/src/board.ts @@ -28,6 +28,11 @@ const renameSchema = z.object({ name: boardNameSchema, }); +const duplicateSchema = z.object({ + id: z.string(), + name: boardNameSchema, +}); + const changeVisibilitySchema = z.object({ id: z.string(), visibility: z.enum(["public", "private"]), @@ -85,6 +90,7 @@ export const boardSchemas = { savePartialSettings: savePartialSettingsSchema, save: saveSchema, create: createSchema, + duplicate: duplicateSchema, rename: renameSchema, changeVisibility: changeVisibilitySchema, permissions: permissionsSchema,