From 8563fe1d30af9bfb33b92ec730b86f216f365cc5 Mon Sep 17 00:00:00 2001 From: Mike Cao Date: Wed, 4 Mar 2026 21:55:52 -0800 Subject: [PATCH] Add TextBlock board component for free-form text content New component that doesn't require a website, with textarea config field. Added requiresWebsite flag to ComponentDefinition so board components can opt out of the websiteId requirement. --- src/app/(main)/boards/TextBlock.tsx | 10 +++++ .../[boardId]/BoardComponentRenderer.tsx | 4 +- .../boards/[boardId]/BoardComponentSelect.tsx | 43 ++++++++++++------- .../boards/[boardId]/BoardViewColumn.tsx | 4 +- .../(main)/boards/boardComponentRegistry.tsx | 24 ++++++++++- 5 files changed, 66 insertions(+), 19 deletions(-) create mode 100644 src/app/(main)/boards/TextBlock.tsx diff --git a/src/app/(main)/boards/TextBlock.tsx b/src/app/(main)/boards/TextBlock.tsx new file mode 100644 index 000000000..6e199ec5e --- /dev/null +++ b/src/app/(main)/boards/TextBlock.tsx @@ -0,0 +1,10 @@ +'use client'; +import { Text } from '@umami/react-zen'; + +export function TextBlock({ text }: { text?: string }) { + if (!text) { + return null; + } + + return {text}; +} diff --git a/src/app/(main)/boards/[boardId]/BoardComponentRenderer.tsx b/src/app/(main)/boards/[boardId]/BoardComponentRenderer.tsx index 3609e9b9e..f6449fbd4 100644 --- a/src/app/(main)/boards/[boardId]/BoardComponentRenderer.tsx +++ b/src/app/(main)/boards/[boardId]/BoardComponentRenderer.tsx @@ -22,7 +22,7 @@ function BoardComponentRendererComponent({ const Component = definition.component; - if (!websiteId) { + if (!websiteId && definition.requiresWebsite !== false) { return ( Select a website @@ -30,7 +30,7 @@ function BoardComponentRendererComponent({ ); } - return ; + return ; } export const BoardComponentRenderer = memo( diff --git a/src/app/(main)/boards/[boardId]/BoardComponentSelect.tsx b/src/app/(main)/boards/[boardId]/BoardComponentSelect.tsx index c88714f04..e3111b191 100644 --- a/src/app/(main)/boards/[boardId]/BoardComponentSelect.tsx +++ b/src/app/(main)/boards/[boardId]/BoardComponentSelect.tsx @@ -97,8 +97,10 @@ export function BoardComponentSelect({ setConfigValues(prev => ({ ...prev, [name]: value })); }; + const needsWebsite = selectedDef?.requiresWebsite !== false; + const handleAdd = () => { - if (!selectedDef || !selectedWebsiteId) return; + if (!selectedDef || (needsWebsite && !selectedWebsiteId)) return; const props: Record = {}; @@ -116,7 +118,7 @@ export function BoardComponentSelect({ const config: BoardComponentConfig = { type: selectedDef.type, - websiteId: selectedWebsiteId, + ...(needsWebsite ? { websiteId: selectedWebsiteId } : {}), title, description, }; @@ -137,7 +139,7 @@ export function BoardComponentSelect({ } : null; - const canSave = !!selectedDef && !!selectedWebsiteId; + const canSave = !!selectedDef && (!needsWebsite || !!selectedWebsiteId); return ( @@ -184,7 +186,7 @@ export function BoardComponentSelect({ - {previewConfig && selectedWebsiteId ? ( + {previewConfig && (!needsWebsite || selectedWebsiteId) ? ( ) : ( @@ -201,17 +203,19 @@ export function BoardComponentSelect({ {t(labels.properties)} - - - {t(labels.website)} - - - + {needsWebsite && ( + + + {t(labels.website)} + + + + )} @@ -262,6 +266,15 @@ export function BoardComponentSelect({ onChange={(value: string) => handleConfigChange(field.name, value)} /> )} + + {field.type === 'textarea' && ( + handleConfigChange(field.name, value)} + style={{ minHeight: 200 }} + /> + )} ))} diff --git a/src/app/(main)/boards/[boardId]/BoardViewColumn.tsx b/src/app/(main)/boards/[boardId]/BoardViewColumn.tsx index 4a5909eda..65df4fdca 100644 --- a/src/app/(main)/boards/[boardId]/BoardViewColumn.tsx +++ b/src/app/(main)/boards/[boardId]/BoardViewColumn.tsx @@ -2,13 +2,15 @@ import { Box, Column } from '@umami/react-zen'; import { Panel } from '@/components/common/Panel'; import { useBoard } from '@/components/hooks'; import type { BoardComponentConfig } from '@/lib/types'; +import { getComponentDefinition } from '../boardComponentRegistry'; import { BoardComponentRenderer } from './BoardComponentRenderer'; export function BoardViewColumn({ component }: { component?: BoardComponentConfig }) { const { board } = useBoard(); + const definition = component ? getComponentDefinition(component.type) : undefined; const websiteId = component?.websiteId || board?.parameters?.websiteId; - if (!component || !websiteId) { + if (!component || (!websiteId && definition?.requiresWebsite !== false)) { return null; } diff --git a/src/app/(main)/boards/boardComponentRegistry.tsx b/src/app/(main)/boards/boardComponentRegistry.tsx index 82843cca9..1d7222c60 100644 --- a/src/app/(main)/boards/boardComponentRegistry.tsx +++ b/src/app/(main)/boards/boardComponentRegistry.tsx @@ -1,4 +1,5 @@ import type { ComponentType } from 'react'; +import { TextBlock } from '@/app/(main)/boards/TextBlock'; import { WebsiteChart } from '@/app/(main)/websites/[websiteId]/WebsiteChart'; import { WebsiteMetricsBar } from '@/app/(main)/websites/[websiteId]/WebsiteMetricsBar'; import { EventsChart } from '@/components/metrics/EventsChart'; @@ -9,7 +10,7 @@ import { WorldMap } from '@/components/metrics/WorldMap'; export interface ConfigField { name: string; label: string; - type: 'select' | 'number' | 'text'; + type: 'select' | 'number' | 'text' | 'textarea'; options?: { label: string; value: string }[]; defaultValue?: any; } @@ -22,12 +23,14 @@ export interface ComponentDefinition { component: ComponentType; defaultProps?: Record; configFields?: ConfigField[]; + requiresWebsite?: boolean; } export const CATEGORIES = [ { key: 'overview', name: 'Overview' }, { key: 'tables', name: 'Tables' }, { key: 'visualization', name: 'Visualization' }, + { key: 'content', name: 'Content' }, ] as const; const METRIC_TYPES = [ @@ -121,6 +124,25 @@ const componentDefinitions: ComponentDefinition[] = [ category: 'visualization', component: EventsChart, }, + + // Content + { + type: 'TextBlock', + name: 'Text', + description: 'Free-form text content', + category: 'content', + component: TextBlock, + requiresWebsite: false, + defaultProps: { text: '' }, + configFields: [ + { + name: 'text', + label: 'Text', + type: 'textarea', + defaultValue: '', + }, + ], + }, ]; const definitionMap = new Map(componentDefinitions.map(def => [def.type, def]));