Move board component metadata editing into modal

This commit is contained in:
Mike Cao 2026-02-13 00:18:51 -08:00
parent 0123f7069b
commit db637864f6
5 changed files with 182 additions and 80 deletions

View file

@ -1,5 +1,14 @@
import { Button, Column, Focusable, ListItem, Row, Select, Text } from '@umami/react-zen'; import {
import { useState } from 'react'; Button,
Column,
Focusable,
ListItem,
Row,
Select,
Text,
TextField,
} from '@umami/react-zen';
import { useEffect, useMemo, useState } from 'react';
import { Panel } from '@/components/common/Panel'; import { Panel } from '@/components/common/Panel';
import { useMessages } from '@/components/hooks'; import { useMessages } from '@/components/hooks';
import type { BoardComponentConfig } from '@/lib/types'; import type { BoardComponentConfig } from '@/lib/types';
@ -13,29 +22,66 @@ import { BoardComponentRenderer } from './BoardComponentRenderer';
export function BoardComponentSelect({ export function BoardComponentSelect({
websiteId, websiteId,
initialConfig,
onSelect, onSelect,
onClose, onClose,
}: { }: {
websiteId: string; websiteId: string;
initialConfig?: BoardComponentConfig;
onSelect: (config: BoardComponentConfig) => void; onSelect: (config: BoardComponentConfig) => void;
onClose: () => void; onClose: () => void;
}) { }) {
const { t, labels, messages } = useMessages(); const { t, labels, messages } = useMessages();
const [selectedDef, setSelectedDef] = useState<ComponentDefinition | null>(null); const [selectedDef, setSelectedDef] = useState<ComponentDefinition | null>(null);
const [configValues, setConfigValues] = useState<Record<string, any>>({}); const [configValues, setConfigValues] = useState<Record<string, any>>({});
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const handleSelectComponent = (def: ComponentDefinition) => { const allDefinitions = useMemo(
setSelectedDef(def); () => CATEGORIES.flatMap(category => getComponentsByCategory(category.key)),
[],
);
const getDefaultConfigValues = (def: ComponentDefinition, config?: BoardComponentConfig) => {
const defaults: Record<string, any> = {}; const defaults: Record<string, any> = {};
if (def.configFields) {
for (const field of def.configFields) { for (const field of def.configFields ?? []) {
defaults[field.name] = field.defaultValue; defaults[field.name] = field.defaultValue;
}
} }
if (def.defaultProps) { if (def.defaultProps) {
Object.assign(defaults, def.defaultProps); Object.assign(defaults, def.defaultProps);
} }
setConfigValues(defaults);
if (config?.props) {
Object.assign(defaults, config.props);
}
return defaults;
};
useEffect(() => {
if (!initialConfig) {
return;
}
const definition = allDefinitions.find(def => def.type === initialConfig.type);
if (!definition) {
return;
}
setSelectedDef(definition);
setConfigValues(getDefaultConfigValues(definition, initialConfig));
setTitle(initialConfig.title || definition.name);
setDescription(initialConfig.description || '');
}, [initialConfig, allDefinitions]);
const handleSelectComponent = (def: ComponentDefinition) => {
setSelectedDef(def);
setConfigValues(getDefaultConfigValues(def));
setTitle(def.name);
setDescription('');
}; };
const handleConfigChange = (name: string, value: any) => { const handleConfigChange = (name: string, value: any) => {
@ -46,16 +92,25 @@ export function BoardComponentSelect({
if (!selectedDef) return; if (!selectedDef) return;
const props: Record<string, any> = {}; const props: Record<string, any> = {};
if (selectedDef.defaultProps) { if (selectedDef.defaultProps) {
Object.assign(props, selectedDef.defaultProps); Object.assign(props, selectedDef.defaultProps);
} }
Object.assign(props, configValues); Object.assign(props, configValues);
if (props.limit) { for (const field of selectedDef.configFields ?? []) {
props.limit = Number(props.limit); if (field.type === 'number' && props[field.name] != null && props[field.name] !== '') {
props[field.name] = Number(props[field.name]);
}
} }
const config: BoardComponentConfig = { type: selectedDef.type }; const config: BoardComponentConfig = {
type: selectedDef.type,
title: title || selectedDef.name,
description,
};
if (Object.keys(props).length > 0) { if (Object.keys(props).length > 0) {
config.props = props; config.props = props;
} }
@ -66,6 +121,8 @@ export function BoardComponentSelect({
const previewConfig: BoardComponentConfig | null = selectedDef const previewConfig: BoardComponentConfig | null = selectedDef
? { ? {
type: selectedDef.type, type: selectedDef.type,
title,
description,
props: { ...selectedDef.defaultProps, ...configValues }, props: { ...selectedDef.defaultProps, ...configValues },
} }
: null; : null;
@ -73,12 +130,13 @@ export function BoardComponentSelect({
return ( return (
<Column gap="4"> <Column gap="4">
<Row gap="4" style={{ height: 600 }}> <Row gap="4" style={{ height: 600 }}>
<Column gap="1" style={{ width: 200, flexShrink: 0, overflowY: 'auto' }}> <Column gap="1" style={{ width: 280, flexShrink: 0, overflowY: 'auto' }}>
{CATEGORIES.map(cat => { {CATEGORIES.map(category => {
const components = getComponentsByCategory(cat.key); const components = getComponentsByCategory(category.key);
return ( return (
<Column key={cat.key} gap="1" marginBottom="2"> <Column key={category.key} gap="1" marginBottom="2">
<Text weight="bold">{cat.name}</Text> <Text weight="bold">{category.name}</Text>
{components.map(def => ( {components.map(def => (
<Focusable key={def.type}> <Focusable key={def.type}>
<Row <Row
@ -111,30 +169,8 @@ export function BoardComponentSelect({
); );
})} })}
</Column> </Column>
<Column gap="3" flexGrow={1} style={{ minWidth: 0 }}> <Column gap="3" flexGrow={1} style={{ minWidth: 0 }}>
{selectedDef?.configFields && selectedDef.configFields.length > 0 && (
<Row gap="3" alignItems="center" wrap="wrap">
{selectedDef.configFields.map((field: ConfigField) => (
<Row key={field.name} gap="2" alignItems="center">
<Text size="sm" color="muted">
{field.label}
</Text>
{field.type === 'select' && (
<Select
value={String(configValues[field.name] ?? field.defaultValue ?? '')}
onChange={(value: string) => handleConfigChange(field.name, value)}
>
{field.options?.map(opt => (
<ListItem key={opt.value} id={opt.value}>
{opt.label}
</ListItem>
))}
</Select>
)}
</Row>
))}
</Row>
)}
<Panel maxHeight="100%"> <Panel maxHeight="100%">
{previewConfig && websiteId ? ( {previewConfig && websiteId ? (
<BoardComponentRenderer config={previewConfig} websiteId={websiteId} /> <BoardComponentRenderer config={previewConfig} websiteId={websiteId} />
@ -147,7 +183,66 @@ export function BoardComponentSelect({
)} )}
</Panel> </Panel>
</Column> </Column>
<Column gap="3" style={{ width: 320, flexShrink: 0, overflowY: 'auto' }}>
<Text weight="bold">{t(labels.properties)}</Text>
<Column gap="2">
<Text size="sm" color="muted">
{t(labels.title)}
</Text>
<TextField value={title} onChange={setTitle} autoComplete="off" />
</Column>
<Column gap="2">
<Text size="sm" color="muted">
{t(labels.description)}
</Text>
<TextField value={description} onChange={setDescription} autoComplete="off" />
</Column>
{selectedDef?.configFields && selectedDef.configFields.length > 0 && (
<Column gap="3">
{selectedDef.configFields.map((field: ConfigField) => (
<Column key={field.name} gap="2">
<Text size="sm" color="muted">
{field.label}
</Text>
{field.type === 'select' && (
<Select
value={String(configValues[field.name] ?? field.defaultValue ?? '')}
onChange={(value: string) => handleConfigChange(field.name, value)}
>
{field.options?.map(option => (
<ListItem key={option.value} id={option.value}>
{option.label}
</ListItem>
))}
</Select>
)}
{field.type === 'text' && (
<TextField
value={String(configValues[field.name] ?? field.defaultValue ?? '')}
onChange={(value: string) => handleConfigChange(field.name, value)}
/>
)}
{field.type === 'number' && (
<TextField
type="number"
value={String(configValues[field.name] ?? field.defaultValue ?? '')}
onChange={(value: string) => handleConfigChange(field.name, value)}
/>
)}
</Column>
))}
</Column>
)}
</Column>
</Row> </Row>
<Row justifyContent="flex-end" gap="2" paddingTop="4"> <Row justifyContent="flex-end" gap="2" paddingTop="4">
<Button variant="quiet" onPress={onClose}> <Button variant="quiet" onPress={onClose}>
{t(labels.cancel)} {t(labels.cancel)}

View file

@ -1,4 +1,4 @@
import { Box, Button, Dialog, Icon, Modal, Tooltip, TooltipTrigger } from '@umami/react-zen'; import { Box, Button, Dialog, Icon, Modal, Row, Tooltip, TooltipTrigger } from '@umami/react-zen';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { Panel } from '@/components/common/Panel'; import { Panel } from '@/components/common/Panel';
import { useBoard, useMessages } from '@/components/hooks'; import { useBoard, useMessages } from '@/components/hooks';
@ -43,7 +43,9 @@ export function BoardEditColumn({
const hasComponent = !!component; const hasComponent = !!component;
const canRemoveAction = hasComponent || canRemove; const canRemoveAction = hasComponent || canRemove;
const title = component ? getComponentDefinition(component.type)?.name : undefined; const defaultTitle = component ? getComponentDefinition(component.type)?.name : undefined;
const title = component?.title || defaultTitle;
const description = component?.description;
const handleRemove = () => { const handleRemove = () => {
if (hasComponent) { if (hasComponent) {
@ -56,10 +58,9 @@ export function BoardEditColumn({
return ( return (
<Panel <Panel
title={title} title={title}
description={description}
width="100%" width="100%"
height="100%" height="100%"
alignItems="center"
justifyContent="center"
position="relative" position="relative"
className={styles.column} className={styles.column}
> >
@ -67,51 +68,49 @@ export function BoardEditColumn({
<Box <Box
className={styles.columnAction} className={styles.columnAction}
position="absolute" position="absolute"
top="10px" top="22px"
right="20px" right="24px"
zIndex={100} zIndex={100}
> >
<TooltipTrigger delay={0}> <Row gap="2">
<Button variant="outline" onPress={handleRemove} isDisabled={!canRemoveAction}> {hasComponent && (
<Icon size="sm">
<X />
</Icon>
</Button>
<Tooltip>{hasComponent ? 'Remove component' : 'Remove column'}</Tooltip>
</TooltipTrigger>
</Box>
)}
{renderedComponent ? (
<>
<Box width="100%" height="100%" overflow="auto">
{renderedComponent}
</Box>
{canEdit && (
<Box
className={styles.columnAction}
position="absolute"
bottom="10px"
right="20px"
zIndex={100}
>
<TooltipTrigger delay={0}> <TooltipTrigger delay={0}>
<Button variant="outline" onPress={() => setShowSelect(true)}> <Button variant="outline" onPress={() => setShowSelect(true)}>
<Icon size="sm"> <Icon size="sm">
<Pencil /> <Pencil />
</Icon> </Icon>
</Button> </Button>
<Tooltip>Change component</Tooltip> <Tooltip>{t(labels.edit)}</Tooltip>
</TooltipTrigger> </TooltipTrigger>
</Box> )}
)} <TooltipTrigger delay={0}>
</> <Button variant="outline" onPress={handleRemove} isDisabled={!canRemoveAction}>
<Icon size="sm">
<X />
</Icon>
</Button>
<Tooltip>{t(labels.remove)}</Tooltip>
</TooltipTrigger>
</Row>
</Box>
)}
{renderedComponent ? (
<Box width="100%" height="100%" overflow="auto">
{renderedComponent}
</Box>
) : ( ) : (
canEdit && ( canEdit && (
<Button variant="outline" onPress={() => setShowSelect(true)}> <Box
<Icon> width="100%"
<Plus /> height="100%"
</Icon> style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}
</Button> >
<Button variant="outline" onPress={() => setShowSelect(true)}>
<Icon>
<Plus />
</Icon>
</Button>
</Box>
) )
)} )}
<Modal isOpen={showSelect} onOpenChange={setShowSelect}> <Modal isOpen={showSelect} onOpenChange={setShowSelect}>
@ -127,6 +126,7 @@ export function BoardEditColumn({
{() => ( {() => (
<BoardComponentSelect <BoardComponentSelect
websiteId={websiteId} websiteId={websiteId}
initialConfig={component}
onSelect={handleSelect} onSelect={handleSelect}
onClose={() => setShowSelect(false)} onClose={() => setShowSelect(false)}
/> />

View file

@ -13,10 +13,11 @@ export function BoardViewColumn({ component }: { component?: BoardComponentConfi
return null; return null;
} }
const title = getComponentDefinition(component.type)?.name; const title = component.title || getComponentDefinition(component.type)?.name;
const description = component.description;
return ( return (
<Panel title={title} height="100%"> <Panel title={title} description={description} height="100%">
<Column width="100%" height="100%"> <Column width="100%" height="100%">
<Box width="100%" overflow="auto"> <Box width="100%" overflow="auto">
<BoardComponentRenderer config={component} websiteId={websiteId} /> <BoardComponentRenderer config={component} websiteId={websiteId} />

View file

@ -5,6 +5,7 @@ import {
Heading, Heading,
Icon, Icon,
Row, Row,
Text,
Tooltip, Tooltip,
TooltipTrigger, TooltipTrigger,
} from '@umami/react-zen'; } from '@umami/react-zen';
@ -14,6 +15,7 @@ import { Maximize, X } from '@/components/icons';
export interface PanelProps extends ColumnProps { export interface PanelProps extends ColumnProps {
title?: string; title?: string;
description?: string;
allowFullscreen?: boolean; allowFullscreen?: boolean;
} }
@ -29,6 +31,7 @@ const fullscreenStyles = {
export function Panel({ export function Panel({
title, title,
description,
allowFullscreen, allowFullscreen,
style, style,
children, children,
@ -56,6 +59,7 @@ export function Panel({
style={{ ...style, ...(isFullscreen ? fullscreenStyles : { height, width }) }} style={{ ...style, ...(isFullscreen ? fullscreenStyles : { height, width }) }}
> >
{title && <Heading>{title}</Heading>} {title && <Heading>{title}</Heading>}
{description && <Text color="muted">{description}</Text>}
{allowFullscreen && ( {allowFullscreen && (
<Row justifyContent="flex-end" alignItems="center"> <Row justifyContent="flex-end" alignItems="center">
<TooltipTrigger delay={0} isDisabled={isFullscreen}> <TooltipTrigger delay={0} isDisabled={isFullscreen}>

View file

@ -146,6 +146,8 @@ export interface ApiError extends Error {
export interface BoardComponentConfig { export interface BoardComponentConfig {
type: string; type: string;
title?: string;
description?: string;
props?: Record<string, any>; props?: Record<string, any>;
} }