implement board clone feature
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user