Add board component selector with live preview

Allows users to select and inject analytics components into board cells.
Includes component registry, renderer, selector modal with category
menu, config fields for MetricsTable, and live preview. Also scopes
website select to team websites when editing a team board.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Mike Cao 2026-02-09 00:52:46 -08:00
parent 28246b8c52
commit 87bde9da1f
10 changed files with 498 additions and 113 deletions

1
.gitignore vendored
View file

@ -47,4 +47,3 @@ yarn-error.log*
*.env.*
*.dev.yml

View file

@ -1,32 +0,0 @@
import { Button, Dialog, DialogTrigger, Icon, Modal, Text, useToast } from '@umami/react-zen';
import { useMessages, useModified, useNavigation } from '@/components/hooks';
import { Plus } from '@/components/icons';
import { BoardAddForm } from './BoardAddForm';
export function BoardAddButton() {
const { t, labels, messages } = useMessages();
const { toast } = useToast();
const { touch } = useModified();
const { teamId } = useNavigation();
const handleSave = async () => {
toast(t(messages.saved));
touch('boards');
};
return (
<DialogTrigger>
<Button data-test="button-board-add" variant="primary">
<Icon>
<Plus />
</Icon>
<Text>{t(labels.addBoard)}</Text>
</Button>
<Modal>
<Dialog title={t(labels.addBoard)} style={{ width: 400 }}>
{({ close }) => <BoardAddForm teamId={teamId} onSave={handleSave} onClose={close} />}
</Dialog>
</Modal>
</DialogTrigger>
);
}

View file

@ -1,61 +0,0 @@
import { Button, Form, FormField, FormSubmitButton, Row, Text, TextField } from '@umami/react-zen';
import { useState } from 'react';
import { useMessages, useUpdateQuery } from '@/components/hooks';
import { WebsiteSelect } from '@/components/input/WebsiteSelect';
export function BoardAddForm({
teamId,
onSave,
onClose,
}: {
teamId?: string;
onSave?: () => void;
onClose?: () => void;
}) {
const { t, labels } = useMessages();
const { mutateAsync, error, isPending } = useUpdateQuery('/boards', { teamId });
const [websiteId, setWebsiteId] = useState<string>();
const handleSubmit = async (data: any) => {
await mutateAsync(
{ type: 'board', ...data, parameters: { websiteId } },
{
onSuccess: async () => {
onSave?.();
onClose?.();
},
},
);
};
return (
<Form onSubmit={handleSubmit} error={error?.message}>
<FormField label={t(labels.name)} name="name" rules={{ required: t(labels.required) }}>
<TextField autoComplete="off" />
</FormField>
<FormField
label={t(labels.description)}
name="description"
rules={{
required: t(labels.required),
}}
>
<TextField asTextArea autoComplete="off" />
</FormField>
<Row alignItems="center" gap="3" paddingTop="3">
<Text>{t(labels.website)}</Text>
<WebsiteSelect websiteId={websiteId} teamId={teamId} onChange={setWebsiteId} />
</Row>
<Row justifyContent="flex-end" paddingTop="3" gap="3">
{onClose && (
<Button isDisabled={isPending} onPress={onClose}>
{t(labels.cancel)}
</Button>
)}
<FormSubmitButton data-test="button-submit" isDisabled={false}>
{t(labels.save)}
</FormSubmitButton>
</Row>
</Form>
);
}

View file

@ -1,21 +1,43 @@
import { Box, Button, Column, Icon, Tooltip, TooltipTrigger } from '@umami/react-zen';
import type { ReactElement } from 'react';
import { Plus, X } from '@/components/icons';
import {
Box,
Button,
Column,
Dialog,
Icon,
Modal,
Tooltip,
TooltipTrigger,
} from '@umami/react-zen';
import { useState } from 'react';
import { useBoard } from '@/components/hooks';
import { Pencil, Plus, X } from '@/components/icons';
import type { BoardComponentConfig } from '@/lib/types';
import { BoardComponentRenderer } from './BoardComponentRenderer';
import { BoardComponentSelect } from './BoardComponentSelect';
export function BoardColumn({
id,
component,
editing = false,
onRemove,
onSetComponent,
canRemove = true,
}: {
id: string;
component?: ReactElement;
component?: BoardComponentConfig;
editing?: boolean;
onRemove?: (id: string) => void;
onSetComponent?: (id: string, config: BoardComponentConfig | null) => void;
canRemove?: boolean;
}) {
const handleAddComponent = () => {};
const [showSelect, setShowSelect] = useState(false);
const { board } = useBoard();
const websiteId = board?.parameters?.websiteId;
const handleSelect = (config: BoardComponentConfig) => {
onSetComponent?.(id, config);
setShowSelect(false);
};
return (
<Column
@ -40,13 +62,52 @@ export function BoardColumn({
</TooltipTrigger>
</Box>
)}
{editing && (
<Button variant="outline" onPress={handleAddComponent}>
<Icon>
<Plus />
</Icon>
</Button>
{component && websiteId ? (
<>
<Box width="100%" height="100%" overflow="auto">
<BoardComponentRenderer config={component} websiteId={websiteId} />
</Box>
{editing && (
<Box position="absolute" bottom="10px" right="20px" zIndex={100}>
<TooltipTrigger delay={0}>
<Button variant="quiet" onPress={() => setShowSelect(true)}>
<Icon size="sm">
<Pencil />
</Icon>
</Button>
<Tooltip>Change component</Tooltip>
</TooltipTrigger>
</Box>
)}
</>
) : (
editing && (
<Button variant="outline" onPress={() => setShowSelect(true)}>
<Icon>
<Plus />
</Icon>
</Button>
)
)}
<Modal isOpen={showSelect} onOpenChange={setShowSelect}>
<Dialog
title="Add component"
style={{
width: '750px',
maxWidth: 'calc(100vw - 40px)',
maxHeight: 'calc(100dvh - 40px)',
padding: '32px',
}}
>
{() => (
<BoardComponentSelect
websiteId={websiteId}
onSelect={handleSelect}
onClose={() => setShowSelect(false)}
/>
)}
</Dialog>
</Modal>
</Column>
);
}

View file

@ -0,0 +1,25 @@
import { Column, Text } from '@umami/react-zen';
import type { BoardComponentConfig } from '@/lib/types';
import { getComponentDefinition } from '../boardComponentRegistry';
export function BoardComponentRenderer({
config,
websiteId,
}: {
config: BoardComponentConfig;
websiteId: string;
}) {
const definition = getComponentDefinition(config.type);
if (!definition) {
return (
<Column alignItems="center" justifyContent="center" width="100%" height="100%">
<Text color="muted">Unknown component: {config.type}</Text>
</Column>
);
}
const Component = definition.component;
return <Component websiteId={websiteId} {...config.props} />;
}

View file

@ -0,0 +1,175 @@
import {
Box,
Button,
Column,
Focusable,
Heading,
ListItem,
Row,
Select,
Text,
} from '@umami/react-zen';
import { useState } from 'react';
import type { BoardComponentConfig } from '@/lib/types';
import {
CATEGORIES,
type ComponentDefinition,
type ConfigField,
getComponentsByCategory,
} from '../boardComponentRegistry';
import { BoardComponentRenderer } from './BoardComponentRenderer';
export function BoardComponentSelect({
websiteId,
onSelect,
onClose,
}: {
websiteId: string;
onSelect: (config: BoardComponentConfig) => void;
onClose: () => void;
}) {
const [selectedDef, setSelectedDef] = useState<ComponentDefinition | null>(null);
const [configValues, setConfigValues] = useState<Record<string, any>>({});
const handleSelectComponent = (def: ComponentDefinition) => {
setSelectedDef(def);
const defaults: Record<string, any> = {};
if (def.configFields) {
for (const field of def.configFields) {
defaults[field.name] = field.defaultValue;
}
}
if (def.defaultProps) {
Object.assign(defaults, def.defaultProps);
}
setConfigValues(defaults);
};
const handleConfigChange = (name: string, value: any) => {
setConfigValues(prev => ({ ...prev, [name]: value }));
};
const handleAdd = () => {
if (!selectedDef) return;
const props: Record<string, any> = {};
if (selectedDef.defaultProps) {
Object.assign(props, selectedDef.defaultProps);
}
Object.assign(props, configValues);
if (props.limit) {
props.limit = Number(props.limit);
}
const config: BoardComponentConfig = { type: selectedDef.type };
if (Object.keys(props).length > 0) {
config.props = props;
}
onSelect(config);
};
const previewConfig: BoardComponentConfig | null = selectedDef
? {
type: selectedDef.type,
props: { ...selectedDef.defaultProps, ...configValues },
}
: null;
return (
<Column gap="4">
<Row gap="4" style={{ height: 500 }}>
<Column gap="1" style={{ width: 200, flexShrink: 0, overflowY: 'auto' }}>
{CATEGORIES.map(cat => {
const components = getComponentsByCategory(cat.key);
return (
<Column key={cat.key} gap="1" marginBottom="2">
<Heading size="md">{cat.name}</Heading>
{components.map(def => (
<Focusable key={def.type}>
<Row
alignItems="center"
paddingX="3"
paddingY="2"
borderRadius
backgroundColor={
selectedDef?.type === def.type ? 'surface-sunken' : undefined
}
hover={{ backgroundColor: 'surface-sunken' }}
style={{ cursor: 'pointer' }}
onClick={() => handleSelectComponent(def)}
>
<Column>
<Text
size="sm"
weight={selectedDef?.type === def.type ? 'bold' : undefined}
>
{def.name}
</Text>
<Text size="xs" color="muted">
{def.description}
</Text>
</Column>
</Row>
</Focusable>
))}
</Column>
);
})}
</Column>
<Column gap="3" flexGrow={1} style={{ minWidth: 0 }}>
{selectedDef?.configFields && selectedDef.configFields.length > 0 && (
<Row gap="3" alignItems="center" flexWrap="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>
)}
<Box
flexGrow={1}
border
borderRadius
overflow="auto"
position="relative"
style={{ minHeight: 0 }}
>
{previewConfig && websiteId ? (
<BoardComponentRenderer config={previewConfig} websiteId={websiteId} />
) : (
<Column alignItems="center" justifyContent="center" height="100%">
<Text color="muted">
{websiteId ? 'Select a component to preview' : 'Select a website first'}
</Text>
</Column>
)}
</Box>
</Column>
</Row>
<Row justifyContent="flex-end" gap="2" paddingTop="4" border="top">
<Button variant="quiet" onPress={onClose}>
Cancel
</Button>
<Button variant="primary" onPress={handleAdd} isDisabled={!selectedDef}>
Add
</Button>
</Row>
</Column>
);
}

View file

@ -14,7 +14,7 @@ import { WebsiteSelect } from '@/components/input/WebsiteSelect';
export function BoardEditHeader() {
const { board, updateBoard, saveBoard, isPending } = useBoard();
const { t, labels } = useMessages();
const { router, renderUrl } = useNavigation();
const { router, renderUrl, teamId } = useNavigation();
const defaultName = t(labels.untitled);
const handleNameChange = (value: string) => {
@ -81,7 +81,11 @@ export function BoardEditHeader() {
</Row>
<Row alignItems="center" gap="3">
<Text>{t(labels.website)}</Text>
<WebsiteSelect websiteId={board?.parameters?.websiteId} onChange={handleWebsiteChange} />
<WebsiteSelect
websiteId={board?.parameters?.websiteId}
teamId={teamId}
onChange={handleWebsiteChange}
/>
</Row>
</Column>
<Column justifyContent="center" alignItems="flex-end">

View file

@ -5,7 +5,7 @@ import { Group, type GroupImperativeHandle, Panel, Separator } from 'react-resiz
import { v4 as uuid } from 'uuid';
import { useBoard } from '@/components/hooks';
import { ChevronDown, Minus, Plus } from '@/components/icons';
import type { BoardColumn as BoardColumnType } from '@/lib/types';
import type { BoardColumn as BoardColumnType, BoardComponentConfig } from '@/lib/types';
import { BoardColumn } from './BoardColumn';
import { MAX_COLUMNS, MIN_COLUMN_WIDTH } from './boardConstants';
@ -61,6 +61,20 @@ export function BoardRow({
});
};
const handleSetComponent = (columnId: string, config: BoardComponentConfig | null) => {
updateBoard({
parameters: produce(board.parameters, draft => {
const row = draft.rows.find(row => row.id === rowId);
if (row) {
const col = row.columns.find(col => col.id === columnId);
if (col) {
col.component = config;
}
}
}),
});
};
return (
<Group groupRef={handleGroupRef} style={{ height: '100%' }}>
{columns?.map((column, index) => (
@ -70,6 +84,7 @@ export function BoardRow({
{...column}
editing={editing}
onRemove={handleRemoveColumn}
onSetComponent={handleSetComponent}
canRemove={columns?.length > 1}
/>
</Panel>

View file

@ -0,0 +1,201 @@
import type { ComponentType } from 'react';
import { Attribution } from '@/app/(main)/websites/[websiteId]/(reports)/attribution/Attribution';
import { Breakdown } from '@/app/(main)/websites/[websiteId]/(reports)/breakdown/Breakdown';
import { Funnel } from '@/app/(main)/websites/[websiteId]/(reports)/funnels/Funnel';
import { Goal } from '@/app/(main)/websites/[websiteId]/(reports)/goals/Goal';
import { Journey } from '@/app/(main)/websites/[websiteId]/(reports)/journeys/Journey';
import { Retention } from '@/app/(main)/websites/[websiteId]/(reports)/retention/Retention';
import { Revenue } from '@/app/(main)/websites/[websiteId]/(reports)/revenue/Revenue';
import { UTM } from '@/app/(main)/websites/[websiteId]/(reports)/utm/UTM';
import { WebsiteChart } from '@/app/(main)/websites/[websiteId]/WebsiteChart';
import { WebsiteMetricsBar } from '@/app/(main)/websites/[websiteId]/WebsiteMetricsBar';
import { EventsChart } from '@/components/metrics/EventsChart';
import { MetricsTable } from '@/components/metrics/MetricsTable';
import { WeeklyTraffic } from '@/components/metrics/WeeklyTraffic';
import { WorldMap } from '@/components/metrics/WorldMap';
export interface ConfigField {
name: string;
label: string;
type: 'select' | 'number' | 'text';
options?: { label: string; value: string }[];
defaultValue?: any;
}
export interface ComponentDefinition {
type: string;
name: string;
description: string;
category: string;
component: ComponentType<any>;
defaultProps?: Record<string, any>;
configFields?: ConfigField[];
}
export const CATEGORIES = [
{ key: 'overview', name: 'Overview' },
{ key: 'tables', name: 'Tables' },
{ key: 'visualization', name: 'Visualization' },
{ key: 'reports', name: 'Reports' },
] as const;
const METRIC_TYPES = [
{ label: 'Pages', value: 'path' },
{ label: 'Entry pages', value: 'entry' },
{ label: 'Exit pages', value: 'exit' },
{ label: 'Referrers', value: 'referrer' },
{ label: 'Channels', value: 'channel' },
{ label: 'Browsers', value: 'browser' },
{ label: 'OS', value: 'os' },
{ label: 'Devices', value: 'device' },
{ label: 'Countries', value: 'country' },
{ label: 'Regions', value: 'region' },
{ label: 'Cities', value: 'city' },
{ label: 'Languages', value: 'language' },
{ label: 'Screens', value: 'screen' },
{ label: 'Query parameters', value: 'query' },
{ label: 'Page titles', value: 'title' },
{ label: 'Hosts', value: 'host' },
{ label: 'Events', value: 'event' },
];
const LIMIT_OPTIONS = [
{ label: '5', value: '5' },
{ label: '10', value: '10' },
{ label: '20', value: '20' },
];
const componentDefinitions: ComponentDefinition[] = [
// Overview
{
type: 'WebsiteMetricsBar',
name: 'Metrics bar',
description: 'Key metrics: views, visitors, bounces, time on site',
category: 'overview',
component: WebsiteMetricsBar,
},
{
type: 'WebsiteChart',
name: 'Website chart',
description: 'Page views and visitors over time',
category: 'overview',
component: WebsiteChart,
},
// Tables
{
type: 'MetricsTable',
name: 'Metrics table',
description: 'Table of metrics by dimension',
category: 'tables',
component: MetricsTable,
defaultProps: { type: 'path', limit: 10 },
configFields: [
{
name: 'type',
label: 'Metric type',
type: 'select',
options: METRIC_TYPES,
defaultValue: 'path',
},
{
name: 'limit',
label: 'Rows',
type: 'select',
options: LIMIT_OPTIONS,
defaultValue: '10',
},
],
},
// Visualization
{
type: 'WorldMap',
name: 'World map',
description: 'Geographic distribution of visitors',
category: 'visualization',
component: WorldMap,
},
{
type: 'WeeklyTraffic',
name: 'Weekly traffic',
description: 'Traffic heatmap by day and hour',
category: 'visualization',
component: WeeklyTraffic,
},
{
type: 'EventsChart',
name: 'Events chart',
description: 'Custom events over time',
category: 'visualization',
component: EventsChart,
},
// Reports
{
type: 'Retention',
name: 'Retention',
description: 'User retention cohort analysis',
category: 'reports',
component: Retention,
},
{
type: 'Funnel',
name: 'Funnel',
description: 'Conversion funnel visualization',
category: 'reports',
component: Funnel,
},
{
type: 'Goal',
name: 'Goal',
description: 'Goal tracking and progress',
category: 'reports',
component: Goal,
},
{
type: 'Journey',
name: 'Journey',
description: 'User navigation flow',
category: 'reports',
component: Journey,
},
{
type: 'UTM',
name: 'UTM',
description: 'UTM campaign performance',
category: 'reports',
component: UTM,
},
{
type: 'Revenue',
name: 'Revenue',
description: 'Revenue analytics and trends',
category: 'reports',
component: Revenue,
},
{
type: 'Attribution',
name: 'Attribution',
description: 'Traffic source attribution',
category: 'reports',
component: Attribution,
},
{
type: 'Breakdown',
name: 'Breakdown',
description: 'Multi-dimensional data breakdown',
category: 'reports',
component: Breakdown,
},
];
const definitionMap = new Map(componentDefinitions.map(def => [def.type, def]));
export function getComponentDefinition(type: string): ComponentDefinition | undefined {
return definitionMap.get(type);
}
export function getComponentsByCategory(category: string): ComponentDefinition[] {
return componentDefinitions.filter(def => def.category === category);
}

View file

@ -1,5 +1,4 @@
import type { UseQueryOptions } from '@tanstack/react-query';
import type { ReactElement } from 'react';
import type { Board as PrismaBoard } from '@/generated/prisma/client';
import type { DATA_TYPE, OPERATORS, ROLES } from './constants';
import type { TIME_UNIT } from './date';
@ -145,15 +144,14 @@ export interface ApiError extends Error {
message: string;
}
export interface BoardComponent {
id: string;
export interface BoardComponentConfig {
type: string;
value: string;
props?: Record<string, any>;
}
export interface BoardColumn {
id: string;
component?: ReactElement;
component?: BoardComponentConfig;
size?: number;
}