Add panel size persistence on board save.

- Add registerLayoutGetter to BoardContext for collecting sizes on save
- Use GroupImperativeHandle and groupRef prop for react-resizable-panels
- Add id props to Panels for layout mapping by panel id
- Collect row and column sizes via getLayout() only when saving
- Restore saved sizes via defaultSize prop on Panels

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Mike Cao 2026-01-19 01:25:31 -08:00
parent 30e48e3aaa
commit 385bdd6734
2 changed files with 89 additions and 11 deletions

View file

@ -1,15 +1,18 @@
'use client'; 'use client';
import { Loading, useToast } from '@umami/react-zen'; import { Loading, useToast } from '@umami/react-zen';
import { createContext, type ReactNode, useCallback, useEffect, useState } from 'react'; import { createContext, type ReactNode, useCallback, useEffect, useRef, useState } from 'react';
import { useApi, useMessages, useModified, useNavigation } from '@/components/hooks'; 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 '@/lib/types'; import type { Board, BoardParameters } from '@/lib/types';
export type LayoutGetter = () => Partial<BoardParameters> | null;
export interface BoardContextValue { export interface BoardContextValue {
board: Partial<Board>; board: Partial<Board>;
updateBoard: (data: Partial<Board>) => void; updateBoard: (data: Partial<Board>) => void;
saveBoard: () => Promise<Board>; saveBoard: () => Promise<Board>;
isPending: boolean; isPending: boolean;
registerLayoutGetter: (getter: LayoutGetter) => void;
} }
export const BoardContext = createContext<BoardContextValue>(null); export const BoardContext = createContext<BoardContextValue>(null);
@ -29,6 +32,11 @@ export function BoardProvider({ boardId, children }: { boardId?: string; childre
const { router, renderUrl } = useNavigation(); const { router, renderUrl } = useNavigation();
const [board, setBoard] = useState<Partial<Board>>(data ?? defaultBoard); const [board, setBoard] = useState<Partial<Board>>(data ?? defaultBoard);
const layoutGetterRef = useRef<LayoutGetter | null>(null);
const registerLayoutGetter = useCallback((getter: LayoutGetter) => {
layoutGetterRef.current = getter;
}, []);
useEffect(() => { useEffect(() => {
if (data) { if (data) {
@ -51,7 +59,18 @@ export function BoardProvider({ boardId, children }: { boardId?: string; childre
const saveBoard = useCallback(async () => { const saveBoard = useCallback(async () => {
const defaultName = formatMessage(labels.untitled); const defaultName = formatMessage(labels.untitled);
const result = await mutateAsync({ ...board, name: board.name || defaultName });
// Get current layout sizes from BoardBody if registered
const layoutData = layoutGetterRef.current?.();
console.log('layoutData from getter:', layoutData);
const parameters = layoutData ? { ...board.parameters, ...layoutData } : board.parameters;
console.log('parameters to save:', parameters);
const result = await mutateAsync({
...board,
name: board.name || defaultName,
parameters,
});
toast(formatMessage(messages.saved)); toast(formatMessage(messages.saved));
touch('boards'); touch('boards');
@ -80,7 +99,9 @@ export function BoardProvider({ boardId, children }: { boardId?: string; childre
} }
return ( return (
<BoardContext.Provider value={{ board, updateBoard, saveBoard, isPending }}> <BoardContext.Provider
value={{ board, updateBoard, saveBoard, isPending, registerLayoutGetter }}
>
{children} {children}
</BoardContext.Provider> </BoardContext.Provider>
); );

View file

@ -1,7 +1,7 @@
import { Box, Button, Column, Icon, Row, Tooltip, TooltipTrigger } from '@umami/react-zen'; import { Box, Button, Column, Icon, Row, Tooltip, TooltipTrigger } from '@umami/react-zen';
import { produce } from 'immer'; import { produce } from 'immer';
import { Fragment, type ReactElement } from 'react'; import { Fragment, type ReactElement, useEffect, useRef } from 'react';
import { Group, Panel, Separator } from 'react-resizable-panels'; import { Group, type GroupImperativeHandle, Panel, Separator } from 'react-resizable-panels';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import { useBoard } from '@/components/hooks'; import { useBoard } from '@/components/hooks';
import { ChevronDown, Minus, Plus, X } from '@/components/icons'; import { ChevronDown, Minus, Plus, X } from '@/components/icons';
@ -21,7 +21,52 @@ const BUTTON_ROW_HEIGHT = 60;
const MAX_COLUMNS = 4; const MAX_COLUMNS = 4;
export function BoardBody() { export function BoardBody() {
const { board, updateBoard, saveBoard, isPending } = useBoard(); const { board, updateBoard, saveBoard, isPending, registerLayoutGetter } = useBoard();
const rowGroupRef = useRef<GroupImperativeHandle>(null);
const columnGroupRefs = useRef<Map<string, GroupImperativeHandle>>(new Map());
// Register a function to get current layout sizes on save
useEffect(() => {
registerLayoutGetter(() => {
const rows = board?.parameters?.rows;
console.log('Layout getter called, rows:', rows);
console.log('rowGroupRef.current:', rowGroupRef.current);
console.log('columnGroupRefs.current:', columnGroupRefs.current);
if (!rows?.length) return null;
const rowLayout = rowGroupRef.current?.getLayout();
console.log('rowLayout:', rowLayout);
const updatedRows = rows.map(row => {
const columnGroupRef = columnGroupRefs.current.get(row.id);
const columnLayout = columnGroupRef?.getLayout();
console.log(`Row ${row.id} columnLayout:`, columnLayout);
const updatedColumns = row.columns.map(col => ({
...col,
size: columnLayout?.[col.id],
}));
return {
...row,
size: rowLayout?.[row.id],
columns: updatedColumns,
};
});
console.log('updatedRows:', updatedRows);
return { rows: updatedRows };
});
}, [registerLayoutGetter, board?.parameters?.rows]);
const registerColumnGroupRef = (rowId: string, ref: GroupImperativeHandle | null) => {
if (ref) {
columnGroupRefs.current.set(rowId, ref);
} else {
columnGroupRefs.current.delete(rowId);
}
};
console.log({ board }); console.log({ board });
@ -82,10 +127,15 @@ export function BoardBody() {
const minHeight = (rows?.length || 1) * MAX_ROW_HEIGHT + BUTTON_ROW_HEIGHT; const minHeight = (rows?.length || 1) * MAX_ROW_HEIGHT + BUTTON_ROW_HEIGHT;
return ( return (
<Group orientation="vertical" style={{ minHeight }}> <Group groupRef={rowGroupRef} orientation="vertical" style={{ minHeight }}>
{rows.map((row, index) => ( {rows.map((row, index) => (
<Fragment key={row.id}> <Fragment key={row.id}>
<Panel minSize={MIN_ROW_HEIGHT} maxSize={MAX_ROW_HEIGHT}> <Panel
id={row.id}
minSize={MIN_ROW_HEIGHT}
maxSize={MAX_ROW_HEIGHT}
defaultSize={row.size}
>
<BoardRow <BoardRow
{...row} {...row}
rowId={row.id} rowId={row.id}
@ -94,6 +144,7 @@ export function BoardBody() {
onRemove={handleRemoveRow} onRemove={handleRemoveRow}
onMoveUp={handleMoveRowUp} onMoveUp={handleMoveRowUp}
onMoveDown={handleMoveRowDown} onMoveDown={handleMoveRowDown}
onRegisterRef={registerColumnGroupRef}
/> />
</Panel> </Panel>
{index < rows?.length - 1 && <Separator />} {index < rows?.length - 1 && <Separator />}
@ -123,6 +174,7 @@ function BoardRow({
onRemove, onRemove,
onMoveUp, onMoveUp,
onMoveDown, onMoveDown,
onRegisterRef,
}: { }: {
rowId: string; rowId: string;
rowIndex: number; rowIndex: number;
@ -131,9 +183,14 @@ function BoardRow({
onRemove?: (id: string) => void; onRemove?: (id: string) => void;
onMoveUp?: (id: string) => void; onMoveUp?: (id: string) => void;
onMoveDown?: (id: string) => void; onMoveDown?: (id: string) => void;
onRegisterRef?: (rowId: string, ref: GroupImperativeHandle | null) => void;
}) { }) {
const { board, updateBoard } = useBoard(); const { board, updateBoard } = useBoard();
const handleGroupRef = (ref: GroupImperativeHandle | null) => {
onRegisterRef?.(rowId, ref);
};
const handleAddColumn = () => { const handleAddColumn = () => {
updateBoard({ updateBoard({
parameters: produce(board.parameters, draft => { parameters: produce(board.parameters, draft => {
@ -160,10 +217,10 @@ function BoardRow({
}; };
return ( return (
<Group style={{ height: '100%' }}> <Group groupRef={handleGroupRef} style={{ height: '100%' }}>
{columns?.map((column, index) => ( {columns?.map((column, index) => (
<Fragment key={column.id}> <Fragment key={column.id}>
<Panel minSize={MIN_COLUMN_WIDTH}> <Panel id={column.id} minSize={MIN_COLUMN_WIDTH} defaultSize={column.size}>
<BoardColumn {...column} onRemove={handleRemoveColumn} /> <BoardColumn {...column} onRemove={handleRemoveColumn} />
</Panel> </Panel>
{index < columns?.length - 1 && <Separator />} {index < columns?.length - 1 && <Separator />}