Add board state management with updateBoard and saveBoard methods.

BoardProvider now manages local board state and exposes updateBoard for
editing and saveBoard for persisting to the database. Supports both
create and edit modes with proper redirect after creation.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Mike Cao 2026-01-16 21:05:43 -08:00
parent e08907d998
commit 68c56060b3
7 changed files with 176 additions and 48 deletions

View file

@ -113,6 +113,7 @@
"react-dom": "^19.2.3", "react-dom": "^19.2.3",
"react-error-boundary": "^4.0.4", "react-error-boundary": "^4.0.4",
"react-intl": "^7.1.14", "react-intl": "^7.1.14",
"react-resizable-panels": "^4.4.1",
"react-simple-maps": "^2.3.0", "react-simple-maps": "^2.3.0",
"react-use-measure": "^2.0.4", "react-use-measure": "^2.0.4",
"react-window": "^1.8.6", "react-window": "^1.8.6",

14
pnpm-lock.yaml generated
View file

@ -164,6 +164,9 @@ importers:
react-intl: react-intl:
specifier: ^7.1.14 specifier: ^7.1.14
version: 7.1.14(react@19.2.3)(typescript@5.9.3) version: 7.1.14(react@19.2.3)(typescript@5.9.3)
react-resizable-panels:
specifier: ^4.4.1
version: 4.4.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
react-simple-maps: react-simple-maps:
specifier: ^2.3.0 specifier: ^2.3.0
version: 2.3.0(prop-types@15.8.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) version: 2.3.0(prop-types@15.8.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
@ -6181,6 +6184,12 @@ packages:
redux: redux:
optional: true optional: true
react-resizable-panels@4.4.1:
resolution: {integrity: sha512-dpM9oI6rGlAq7VYDeafSRA1JmkJv8aNuKySR+tZLQQLfaeqTnQLSM52EcoI/QdowzsjVUCk6jViKS0xHWITVRQ==}
peerDependencies:
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
react-simple-maps@2.3.0: react-simple-maps@2.3.0:
resolution: {integrity: sha512-IZVeiPSRZKwD6I/2NvXpQ2uENYGDGZp8DvZjkapcxuJ/LQHTfl+Byb+KNgY7s+iatRA2ad8LnZ3AgqcjziCCsw==} resolution: {integrity: sha512-IZVeiPSRZKwD6I/2NvXpQ2uENYGDGZp8DvZjkapcxuJ/LQHTfl+Byb+KNgY7s+iatRA2ad8LnZ3AgqcjziCCsw==}
peerDependencies: peerDependencies:
@ -13909,6 +13918,11 @@ snapshots:
'@types/react': 19.2.8 '@types/react': 19.2.8
redux: 5.0.1 redux: 5.0.1
react-resizable-panels@4.4.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
dependencies:
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
react-simple-maps@2.3.0(prop-types@15.8.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): react-simple-maps@2.3.0(prop-types@15.8.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
dependencies: dependencies:
d3-geo: 2.0.2 d3-geo: 2.0.2

View file

@ -1,17 +1,86 @@
'use client'; 'use client';
import { Loading } from '@umami/react-zen'; import { Loading, useToast } from '@umami/react-zen';
import { createContext, type ReactNode } from 'react'; import { createContext, type ReactNode, useCallback, useEffect, useState } from 'react';
import { useApi, useMessages, useModified, useNavigation } from '@/components/hooks';
import { useBoardQuery } from '@/components/hooks/queries/useBoardQuery'; import { useBoardQuery } from '@/components/hooks/queries/useBoardQuery';
import type { Board } from '@/generated/prisma/client'; import type { Board } from '@/generated/prisma/client';
export const BoardContext = createContext<Board>(null); export interface BoardContextValue {
board: Partial<Board>;
updateBoard: (data: Partial<Board>) => void;
saveBoard: () => Promise<Board>;
isPending: boolean;
}
export function BoardProvider({ boardId, children }: { boardId: string; children: ReactNode }) { export const BoardContext = createContext<BoardContextValue>(null);
const { data: board, isFetching, isLoading } = useBoardQuery(boardId);
if (isFetching && isLoading) { const defaultBoard: Partial<Board> = {
name: '',
description: '',
};
export function BoardProvider({ boardId, children }: { boardId?: string; children: ReactNode }) {
const { data, isFetching, isLoading } = useBoardQuery(boardId);
const { post, useMutation } = useApi();
const { touch } = useModified();
const { toast } = useToast();
const { formatMessage, labels, messages } = useMessages();
const { router, renderUrl } = useNavigation();
const [board, setBoard] = useState<Partial<Board>>(data ?? defaultBoard);
useEffect(() => {
if (data) {
setBoard(data);
}
}, [data]);
const { mutateAsync, isPending } = useMutation({
mutationFn: (boardData: Partial<Board>) => {
if (boardData.id) {
return post(`/boards/${boardData.id}`, boardData);
}
return post('/boards', { ...boardData, type: 'dashboard', slug: '' });
},
});
const updateBoard = useCallback((data: Partial<Board>) => {
setBoard(current => ({ ...current, ...data }));
}, []);
const saveBoard = useCallback(async () => {
const defaultName = formatMessage(labels.untitled);
const result = await mutateAsync({ ...board, name: board.name || defaultName });
toast(formatMessage(messages.saved));
touch('boards');
if (board.id) {
touch(`board:${board.id}`);
} else if (result?.id) {
router.push(renderUrl(`/boards/${result.id}`));
}
return result;
}, [
board,
mutateAsync,
toast,
formatMessage,
labels.untitled,
messages.saved,
touch,
router,
renderUrl,
]);
if (boardId && isFetching && isLoading) {
return <Loading placement="absolute" />; return <Loading placement="absolute" />;
} }
return <BoardContext.Provider value={board}>{children}</BoardContext.Provider>; return (
<BoardContext.Provider value={{ board, updateBoard, saveBoard, isPending }}>
{children}
</BoardContext.Provider>
);
} }

View file

@ -1,3 +1,69 @@
import { Button, Column, Icon, Row } from '@umami/react-zen';
import { produce } from 'immer';
import { v4 as uuid } from 'uuid';
import { useBoard } from '@/components/hooks';
import { Plus } from '@/components/icons';
export function BoardBody() { export function BoardBody() {
return <h1>i am bored.</h1>; const { board, updateBoard, saveBoard, isPending } = useBoard();
console.log({ board });
const handleAddRow = () => {
updateBoard({
parameters: produce(board.parameters, draft => {
if (!draft.rows) {
draft.rows = [];
}
draft.rows.push({ id: uuid(), components: [] });
}),
});
};
return (
<Column>
{board?.parameters?.rows?.map((row, rowIndex) => {
return <BoardRow key={row.id} rowIndex={rowIndex} components={row.components} />;
})}
<Row>
<Button variant="outline" onPress={handleAddRow}>
<Icon>
<Plus />
</Icon>
</Button>
</Row>
</Column>
);
}
function BoardComponent() {
return <Column>hi</Column>;
}
function BoardRow({ rowIndex, components }: { rowIndex: number; components: any[] }) {
const { board, updateBoard } = useBoard();
const handleAddComponent = () => {
updateBoard({
parameters: produce(board.parameters, draft => {
if (!draft.rows[rowIndex]) {
draft.rows[rowIndex] = { id: uuid(), components: [] };
}
draft.rows[rowIndex].components.push({ id: uuid(), type: 'text', value: '' });
}),
});
};
return (
<Row>
{components?.map(component => {
return <BoardComponent key={component.id} />;
})}
<Button variant="outline" onPress={handleAddComponent}>
<Icon>
<Plus />
</Icon>
</Button>
</Row>
);
} }

View file

@ -1,47 +1,21 @@
import { Column, Grid, Heading, LoadingButton, Row, TextField, useToast } from '@umami/react-zen'; import { Column, Grid, Heading, LoadingButton, Row, TextField } from '@umami/react-zen';
import { useState } from 'react'; import { useBoard, useMessages } from '@/components/hooks';
import { useApi, useBoard, useMessages, useModified, useNavigation } from '@/components/hooks';
export function BoardHeader() { export function BoardHeader() {
const board = useBoard(); const { board, updateBoard, saveBoard, isPending } = useBoard();
const { formatMessage, labels, messages } = useMessages(); const { formatMessage, labels } = useMessages();
const { post, useMutation } = useApi();
const { touch } = useModified();
const { router, renderUrl } = useNavigation();
const { toast } = useToast();
const defaultName = formatMessage(labels.untitled); const defaultName = formatMessage(labels.untitled);
const [name, setName] = useState(board?.name ?? '');
const [description, setDescription] = useState(board?.description ?? '');
const { mutateAsync, isPending } = useMutation({
mutationFn: (data: { name: string; description: string }) => {
if (board) {
return post(`/boards/${board.id}`, data);
}
return post('/boards', { ...data, type: 'dashboard', slug: '' });
},
});
const handleNameChange = (value: string) => { const handleNameChange = (value: string) => {
setName(value); updateBoard({ name: value });
}; };
const handleDescriptionChange = (value: string) => { const handleDescriptionChange = (value: string) => {
setDescription(value); updateBoard({ description: value });
}; };
const handleSave = async () => { const handleSave = () => {
const result = await mutateAsync({ name: name || defaultName, description }); saveBoard();
toast(formatMessage(messages.saved));
touch('boards');
if (board) {
touch(`board:${board.id}`);
} else if (result?.id) {
router.push(renderUrl(`/boards/${result.id}`));
}
}; };
return ( return (
@ -57,26 +31,26 @@ export function BoardHeader() {
<TextField <TextField
variant="quiet" variant="quiet"
name="name" name="name"
value={name} value={board?.name ?? ''}
placeholder={defaultName} placeholder={defaultName}
onChange={handleNameChange} onChange={handleNameChange}
autoComplete="off" autoComplete="off"
style={{ fontSize: '2rem', fontWeight: 700, width: '100%' }} style={{ fontSize: '2rem', fontWeight: 700, width: '100%' }}
> >
<Heading size="4">{name}</Heading> <Heading size="4">{board?.name}</Heading>
</TextField> </TextField>
</Row> </Row>
<Row> <Row>
<TextField <TextField
variant="quiet" variant="quiet"
name="description" name="description"
value={description} value={board?.description ?? ''}
placeholder={`+ ${formatMessage(labels.addDescription)}`} placeholder={`+ ${formatMessage(labels.addDescription)}`}
autoComplete="off" autoComplete="off"
onChange={handleDescriptionChange} onChange={handleDescriptionChange}
style={{ width: '100%' }} style={{ width: '100%' }}
> >
{description} {board?.description}
</TextField> </TextField>
</Row> </Row>
</Column> </Column>

View file

@ -1,6 +1,6 @@
import { useContext } from 'react'; import { useContext } from 'react';
import { BoardContext } from '@/app/(main)/boards/BoardProvider'; import { BoardContext, type BoardContextValue } from '@/app/(main)/boards/BoardProvider';
export function useBoard() { export function useBoard(): BoardContextValue {
return useContext(BoardContext); return useContext(BoardContext);
} }

View file

@ -141,3 +141,7 @@ export interface ApiError extends Error {
code?: string; code?: string;
message: string; message: string;
} }
export interface BoardData {
rows: { id: string; name: string; value: number }[];
}