Add board view/edit mode separation with cancel button.

/boards/[id] is now view mode (read-only) with an edit button.
/boards/[id]/edit is edit mode with save and cancel buttons.
Save navigates back to view, cancel discards changes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Mike Cao 2026-02-05 19:42:50 -08:00
parent 3b4776b0e0
commit a8534a9d4d
7 changed files with 153 additions and 74 deletions

View file

@ -10,6 +10,7 @@ export type LayoutGetter = () => Partial<BoardParameters> | null;
export interface BoardContextValue { export interface BoardContextValue {
board: Partial<Board>; board: Partial<Board>;
editing: boolean;
updateBoard: (data: Partial<Board>) => void; updateBoard: (data: Partial<Board>) => void;
saveBoard: () => Promise<Board>; saveBoard: () => Promise<Board>;
isPending: boolean; isPending: boolean;
@ -26,7 +27,15 @@ const createDefaultBoard = (): Partial<Board> => ({
}, },
}); });
export function BoardProvider({ boardId, children }: { boardId?: string; children: ReactNode }) { export function BoardProvider({
boardId,
editing = false,
children,
}: {
boardId?: string;
editing?: boolean;
children: ReactNode;
}) {
const { data, isFetching, isLoading } = useBoardQuery(boardId); const { data, isFetching, isLoading } = useBoardQuery(boardId);
const { post, useMutation } = useApi(); const { post, useMutation } = useApi();
const { touch } = useModified(); const { touch } = useModified();
@ -103,7 +112,7 @@ export function BoardProvider({ boardId, children }: { boardId?: string; childre
return ( return (
<BoardContext.Provider <BoardContext.Provider
value={{ board, updateBoard, saveBoard, isPending, registerLayoutGetter }} value={{ board, editing, updateBoard, saveBoard, isPending, registerLayoutGetter }}
> >
{children} {children}
</BoardContext.Provider> </BoardContext.Provider>

View file

@ -21,7 +21,7 @@ const BUTTON_ROW_HEIGHT = 60;
const MAX_COLUMNS = 4; const MAX_COLUMNS = 4;
export function BoardBody() { export function BoardBody() {
const { board, updateBoard, saveBoard, isPending, registerLayoutGetter } = useBoard(); const { board, editing, updateBoard, saveBoard, isPending, registerLayoutGetter } = useBoard();
const rowGroupRef = useRef<GroupImperativeHandle>(null); const rowGroupRef = useRef<GroupImperativeHandle>(null);
const columnGroupRefs = useRef<Map<string, GroupImperativeHandle>>(new Map()); const columnGroupRefs = useRef<Map<string, GroupImperativeHandle>>(new Map());
@ -141,6 +141,7 @@ export function BoardBody() {
rowId={row.id} rowId={row.id}
rowIndex={index} rowIndex={index}
rowCount={rows?.length} rowCount={rows?.length}
editing={editing}
onRemove={handleRemoveRow} onRemove={handleRemoveRow}
onMoveUp={handleMoveRowUp} onMoveUp={handleMoveRowUp}
onMoveDown={handleMoveRowDown} onMoveDown={handleMoveRowDown}
@ -150,18 +151,20 @@ export function BoardBody() {
{index < rows?.length - 1 && <Separator />} {index < rows?.length - 1 && <Separator />}
</Fragment> </Fragment>
))} ))}
<Panel minSize={BUTTON_ROW_HEIGHT}> {editing && (
<Row padding="3"> <Panel minSize={BUTTON_ROW_HEIGHT}>
<TooltipTrigger delay={0}> <Row padding="3">
<Button variant="outline" onPress={handleAddRow}> <TooltipTrigger delay={0}>
<Icon> <Button variant="outline" onPress={handleAddRow}>
<Plus /> <Icon>
</Icon> <Plus />
</Button> </Icon>
<Tooltip placement="bottom">Add row</Tooltip> </Button>
</TooltipTrigger> <Tooltip placement="bottom">Add row</Tooltip>
</Row> </TooltipTrigger>
</Panel> </Row>
</Panel>
)}
</Group> </Group>
); );
} }
@ -171,6 +174,7 @@ function BoardRow({
rowIndex, rowIndex,
rowCount, rowCount,
columns, columns,
editing = false,
onRemove, onRemove,
onMoveUp, onMoveUp,
onMoveDown, onMoveDown,
@ -180,6 +184,7 @@ function BoardRow({
rowIndex: number; rowIndex: number;
rowCount: number; rowCount: number;
columns: BoardColumnType[]; columns: BoardColumnType[];
editing?: boolean;
onRemove?: (id: string) => void; onRemove?: (id: string) => void;
onMoveUp?: (id: string) => void; onMoveUp?: (id: string) => void;
onMoveDown?: (id: string) => void; onMoveDown?: (id: string) => void;
@ -223,6 +228,7 @@ function BoardRow({
<Panel id={column.id} minSize={MIN_COLUMN_WIDTH} defaultSize={column.size}> <Panel id={column.id} minSize={MIN_COLUMN_WIDTH} defaultSize={column.size}>
<BoardColumn <BoardColumn
{...column} {...column}
editing={editing}
onRemove={handleRemoveColumn} onRemove={handleRemoveColumn}
canRemove={columns?.length > 1} canRemove={columns?.length > 1}
/> />
@ -230,48 +236,50 @@ function BoardRow({
{index < columns?.length - 1 && <Separator />} {index < columns?.length - 1 && <Separator />}
</Fragment> </Fragment>
))} ))}
<Column alignSelf="center" padding="3" gap="1"> {editing && (
<TooltipTrigger delay={0}> <Column alignSelf="center" padding="3" gap="1">
<Button variant="outline" onPress={() => onMoveUp?.(rowId)} isDisabled={rowIndex === 0}> <TooltipTrigger delay={0}>
<Icon rotate={180}> <Button variant="outline" onPress={() => onMoveUp?.(rowId)} isDisabled={rowIndex === 0}>
<ChevronDown /> <Icon rotate={180}>
</Icon> <ChevronDown />
</Button> </Icon>
<Tooltip placement="top">Move row up</Tooltip> </Button>
</TooltipTrigger> <Tooltip placement="top">Move row up</Tooltip>
<TooltipTrigger delay={0}> </TooltipTrigger>
<Button <TooltipTrigger delay={0}>
variant="outline" <Button
onPress={handleAddColumn} variant="outline"
isDisabled={columns?.length >= MAX_COLUMNS} onPress={handleAddColumn}
> isDisabled={columns?.length >= MAX_COLUMNS}
<Icon> >
<Plus /> <Icon>
</Icon> <Plus />
</Button> </Icon>
<Tooltip placement="left">Add column</Tooltip> </Button>
</TooltipTrigger> <Tooltip placement="left">Add column</Tooltip>
<TooltipTrigger delay={0}> </TooltipTrigger>
<Button variant="outline" onPress={() => onRemove?.(rowId)}> <TooltipTrigger delay={0}>
<Icon> <Button variant="outline" onPress={() => onRemove?.(rowId)}>
<Minus /> <Icon>
</Icon> <Minus />
</Button> </Icon>
<Tooltip placement="left">Remove row</Tooltip> </Button>
</TooltipTrigger> <Tooltip placement="left">Remove row</Tooltip>
<TooltipTrigger delay={0}> </TooltipTrigger>
<Button <TooltipTrigger delay={0}>
variant="outline" <Button
onPress={() => onMoveDown?.(rowId)} variant="outline"
isDisabled={rowIndex === rowCount - 1} onPress={() => onMoveDown?.(rowId)}
> isDisabled={rowIndex === rowCount - 1}
<Icon> >
<ChevronDown /> <Icon>
</Icon> <ChevronDown />
</Button> </Icon>
<Tooltip placement="bottom">Move row down</Tooltip> </Button>
</TooltipTrigger> <Tooltip placement="bottom">Move row down</Tooltip>
</Column> </TooltipTrigger>
</Column>
)}
</Group> </Group>
); );
} }
@ -279,11 +287,13 @@ function BoardRow({
function BoardColumn({ function BoardColumn({
id, id,
component, component,
editing = false,
onRemove, onRemove,
canRemove = true, canRemove = true,
}: { }: {
id: string; id: string;
component?: ReactElement; component?: ReactElement;
editing?: boolean;
onRemove?: (id: string) => void; onRemove?: (id: string) => void;
canRemove?: boolean; canRemove?: boolean;
}) { }) {
@ -297,10 +307,10 @@ function BoardColumn({
height="100%" height="100%"
alignItems="center" alignItems="center"
justifyContent="center" justifyContent="center"
backgroundColor="3" backgroundColor="surface-sunken"
position="relative" position="relative"
> >
{canRemove && ( {editing && canRemove && (
<Box position="absolute" top="10px" right="20px" zIndex={100}> <Box position="absolute" top="10px" right="20px" zIndex={100}>
<TooltipTrigger delay={0}> <TooltipTrigger delay={0}>
<Button variant="quiet" onPress={() => onRemove?.(id)}> <Button variant="quiet" onPress={() => onRemove?.(id)}>
@ -312,11 +322,13 @@ function BoardColumn({
</TooltipTrigger> </TooltipTrigger>
</Box> </Box>
)} )}
<Button variant="outline" onPress={handleAddComponent}> {editing && (
<Icon> <Button variant="outline" onPress={handleAddComponent}>
<Plus /> <Icon>
</Icon> <Plus />
</Button> </Icon>
</Button>
)}
</Column> </Column>
); );
} }

View file

@ -1,9 +1,38 @@
import { Column, Grid, Heading, LoadingButton, Row, TextField } from '@umami/react-zen'; import { Button, Column, Grid, Heading, LoadingButton, Row, TextField } from '@umami/react-zen';
import { useBoard, useMessages } from '@/components/hooks'; import { IconLabel } from '@/components/common/IconLabel';
import { LinkButton } from '@/components/common/LinkButton';
import { PageHeader } from '@/components/common/PageHeader';
import { useBoard, useMessages, useNavigation } from '@/components/hooks';
import { Edit } from '@/components/icons';
export function BoardHeader() { export function BoardHeader() {
const { board, editing } = useBoard();
if (editing) {
return <BoardEditHeader />;
}
return <BoardViewHeader />;
}
function BoardViewHeader() {
const { board } = useBoard();
const { renderUrl } = useNavigation();
const { formatMessage, labels } = useMessages();
return (
<PageHeader title={board?.name} description={board?.description}>
<LinkButton href={renderUrl(`/boards/${board?.id}/edit`, false)}>
<IconLabel icon={<Edit />}>{formatMessage(labels.edit)}</IconLabel>
</LinkButton>
</PageHeader>
);
}
function BoardEditHeader() {
const { board, updateBoard, saveBoard, isPending } = useBoard(); const { board, updateBoard, saveBoard, isPending } = useBoard();
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const { router, renderUrl } = useNavigation();
const defaultName = formatMessage(labels.untitled); const defaultName = formatMessage(labels.untitled);
const handleNameChange = (value: string) => { const handleNameChange = (value: string) => {
@ -14,8 +43,19 @@ export function BoardHeader() {
updateBoard({ description: value }); updateBoard({ description: value });
}; };
const handleSave = () => { const handleSave = async () => {
saveBoard(); await saveBoard();
if (board.id) {
router.push(renderUrl(`/boards/${board.id}`));
}
};
const handleCancel = () => {
if (board.id) {
router.push(renderUrl(`/boards/${board.id}`));
} else {
router.push(renderUrl('/boards'));
}
}; };
return ( return (
@ -55,9 +95,14 @@ export function BoardHeader() {
</Row> </Row>
</Column> </Column>
<Column justifyContent="center" alignItems="flex-end"> <Column justifyContent="center" alignItems="flex-end">
<LoadingButton variant="primary" onPress={handleSave} isLoading={isPending}> <Row gap="3">
{formatMessage(labels.save)} <Button variant="quiet" onPress={handleCancel}>
</LoadingButton> {formatMessage(labels.cancel)}
</Button>
<LoadingButton variant="primary" onPress={handleSave} isLoading={isPending}>
{formatMessage(labels.save)}
</LoadingButton>
</Row>
</Column> </Column>
</Grid> </Grid>
); );

View file

@ -5,9 +5,9 @@ import { BoardHeader } from '@/app/(main)/boards/[boardId]/BoardHeader';
import { BoardProvider } from '@/app/(main)/boards/BoardProvider'; import { BoardProvider } from '@/app/(main)/boards/BoardProvider';
import { PageBody } from '@/components/common/PageBody'; import { PageBody } from '@/components/common/PageBody';
export function BoardPage({ boardId }: { boardId: string }) { export function BoardPage({ boardId, editing = false }: { boardId?: string; editing?: boolean }) {
return ( return (
<BoardProvider boardId={boardId}> <BoardProvider boardId={boardId} editing={editing}>
<PageBody> <PageBody>
<Column> <Column>
<BoardHeader /> <BoardHeader />

View file

@ -0,0 +1,12 @@
import type { Metadata } from 'next';
import { BoardPage } from '../BoardPage';
export default async function ({ params }: { params: Promise<{ boardId: string }> }) {
const { boardId } = await params;
return <BoardPage boardId={boardId} editing />;
}
export const metadata: Metadata = {
title: 'Edit Board',
};

View file

@ -3,8 +3,9 @@ import { BoardPage } from './BoardPage';
export default async function ({ params }: { params: Promise<{ boardId: string }> }) { export default async function ({ params }: { params: Promise<{ boardId: string }> }) {
const { boardId } = await params; const { boardId } = await params;
const isCreate = boardId === 'create';
return <BoardPage boardId={boardId !== 'create' ? boardId : undefined} />; return <BoardPage boardId={isCreate ? undefined : boardId} editing={isCreate} />;
} }
export const metadata: Metadata = { export const metadata: Metadata = {

View file