mirror of
https://github.com/umami-software/umami.git
synced 2026-02-12 08:37:13 +01:00
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:
parent
3b4776b0e0
commit
a8534a9d4d
7 changed files with 153 additions and 74 deletions
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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 />
|
||||||
|
|
|
||||||
12
src/app/(main)/boards/[boardId]/edit/page.tsx
Normal file
12
src/app/(main)/boards/[boardId]/edit/page.tsx
Normal 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',
|
||||||
|
};
|
||||||
|
|
@ -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 = {
|
||||||
|
|
|
||||||
0
src/components/common/IconLabel.tsx
Normal file
0
src/components/common/IconLabel.tsx
Normal file
Loading…
Add table
Add a link
Reference in a new issue