feat(board): add board duplication (#1856)
Co-authored-by: Manuel <30572287+manuel-rw@users.noreply.github.com>
This commit is contained in:
@@ -3,12 +3,14 @@
|
|||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Menu } from "@mantine/core";
|
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 type { RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { useSession } from "@homarr/auth/client";
|
||||||
import { revalidatePathActionAsync } from "@homarr/common/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 { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import { useBoardPermissions } from "~/components/board/permissions/client";
|
import { useBoardPermissions } from "~/components/board/permissions/client";
|
||||||
@@ -30,8 +32,10 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
|
|||||||
const tCommon = useScopedI18n("common");
|
const tCommon = useScopedI18n("common");
|
||||||
|
|
||||||
const { hasFullAccess, hasChangeAccess } = useBoardPermissions(board);
|
const { hasFullAccess, hasChangeAccess } = useBoardPermissions(board);
|
||||||
|
const { data: session } = useSession();
|
||||||
|
|
||||||
const { openConfirmModal } = useConfirmModal();
|
const { openConfirmModal } = useConfirmModal();
|
||||||
|
const { openModal: openDuplicateModal } = useModalAction(DuplicateBoardModal);
|
||||||
|
|
||||||
const setHomeBoardMutation = clientApi.board.setHomeBoard.useMutation({
|
const setHomeBoardMutation = clientApi.board.setHomeBoard.useMutation({
|
||||||
onSettled: async () => {
|
onSettled: async () => {
|
||||||
@@ -64,11 +68,28 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
|
|||||||
await setHomeBoardMutation.mutateAsync({ id: board.id });
|
await setHomeBoardMutation.mutateAsync({ id: board.id });
|
||||||
}, [board.id, setHomeBoardMutation]);
|
}, [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 (
|
return (
|
||||||
<Menu.Dropdown>
|
<Menu.Dropdown>
|
||||||
<Menu.Item onClick={handleSetHomeBoard} leftSection={<IconHome {...iconProps} />}>
|
<Menu.Item onClick={handleSetHomeBoard} leftSection={<IconHome {...iconProps} />}>
|
||||||
{t("setHomeBoard.label")}
|
{t("setHomeBoard.label")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
{session?.user.permissions.includes("board-create") && (
|
||||||
|
<Menu.Item onClick={handleDuplicateBoard} leftSection={<IconCopy {...iconProps} />}>
|
||||||
|
{t("duplicate.label")}
|
||||||
|
</Menu.Item>
|
||||||
|
)}
|
||||||
{hasChangeAccess && (
|
{hasChangeAccess && (
|
||||||
<>
|
<>
|
||||||
<Menu.Divider />
|
<Menu.Divider />
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { TRPCError } from "@trpc/server";
|
|||||||
import superjson from "superjson";
|
import superjson from "superjson";
|
||||||
|
|
||||||
import { constructBoardPermissions } from "@homarr/auth/shared";
|
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 { and, createId, eq, inArray, like, or } from "@homarr/db";
|
||||||
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
|
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
|
||||||
import {
|
import {
|
||||||
@@ -11,7 +11,9 @@ import {
|
|||||||
boardUserPermissions,
|
boardUserPermissions,
|
||||||
groupMembers,
|
groupMembers,
|
||||||
groupPermissions,
|
groupPermissions,
|
||||||
|
integrationGroupPermissions,
|
||||||
integrationItems,
|
integrationItems,
|
||||||
|
integrationUserPermissions,
|
||||||
items,
|
items,
|
||||||
sections,
|
sections,
|
||||||
users,
|
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<string, string>(boardSections.map((section) => [section.id, createId()]));
|
||||||
|
const sectionsToInsert: InferInsertModel<typeof sections>[] = 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<string, string>(flatItems.map((item) => [item.id, createId()]));
|
||||||
|
const itemsToInsert: InferInsertModel<typeof items>[] = 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 }) => {
|
renameBoard: protectedProcedure.input(validation.board.rename).mutation(async ({ ctx, input }) => {
|
||||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "full");
|
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "full");
|
||||||
|
|
||||||
|
|||||||
@@ -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<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DuplicateBoardModal = createModal<InnerProps>(({ 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 (
|
||||||
|
<form
|
||||||
|
onSubmit={form.onSubmit(async (values) => {
|
||||||
|
// 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"),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Stack>
|
||||||
|
<Text size="sm" c="gray.6">
|
||||||
|
{t("board.action.duplicate.message", { name: innerProps.board.name })}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label={t("board.field.name.label")}
|
||||||
|
data-autofocus
|
||||||
|
{...form.getInputProps("name")}
|
||||||
|
description={
|
||||||
|
boardNameStatus.description ? (
|
||||||
|
<Group c={boardNameStatus.description.color} gap="xs" align="center">
|
||||||
|
{boardNameStatus.description.icon ? <boardNameStatus.description.icon size={16} /> : null}
|
||||||
|
<span>{boardNameStatus.description.label}</span>
|
||||||
|
</Group>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
withAsterisk
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Group justify="end">
|
||||||
|
<Button variant="subtle" color="gray" onClick={actions.closeModal}>
|
||||||
|
{t("common.action.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" loading={isPending}>
|
||||||
|
{t("common.action.create")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}).withOptions({
|
||||||
|
defaultTitle(t) {
|
||||||
|
return t("board.action.duplicate.title");
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
export { AddBoardModal } from "./add-board-modal";
|
export { AddBoardModal } from "./add-board-modal";
|
||||||
export { ImportBoardModal } from "./import-board-modal";
|
export { ImportBoardModal } from "./import-board-modal";
|
||||||
|
export { DuplicateBoardModal } from "./duplicate-board-modal";
|
||||||
|
|||||||
@@ -1760,6 +1760,20 @@
|
|||||||
},
|
},
|
||||||
"board": {
|
"board": {
|
||||||
"action": {
|
"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": {
|
"edit": {
|
||||||
"notification": {
|
"notification": {
|
||||||
"success": {
|
"success": {
|
||||||
@@ -2125,6 +2139,9 @@
|
|||||||
"tooltip": "This board will show as your home board"
|
"tooltip": "This board will show as your home board"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"duplicate": {
|
||||||
|
"label": "Duplicate board"
|
||||||
|
},
|
||||||
"delete": {
|
"delete": {
|
||||||
"label": "Delete permanently",
|
"label": "Delete permanently",
|
||||||
"confirm": {
|
"confirm": {
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ const renameSchema = z.object({
|
|||||||
name: boardNameSchema,
|
name: boardNameSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const duplicateSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
name: boardNameSchema,
|
||||||
|
});
|
||||||
|
|
||||||
const changeVisibilitySchema = z.object({
|
const changeVisibilitySchema = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
visibility: z.enum(["public", "private"]),
|
visibility: z.enum(["public", "private"]),
|
||||||
@@ -85,6 +90,7 @@ export const boardSchemas = {
|
|||||||
savePartialSettings: savePartialSettingsSchema,
|
savePartialSettings: savePartialSettingsSchema,
|
||||||
save: saveSchema,
|
save: saveSchema,
|
||||||
create: createSchema,
|
create: createSchema,
|
||||||
|
duplicate: duplicateSchema,
|
||||||
rename: renameSchema,
|
rename: renameSchema,
|
||||||
changeVisibility: changeVisibilitySchema,
|
changeVisibility: changeVisibilitySchema,
|
||||||
permissions: permissionsSchema,
|
permissions: permissionsSchema,
|
||||||
|
|||||||
Reference in New Issue
Block a user