Split board details and design flows

This commit is contained in:
Mike Cao
2026-03-10 02:08:24 -07:00
parent 54023e3796
commit cdcb1f379b
17 changed files with 348 additions and 202 deletions
+20
View File
@@ -0,0 +1,20 @@
import { useMessages, useNavigation } from '@/components/hooks';
import { Plus } from '@/components/icons';
import { DialogButton } from '@/components/input/DialogButton';
import type { Board } from '@/lib/types';
import { BoardEditForm } from './BoardEditForm';
export function BoardAddButton() {
const { t, labels } = useMessages();
const { teamId, router, renderUrl } = useNavigation();
const handleSave = (board: Board) => {
router.push(renderUrl(`/boards/${board.id}/design`, false));
};
return (
<DialogButton icon={<Plus />} label={t(labels.addBoard)} variant="primary" width="600px">
{({ close }) => <BoardEditForm teamId={teamId} onSave={handleSave} onClose={close} />}
</DialogButton>
);
}
@@ -0,0 +1,16 @@
import { Icon } from '@umami/react-zen';
import { LinkButton } from '@/components/common/LinkButton';
import { useNavigation } from '@/components/hooks';
import { LayoutDashboard } from '@/components/icons';
export function BoardDesignButton({ boardId }: { boardId: string }) {
const { renderUrl } = useNavigation();
return (
<LinkButton href={renderUrl(`/boards/${boardId}/design`)} aria-label="Design" variant="quiet">
<Icon>
<LayoutDashboard />
</Icon>
</LinkButton>
);
}
@@ -10,7 +10,6 @@ export function BoardEditButton({ boardId }: { boardId: string }) {
return (
<LinkButton
href={renderUrl(`/boards/${boardId}/edit`)}
title={t(labels.edit)}
aria-label={t(labels.edit)}
variant="quiet"
>
+199
View File
@@ -0,0 +1,199 @@
import {
Box,
Button,
Form,
FormField,
FormSubmitButton,
ListItem,
Loading,
Row,
Select,
TextField,
} from '@umami/react-zen';
import { useEffect, useState } from 'react';
import { useBoardQuery, useMessages, useNavigation, 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 BoardFormValues {
name: string;
description: string;
type: BoardType;
entityId: string;
}
function getDefaultValues(board?: Partial<Board>): BoardFormValues {
const boardType = getBoardType(board, { coerceDashboard: true });
const { entityId } = getBoardEntity(board);
return {
name: board?.name ?? '',
description: board?.description ?? '',
type: boardType,
entityId: entityId ?? '',
};
}
export function BoardEditForm({
boardId,
teamId,
onSave,
onClose,
}: {
boardId?: string;
teamId?: string;
onSave?: (board: Board) => void | Promise<void>;
onClose?: () => void;
}) {
const { t, labels, messages, getErrorMessage } = useMessages();
const { teamId: navigationTeamId } = useNavigation();
const resolvedTeamId = teamId ?? navigationTeamId;
const { data, isLoading } = useBoardQuery(boardId || '');
const { mutateAsync, error, isPending, touch, toast } = useUpdateQuery(
boardId ? `/boards/${boardId}` : '/boards',
{
id: boardId,
teamId: resolvedTeamId,
},
);
const [values, setValues] = useState<BoardFormValues>(getDefaultValues());
useEffect(() => {
if (data) {
setValues(getDefaultValues(data));
}
}, [data]);
const handleSubmit = async () => {
const result = await mutateAsync({
name: values.name,
description: values.description,
type: values.type,
parameters: setBoardEntity({}, values.type, values.entityId || undefined),
});
toast(t(messages.saved));
touch('boards');
touch(`board:${result.id}`);
await onSave?.(result);
onClose?.();
};
const handleNameChange = (name: string) => {
setValues(current => ({ ...current, name }));
};
const handleDescriptionChange = (description: string) => {
setValues(current => ({ ...current, description }));
};
const handleTypeChange = (type: string) => {
setValues(current => ({
...current,
type: type as BoardType,
entityId: '',
}));
};
const handleEntityChange = (entityId: string) => {
setValues(current => ({ ...current, entityId }));
};
if (boardId && isLoading) {
return <Loading placement="absolute" />;
}
const entityLabel =
values.type === BOARD_TYPES.pixel
? t(labels.pixel)
: values.type === BOARD_TYPES.link
? t(labels.link)
: t(labels.website);
return (
<Form onSubmit={handleSubmit} error={getErrorMessage(error)} values={values}>
<FormField name="name" label={t(labels.name)} rules={{ required: t(labels.required) }}>
<TextField
autoComplete="off"
autoFocus={!boardId}
value={values.name}
placeholder={t(labels.untitled)}
onChange={handleNameChange}
/>
</FormField>
<FormField name="description" label={t(labels.description)}>
<TextField
autoComplete="off"
asTextArea
resize="vertical"
value={values.description}
placeholder={t(labels.addDescription)}
onChange={handleDescriptionChange}
/>
</FormField>
<FormField
name="type"
label={t(labels.boardType)}
rules={{ required: t(labels.required) }}
>
<Box width="100%" maxWidth="360px">
<Select value={values.type} onChange={handleTypeChange}>
<ListItem id={BOARD_TYPES.mixed}>{t(labels.open)}</ListItem>
<ListItem id={BOARD_TYPES.website}>{t(labels.website)}</ListItem>
<ListItem id={BOARD_TYPES.pixel}>{t(labels.pixel)}</ListItem>
<ListItem id={BOARD_TYPES.link}>{t(labels.link)}</ListItem>
</Select>
</Box>
</FormField>
{requiresBoardEntity(values.type) && (
<FormField
name="entityId"
label={entityLabel}
rules={{ required: t(labels.required) }}
>
<Box width="100%" maxWidth="360px">
{values.type === BOARD_TYPES.website ? (
<WebsiteSelect
websiteId={values.entityId}
teamId={resolvedTeamId}
onChange={handleEntityChange}
/>
) : values.type === BOARD_TYPES.pixel ? (
<PixelSelect
pixelId={values.entityId}
teamId={resolvedTeamId}
placeholder={t(labels.selectPixel)}
onChange={handleEntityChange}
/>
) : (
<LinkSelect
linkId={values.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}>{t(labels.save)}</FormSubmitButton>
</Row>
</Form>
);
}
+19 -9
View File
@@ -70,6 +70,7 @@ export function BoardProvider({
const { router, renderUrl, teamId } = useNavigation();
const [board, setBoard] = useState<Partial<Board>>(data ?? createDefaultBoard());
const boardRef = useRef<Partial<Board>>(data ?? createDefaultBoard());
const layoutGetterRef = useRef<LayoutGetter | null>(null);
const registerLayoutGetter = useCallback((getter: LayoutGetter) => {
@@ -78,11 +79,14 @@ export function BoardProvider({
useEffect(() => {
if (data) {
setBoard({
const nextBoard = {
...data,
type: getBoardType(data, { coerceDashboard: true }),
parameters: sanitizeBoardParameters(data.parameters),
});
};
boardRef.current = nextBoard;
setBoard(nextBoard);
}
}, [data]);
@@ -101,35 +105,41 @@ export function BoardProvider({
});
const updateBoard = useCallback((data: Partial<Board>) => {
setBoard(current => ({ ...current, ...data }));
setBoard(current => {
const nextBoard = { ...current, ...data };
boardRef.current = nextBoard;
return nextBoard;
});
}, []);
const saveBoard = useCallback(async () => {
const currentBoard = boardRef.current;
const defaultName = t(labels.untitled);
// Get current layout sizes from BoardEditBody if registered
const layoutData = layoutGetterRef.current?.();
const parameters = sanitizeBoardParameters(
layoutData ? { ...board.parameters, ...layoutData } : board.parameters,
layoutData ? { ...currentBoard.parameters, ...layoutData } : currentBoard.parameters,
);
const result = await mutateAsync({
...board,
name: board.name || defaultName,
...currentBoard,
name: currentBoard.name || defaultName,
parameters,
});
toast(t(messages.saved));
touch('boards');
if (board.id) {
touch(`board:${board.id}`);
if (currentBoard.id) {
touch(`board:${currentBoard.id}`);
} else if (result?.id) {
router.push(renderUrl(`/boards/${result.id}`));
}
return result;
}, [board, mutateAsync, toast, t, labels.untitled, messages.saved, touch, router, renderUrl]);
}, [mutateAsync, toast, t, labels.untitled, messages.saved, touch, router, renderUrl]);
if (boardId && isFetching && isLoading) {
return <Loading placement="absolute" />;
+3 -8
View File
@@ -1,25 +1,20 @@
'use client';
import { Column } from '@umami/react-zen';
import { IconLabel } from '@/components/common/IconLabel';
import { LinkButton } from '@/components/common/LinkButton';
import { PageBody } from '@/components/common/PageBody';
import { PageHeader } from '@/components/common/PageHeader';
import { Panel } from '@/components/common/Panel';
import { useMessages, useNavigation } from '@/components/hooks';
import { Plus } from '@/components/icons';
import { useMessages } from '@/components/hooks';
import { BoardAddButton } from './BoardAddButton';
import { BoardsDataTable } from './BoardsDataTable';
export function BoardsPage() {
const { t, labels } = useMessages();
const { renderUrl } = useNavigation();
return (
<PageBody>
<Column margin="2">
<PageHeader title={t(labels.boards)}>
<LinkButton href={renderUrl('/boards/create')} variant="primary">
<IconLabel icon={<Plus />} label={t(labels.addBoard)} />
</LinkButton>
<BoardAddButton />
</PageHeader>
<Panel>
<BoardsDataTable />
+2
View File
@@ -3,6 +3,7 @@ import Link from 'next/link';
import { DateDistance } from '@/components/common/DateDistance';
import { useMessages, useNavigation } from '@/components/hooks';
import { BoardDeleteButton } from './BoardDeleteButton';
import { BoardDesignButton } from './BoardDesignButton';
import { BoardEditButton } from './BoardEditButton';
export function BoardsTable(props: DataTableProps) {
@@ -25,6 +26,7 @@ export function BoardsTable(props: DataTableProps) {
return (
<Row>
<BoardEditButton boardId={id} />
<BoardDesignButton boardId={id} />
<BoardDeleteButton boardId={id} name={name} />
</Row>
);
@@ -95,11 +95,7 @@ export function BoardComponentSelect({
const definition = allDefinitions.find(def => def.type === initialConfig.type);
if (!definition || !isBoardComponentSupported(definition.type, activeEntityType)) {
setSelectedDef(null);
setConfigValues({});
setTitle('');
setDescription('');
if (!definition) {
return;
}
@@ -114,24 +110,12 @@ export function BoardComponentSelect({
}, [
initialConfig,
allDefinitions,
activeEntityType,
boardEntityId,
boardEntityType,
initialEntity.entityId,
initialEntity.entityType,
]);
useEffect(() => {
if (!selectedDef || isSelectedDefSupported) {
return;
}
setSelectedDef(null);
setConfigValues({});
setTitle('');
setDescription('');
}, [isSelectedDefSupported, selectedDef]);
const handleSelectComponent = (def: ComponentDefinition) => {
setSelectedDef(def);
setConfigValues(getDefaultConfigValues(def));
@@ -211,7 +195,8 @@ export function BoardComponentSelect({
<Column gap="1" style={{ width: 280, flexShrink: 0, overflowY: 'auto' }}>
{CATEGORIES.map(category => {
const components = getComponentsByCategory(category.key).filter(def =>
isBoardComponentSupported(def.type, activeEntityType),
isBoardComponentSupported(def.type, activeEntityType) ||
def.type === selectedDef?.type,
);
if (!components.length) {
@@ -1,150 +0,0 @@
import { Box, Form, FormField, ListItem, Row, Select, TextField } from '@umami/react-zen';
import { Panel } from '@/components/common/Panel';
import { useBoard, useMessages, useNavigation } 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';
export function BoardEditForm() {
const { board, updateBoard, saveBoard } = useBoard();
const { t, labels } = useMessages();
const { teamId } = useNavigation();
const boardType = getBoardType(board, { coerceDashboard: true });
const { entityId } = getBoardEntity(board);
const handleNameChange = (name: string) => {
updateBoard({ name });
};
const handleDescriptionChange = (description: string) => {
updateBoard({ description });
};
const handleTypeChange = (type: string) => {
updateBoard({
type,
parameters: setBoardEntity(board.parameters, type as BoardType),
});
};
const handleEntityChange = (nextEntityId: string) => {
updateBoard({
parameters: setBoardEntity(board.parameters, boardType, nextEntityId),
});
};
const renderEntitySelect = () => {
if (boardType === BOARD_TYPES.website) {
return (
<WebsiteSelect
websiteId={entityId}
teamId={teamId}
onChange={handleEntityChange}
width="100%"
/>
);
}
if (boardType === BOARD_TYPES.pixel) {
return (
<PixelSelect
pixelId={entityId}
teamId={teamId}
placeholder={t(labels.selectPixel)}
onChange={handleEntityChange}
width="100%"
/>
);
}
if (boardType === BOARD_TYPES.link) {
return (
<LinkSelect
linkId={entityId}
teamId={teamId}
placeholder={t(labels.selectLink)}
onChange={handleEntityChange}
width="100%"
/>
);
}
return null;
};
const entityLabel =
boardType === BOARD_TYPES.pixel
? t(labels.pixel)
: boardType === BOARD_TYPES.link
? t(labels.link)
: t(labels.website);
return (
<Row width="100%" justifyContent="center">
<Panel width="100%" maxWidth="600px" marginBottom="6">
<Form
onSubmit={saveBoard}
values={{
name: board?.name ?? '',
description: board?.description ?? '',
type: boardType,
entityId: entityId ?? '',
}}
>
<FormField name="name" label={t(labels.name)} rules={{ required: t(labels.required) }}>
<TextField
autoComplete="off"
autoFocus={!board?.id}
value={board?.name ?? ''}
placeholder={t(labels.untitled)}
onChange={handleNameChange}
/>
</FormField>
<FormField name="description" label={t(labels.description)}>
<TextField
autoComplete="off"
asTextArea
resize="vertical"
value={board?.description ?? ''}
placeholder={t(labels.addDescription)}
onChange={handleDescriptionChange}
/>
</FormField>
<FormField
name="type"
label={t(labels.boardType)}
rules={{ required: t(labels.required) }}
>
<Box width="100%" maxWidth="360px">
<Select value={boardType} onChange={handleTypeChange} width="100%">
<ListItem id={BOARD_TYPES.mixed}>{t(labels.open)}</ListItem>
<ListItem id={BOARD_TYPES.website}>{t(labels.website)}</ListItem>
<ListItem id={BOARD_TYPES.pixel}>{t(labels.pixel)}</ListItem>
<ListItem id={BOARD_TYPES.link}>{t(labels.link)}</ListItem>
</Select>
</Box>
</FormField>
{requiresBoardEntity(boardType) && (
<FormField
name="entityId"
label={entityLabel}
rules={{ required: t(labels.required) }}
>
<Box width="100%" maxWidth="360px">
{renderEntitySelect()}
</Box>
</FormField>
)}
</Form>
</Panel>
</Row>
);
}
@@ -2,12 +2,11 @@ import { Button, LoadingButton, Row } from '@umami/react-zen';
import { PageHeader } from '@/components/common/PageHeader';
import { useBoard, useMessages, useNavigation } from '@/components/hooks';
export function BoardEditHeader() {
export function BoardDesignHeader() {
const { board, saveBoard, isPending } = useBoard();
const { t, labels } = useMessages();
const { router, renderUrl } = useNavigation();
const defaultName = t(labels.untitled);
const title = board?.id ? board?.name || defaultName : t(labels.addBoard);
const title = board?.name || t(labels.untitled);
const handleSave = async () => {
await saveBoard();
@@ -4,16 +4,14 @@ import { BoardProvider } from '@/app/(main)/boards/BoardProvider';
import { PageBody } from '@/components/common/PageBody';
import { BoardControls } from './BoardControls';
import { BoardEditBody } from './BoardEditBody';
import { BoardEditForm } from './BoardEditForm';
import { BoardEditHeader } from './BoardEditHeader';
import { BoardDesignHeader } from './BoardEditHeader';
export function BoardEditPage({ boardId }: { boardId?: string }) {
export function BoardDesignPage({ boardId }: { boardId: string }) {
return (
<BoardProvider boardId={boardId} editing>
<PageBody>
<Column>
<BoardEditHeader />
<BoardEditForm />
<BoardDesignHeader />
<BoardControls />
<BoardEditBody />
</Column>
@@ -4,10 +4,9 @@ import { LinkButton } from '@/components/common/LinkButton';
import { PageHeader } from '@/components/common/PageHeader';
import { useBoard, useMessages, useNavigation } from '@/components/hooks';
import { getBoardEntity } from '@/lib/boards';
import { Edit } from '@/components/icons';
import { Edit, LayoutDashboard } from '@/components/icons';
import { BoardEntityBadge } from '../BoardEntityBadge';
import { useBoardEntityBadgeProps } from '../useBoardEntityBadgeProps';
import { BoardShareButton } from './BoardShareButton';
export function BoardViewHeader({
showActions = true,
@@ -28,10 +27,12 @@ export function BoardViewHeader({
{showEntityBadge && entityBadge && <BoardEntityBadge {...entityBadge} />}
{showActions && board?.id && (
<>
<BoardShareButton boardId={board.id} />
<LinkButton href={renderUrl(`/boards/${board.id}/edit`, false)}>
<IconLabel icon={<Edit />}>{t(labels.edit)}</IconLabel>
</LinkButton>
<LinkButton href={renderUrl(`/boards/${board.id}/design`, false)}>
<IconLabel icon={<LayoutDashboard />}>Design</IconLabel>
</LinkButton>
</>
)}
</Row>
@@ -0,0 +1,12 @@
import type { Metadata } from 'next';
import { BoardDesignPage } from '../BoardEditPage';
export default async function ({ params }: { params: Promise<{ boardId: string }> }) {
const { boardId } = await params;
return <BoardDesignPage boardId={boardId} />;
}
export const metadata: Metadata = {
title: 'Design Board',
};
@@ -0,0 +1,48 @@
'use client';
import { Column } from '@umami/react-zen';
import Link from 'next/link';
import { BoardEditForm } from '@/app/(main)/boards/BoardEditForm';
import { BoardShareDialog } from '@/app/(main)/boards/[boardId]/BoardShareDialog';
import { IconLabel } from '@/components/common/IconLabel';
import { Panel } from '@/components/common/Panel';
import { PageBody } from '@/components/common/PageBody';
import { PageHeader } from '@/components/common/PageHeader';
import { useBoardQuery, useMessages, useNavigation } from '@/components/hooks';
import { ArrowLeft, LayoutDashboard } from '@/components/icons';
export function BoardEditPage({ boardId }: { boardId: string }) {
const { data: board } = useBoardQuery(boardId);
const { t, labels } = useMessages();
const { renderUrl } = useNavigation();
return (
<PageBody>
<Column
margin="2"
width="100%"
style={{ minWidth: 'min(760px, 100%)', marginInline: 'auto' }}
>
<>
<Column marginTop="6">
<Link href={renderUrl(`/boards/${boardId}`)}>
<IconLabel icon={<ArrowLeft />} label={t(labels.boards)} />
</Link>
</Column>
<PageHeader
title={board?.name || t(labels.untitled)}
description={board?.description}
icon={<LayoutDashboard />}
/>
</>
<Column gap="6">
<Panel>
<BoardEditForm boardId={boardId} />
</Panel>
<Panel>
<BoardShareDialog boardId={boardId} />
</Panel>
</Column>
</Column>
</PageBody>
);
}
@@ -1,5 +1,5 @@
import type { Metadata } from 'next';
import { BoardEditPage } from '../BoardEditPage';
import { BoardEditPage } from './BoardEditPage';
export default async function ({ params }: { params: Promise<{ boardId: string }> }) {
const { boardId } = await params;
+3 -4
View File
@@ -1,13 +1,12 @@
import type { Metadata } from 'next';
import { BoardEditPage } from './BoardEditPage';
import { redirect } from 'next/navigation';
import { BoardViewPage } from './BoardViewPage';
export default async function ({ params }: { params: Promise<{ boardId: string }> }) {
const { boardId } = await params;
const isCreate = boardId === 'create';
if (isCreate) {
return <BoardEditPage />;
if (boardId === 'create') {
redirect('/boards');
}
return <BoardViewPage boardId={boardId} />;
+13
View File
@@ -0,0 +1,13 @@
'use client';
import { useEffect } from 'react';
import { useNavigation } from '@/components/hooks';
export default function CreateBoardPage() {
const { router, renderUrl } = useNavigation();
useEffect(() => {
router.replace(renderUrl('/boards', false));
}, [router, renderUrl]);
return null;
}