implement board clone feature

This commit is contained in:
Francis Cao committed 2026-06-30 08:50:33 -07:00
1 parent ab5b1bf354
commit 2efe6297be
4 files changed
+269

No files matched your search

@@ -0,0 +1,32 @@
import { Copy } from '@/components/icons';
import { useNavigation } from '@/components/hooks';
import { DialogButton } from '@/components/input/DialogButton';
import type { Board } from '@/lib/types';
import { BoardCloneForm } from './BoardCloneForm';
export function BoardCloneButton({
boardId,
showLabel = false,
}: {
boardId: string;
showLabel?: boolean;
}) {
const { router, renderUrl } = useNavigation();
const handleSave = (board: Board) => {
router.push(renderUrl(`/boards/${board.id}/design`, false));
};
return (
<DialogButton
icon={<Copy />}
label={showLabel ? 'Clone' : undefined}
title="Clone board"
aria-label="Clone"
variant={showLabel ? undefined : 'quiet'}
width="600px"
>
{({ close }) => <BoardCloneForm boardId={boardId} onSave={handleSave} onClose={close} />}
</DialogButton>
);
}
+164
View File
@@ -0,0 +1,164 @@
import {
Box,
Button,
Form,
FormField,
FormSubmitButton,
Loading,
Row,
Text,
TextField,
} from '@umami/react-zen';
import { useBoardQuery, useMessages, useUpdateQuery } from '@/components/hooks';
import { LinkSelect } from '@/components/input/LinkSelect';
import { PixelSelect } from '@/components/input/PixelSelect';
import { WebsiteSelect } from '@/components/input/WebsiteSelect';
import {
BOARD_TYPES,
type BoardType,
getBoardEntity,
getBoardType,
requiresBoardEntity,
setBoardEntity,
} from '@/lib/boards';
import type { Board } from '@/lib/types';
interface BoardCloneFormValues {
name: string;
description: string;
type: BoardType;
entityId: string;
}
function getDefaultValues(board?: Partial<Board>): BoardCloneFormValues {
const boardType = getBoardType(board, { coerceDashboard: true });
const { entityId } = getBoardEntity(board);
return {
name: board?.name ? `${board.name} copy` : '',
description: board?.description ?? '',
type: boardType,
entityId: entityId ?? '',
};
}
export function BoardCloneForm({
boardId,
onSave,
onClose,
}: {
boardId: string;
onSave?: (board: Board) => void | Promise<void>;
onClose?: () => void;
}) {
const { t, labels, messages, getErrorMessage } = useMessages();
const { data: board, isLoading } = useBoardQuery(boardId);
const { mutateAsync, error, isPending, touch, toast } = useUpdateQuery(`/boards/${boardId}/clone`);
const values = getDefaultValues(board);
const boardType = getBoardType(board, { coerceDashboard: true });
const resolvedTeamId = board?.teamId;
const handleSubmit = async (data: BoardCloneFormValues) => {
const result = await mutateAsync({
name: data.name,
description: data.description,
parameters: setBoardEntity(board?.parameters, boardType, data.entityId || undefined),
});
toast(t(messages.saved));
touch('boards');
touch(`board:${result.id}`);
await onSave?.(result);
onClose?.();
};
if (isLoading) {
return <Loading placement="absolute" />;
}
return (
<Form onSubmit={handleSubmit} error={getErrorMessage(error)} values={values}>
{({ watch, setValue }) => {
const entityId = watch('entityId') as string;
const entityLabel =
boardType === BOARD_TYPES.pixel
? t(labels.pixel)
: boardType === BOARD_TYPES.link
? t(labels.link)
: t(labels.website);
const boardTypeLabel =
boardType === BOARD_TYPES.mixed
? t(labels.open)
: boardType === BOARD_TYPES.pixel
? t(labels.pixel)
: boardType === BOARD_TYPES.link
? t(labels.link)
: t(labels.website);
const handleEntityChange = (value: string) => {
setValue('entityId', value, { shouldDirty: true });
};
return (
<>
<FormField name="name" label={t(labels.name)} rules={{ required: t(labels.required) }}>
<TextField autoComplete="off" autoFocus placeholder={t(labels.untitled)} />
</FormField>
<FormField name="description" label={t(labels.description)}>
<TextField
autoComplete="off"
asTextArea
resize="vertical"
placeholder={t(labels.addDescription)}
/>
</FormField>
<FormField name="type" label={t(labels.boardType)}>
<Box width="100%" maxWidth="360px">
<Text>{boardTypeLabel}</Text>
</Box>
</FormField>
{requiresBoardEntity(boardType) && (
<FormField
name="entityId"
label={entityLabel}
rules={{ required: t(labels.required) }}
>
<Box width="100%" maxWidth="360px">
{boardType === BOARD_TYPES.website ? (
<WebsiteSelect
websiteId={entityId}
teamId={resolvedTeamId}
onChange={handleEntityChange}
/>
) : boardType === BOARD_TYPES.pixel ? (
<PixelSelect
pixelId={entityId}
teamId={resolvedTeamId}
placeholder={t(labels.selectPixel)}
onChange={handleEntityChange}
/>
) : (
<LinkSelect
linkId={entityId}
teamId={resolvedTeamId}
placeholder={t(labels.selectLink)}
onChange={handleEntityChange}
/>
)}
</Box>
</FormField>
)}
<Row justifyContent="flex-end" paddingTop="3" gap="3">
{onClose && (
<Button isDisabled={isPending} onPress={onClose}>
{t(labels.cancel)}
</Button>
)}
<FormSubmitButton isDisabled={isPending}>Clone</FormSubmitButton>
</Row>
</>
);
}}
</Form>
);
}
@@ -5,6 +5,7 @@ import { PageHeader } from '@/components/common/PageHeader';
import { useBoard, useMessages, useNavigation } from '@/components/hooks';
import { Edit, LayoutDashboard } from '@/components/icons';
import { getBoardEntity } from '@/lib/boards';
import { BoardCloneButton } from '../BoardCloneButton';
import { BoardEntityBadge } from '../BoardEntityBadge';
import { useBoardEntityBadgeProps } from '../useBoardEntityBadgeProps';
@@ -33,6 +34,7 @@ export function BoardViewHeader({
<LinkButton href={renderUrl(`/boards/${board.id}/edit`, false)}>
<IconLabel icon={<Edit />}>{t(labels.edit)}</IconLabel>
</LinkButton>
<BoardCloneButton boardId={board.id} showLabel />
</>
)}
</Row>
@@ -0,0 +1,71 @@
import type { Prisma } from '@/generated/prisma/client';
import { z } from 'zod';
import { BOARD_TYPES, normalizeBoardType } from '@/lib/boards';
import type { BoardParameters } from '@/lib/types';
import { uuid } from '@/lib/crypto';
import { parseRequest } from '@/lib/request';
import { badRequest, json, unauthorized } from '@/lib/response';
import {
canCreateTeamWebsite,
canCreateWebsite,
canUpdateBoard,
canViewBoardEntities,
} from '@/permissions';
import { createBoard, getBoard } from '@/queries/prisma';
export async function POST(request: Request, { params }: { params: Promise<{ boardId: string }> }) {
const schema = z.object({
name: z.string().max(200).optional(),
description: z.string().max(500).optional(),
parameters: z.object({}).passthrough().optional(),
});
const { auth, body, error } = await parseRequest(request, schema);
if (error) {
return error();
}
const { boardId } = await params;
if (!(await canUpdateBoard(auth, boardId))) {
return unauthorized();
}
const sourceBoard = await getBoard(boardId);
if (!sourceBoard) {
return unauthorized();
}
const sourceType = normalizeBoardType(sourceBoard.type);
if (!sourceType || sourceType === BOARD_TYPES.dashboard) {
return badRequest({ message: 'Board cannot be cloned.' });
}
if (
(sourceBoard.teamId && !(await canCreateTeamWebsite(auth, sourceBoard.teamId))) ||
!(await canCreateWebsite(auth))
) {
return unauthorized();
}
const parameters = (body.parameters ?? sourceBoard.parameters ?? {}) as BoardParameters;
if (!(await canViewBoardEntities(auth, sourceType, parameters))) {
return badRequest({ message: 'Board contains inaccessible entities.' });
}
const result = await createBoard({
id: uuid(),
type: sourceType,
name: body.name ?? sourceBoard.name,
description: body.description ?? sourceBoard.description,
parameters: parameters as Prisma.InputJsonValue,
teamId: sourceBoard.teamId,
userId: sourceBoard.teamId ? undefined : sourceBoard.userId ?? auth.user.id,
});
return json(result);
}