diff --git a/src/app/(main)/boards/BoardCloneButton.tsx b/src/app/(main)/boards/BoardCloneButton.tsx new file mode 100644 index 000000000..3f6c7732b --- /dev/null +++ b/src/app/(main)/boards/BoardCloneButton.tsx @@ -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 ( + } + label={showLabel ? 'Clone' : undefined} + title="Clone board" + aria-label="Clone" + variant={showLabel ? undefined : 'quiet'} + width="600px" + > + {({ close }) => } + + ); +} diff --git a/src/app/(main)/boards/BoardCloneForm.tsx b/src/app/(main)/boards/BoardCloneForm.tsx new file mode 100644 index 000000000..ec35f90cb --- /dev/null +++ b/src/app/(main)/boards/BoardCloneForm.tsx @@ -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): 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; + 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 ; + } + + return ( +
+ {({ 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 ( + <> + + + + + + + + + {boardTypeLabel} + + + {requiresBoardEntity(boardType) && ( + + + {boardType === BOARD_TYPES.website ? ( + + ) : boardType === BOARD_TYPES.pixel ? ( + + ) : ( + + )} + + + )} + + {onClose && ( + + )} + Clone + + + ); + }} +
+ ); +} diff --git a/src/app/(main)/boards/[boardId]/BoardViewHeader.tsx b/src/app/(main)/boards/[boardId]/BoardViewHeader.tsx index 7c07b7d64..2bd5c7756 100644 --- a/src/app/(main)/boards/[boardId]/BoardViewHeader.tsx +++ b/src/app/(main)/boards/[boardId]/BoardViewHeader.tsx @@ -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({ }>{t(labels.edit)} + )} diff --git a/src/app/api/boards/[boardId]/clone/route.ts b/src/app/api/boards/[boardId]/clone/route.ts new file mode 100644 index 000000000..7431cdaa6 --- /dev/null +++ b/src/app/api/boards/[boardId]/clone/route.ts @@ -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); +}