From 87bde9da1ff8dee1dff4cf3f68f4d9d8d01ae75a Mon Sep 17 00:00:00 2001 From: Mike Cao Date: Mon, 9 Feb 2026 00:52:46 -0800 Subject: [PATCH] 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 --- .gitignore | 1 - src/app/(main)/boards/BoardAddButton.tsx | 32 --- src/app/(main)/boards/BoardAddForm.tsx | 61 ------ .../(main)/boards/[boardId]/BoardColumn.tsx | 83 +++++++- .../[boardId]/BoardComponentRenderer.tsx | 25 +++ .../boards/[boardId]/BoardComponentSelect.tsx | 175 +++++++++++++++ .../boards/[boardId]/BoardEditHeader.tsx | 8 +- src/app/(main)/boards/[boardId]/BoardRow.tsx | 17 +- .../(main)/boards/boardComponentRegistry.tsx | 201 ++++++++++++++++++ src/lib/types.ts | 8 +- 10 files changed, 498 insertions(+), 113 deletions(-) delete mode 100644 src/app/(main)/boards/BoardAddButton.tsx delete mode 100644 src/app/(main)/boards/BoardAddForm.tsx create mode 100644 src/app/(main)/boards/[boardId]/BoardComponentRenderer.tsx create mode 100644 src/app/(main)/boards/[boardId]/BoardComponentSelect.tsx create mode 100644 src/app/(main)/boards/boardComponentRegistry.tsx diff --git a/.gitignore b/.gitignore index dcf657a08..f8814dc9f 100644 --- a/.gitignore +++ b/.gitignore @@ -47,4 +47,3 @@ yarn-error.log* *.env.* *.dev.yml - diff --git a/src/app/(main)/boards/BoardAddButton.tsx b/src/app/(main)/boards/BoardAddButton.tsx deleted file mode 100644 index e3139d4b2..000000000 --- a/src/app/(main)/boards/BoardAddButton.tsx +++ /dev/null @@ -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 ( - - - - - {({ close }) => } - - - - ); -} diff --git a/src/app/(main)/boards/BoardAddForm.tsx b/src/app/(main)/boards/BoardAddForm.tsx deleted file mode 100644 index 63fc1366e..000000000 --- a/src/app/(main)/boards/BoardAddForm.tsx +++ /dev/null @@ -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(); - - const handleSubmit = async (data: any) => { - await mutateAsync( - { type: 'board', ...data, parameters: { websiteId } }, - { - onSuccess: async () => { - onSave?.(); - onClose?.(); - }, - }, - ); - }; - - return ( -
- - - - - - - - {t(labels.website)} - - - - {onClose && ( - - )} - - {t(labels.save)} - - -
- ); -} diff --git a/src/app/(main)/boards/[boardId]/BoardColumn.tsx b/src/app/(main)/boards/[boardId]/BoardColumn.tsx index bfaf22fc9..4cffe00b6 100644 --- a/src/app/(main)/boards/[boardId]/BoardColumn.tsx +++ b/src/app/(main)/boards/[boardId]/BoardColumn.tsx @@ -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 ( )} - {editing && ( - + {component && websiteId ? ( + <> + + + + {editing && ( + + + + Change component + + + )} + + ) : ( + editing && ( + + ) )} + + + {() => ( + setShowSelect(false)} + /> + )} + + ); } diff --git a/src/app/(main)/boards/[boardId]/BoardComponentRenderer.tsx b/src/app/(main)/boards/[boardId]/BoardComponentRenderer.tsx new file mode 100644 index 000000000..f52284ad7 --- /dev/null +++ b/src/app/(main)/boards/[boardId]/BoardComponentRenderer.tsx @@ -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 ( + + Unknown component: {config.type} + + ); + } + + const Component = definition.component; + + return ; +} diff --git a/src/app/(main)/boards/[boardId]/BoardComponentSelect.tsx b/src/app/(main)/boards/[boardId]/BoardComponentSelect.tsx new file mode 100644 index 000000000..cc4c20fdb --- /dev/null +++ b/src/app/(main)/boards/[boardId]/BoardComponentSelect.tsx @@ -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(null); + const [configValues, setConfigValues] = useState>({}); + + const handleSelectComponent = (def: ComponentDefinition) => { + setSelectedDef(def); + const defaults: Record = {}; + 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 = {}; + 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 ( + + + + {CATEGORIES.map(cat => { + const components = getComponentsByCategory(cat.key); + return ( + + {cat.name} + {components.map(def => ( + + handleSelectComponent(def)} + > + + + {def.name} + + + {def.description} + + + + + ))} + + ); + })} + + + {selectedDef?.configFields && selectedDef.configFields.length > 0 && ( + + {selectedDef.configFields.map((field: ConfigField) => ( + + + {field.label} + + {field.type === 'select' && ( + + )} + + ))} + + )} + + {previewConfig && websiteId ? ( + + ) : ( + + + {websiteId ? 'Select a component to preview' : 'Select a website first'} + + + )} + + + + + + + + + ); +} diff --git a/src/app/(main)/boards/[boardId]/BoardEditHeader.tsx b/src/app/(main)/boards/[boardId]/BoardEditHeader.tsx index afcbea6df..48edb269d 100644 --- a/src/app/(main)/boards/[boardId]/BoardEditHeader.tsx +++ b/src/app/(main)/boards/[boardId]/BoardEditHeader.tsx @@ -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() { {t(labels.website)} - + diff --git a/src/app/(main)/boards/[boardId]/BoardRow.tsx b/src/app/(main)/boards/[boardId]/BoardRow.tsx index 7c5ee47ef..49e04bca7 100644 --- a/src/app/(main)/boards/[boardId]/BoardRow.tsx +++ b/src/app/(main)/boards/[boardId]/BoardRow.tsx @@ -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 ( {columns?.map((column, index) => ( @@ -70,6 +84,7 @@ export function BoardRow({ {...column} editing={editing} onRemove={handleRemoveColumn} + onSetComponent={handleSetComponent} canRemove={columns?.length > 1} /> diff --git a/src/app/(main)/boards/boardComponentRegistry.tsx b/src/app/(main)/boards/boardComponentRegistry.tsx new file mode 100644 index 000000000..de6c51440 --- /dev/null +++ b/src/app/(main)/boards/boardComponentRegistry.tsx @@ -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; + defaultProps?: Record; + 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); +} diff --git a/src/lib/types.ts b/src/lib/types.ts index 26fdd09fd..c06fe0e35 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -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; } export interface BoardColumn { id: string; - component?: ReactElement; + component?: BoardComponentConfig; size?: number; }