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.
This commit is contained in:
Mike Cao
2026-03-04 21:55:52 -08:00
parent f0977321bd
commit 8563fe1d30
5 changed files with 66 additions and 19 deletions
+10
View File
@@ -0,0 +1,10 @@
'use client';
import { Text } from '@umami/react-zen';
export function TextBlock({ text }: { text?: string }) {
if (!text) {
return null;
}
return <Text style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>{text}</Text>;
}
@@ -22,7 +22,7 @@ function BoardComponentRendererComponent({
const Component = definition.component;
if (!websiteId) {
if (!websiteId && definition.requiresWebsite !== false) {
return (
<Column alignItems="center" justifyContent="center" width="100%" height="100%">
<Text color="muted">Select a website</Text>
@@ -30,7 +30,7 @@ function BoardComponentRendererComponent({
);
}
return <Component websiteId={websiteId} {...config.props} />;
return <Component {...(websiteId ? { websiteId } : {})} {...config.props} />;
}
export const BoardComponentRenderer = memo(
@@ -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<string, any> = {};
@@ -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 (
<Column gap="4">
@@ -184,7 +186,7 @@ export function BoardComponentSelect({
<Column gap="3" flexGrow={1} style={{ minWidth: 0 }}>
<Panel maxHeight="100%">
{previewConfig && selectedWebsiteId ? (
{previewConfig && (!needsWebsite || selectedWebsiteId) ? (
<BoardComponentRenderer config={previewConfig} websiteId={selectedWebsiteId} />
) : (
<Column alignItems="center" justifyContent="center" height="100%">
@@ -201,17 +203,19 @@ export function BoardComponentSelect({
<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.website)}
</Text>
<WebsiteSelect
websiteId={selectedWebsiteId}
teamId={teamId}
placeholder={t(labels.selectWebsite)}
onChange={setSelectedWebsiteId}
/>
</Column>
{needsWebsite && (
<Column gap="2">
<Text size="sm" color="muted">
{t(labels.website)}
</Text>
<WebsiteSelect
websiteId={selectedWebsiteId}
teamId={teamId}
placeholder={t(labels.selectWebsite)}
onChange={setSelectedWebsiteId}
/>
</Column>
)}
<Column gap="2">
<Text size="sm" color="muted">
@@ -262,6 +266,15 @@ export function BoardComponentSelect({
onChange={(value: string) => handleConfigChange(field.name, value)}
/>
)}
{field.type === 'textarea' && (
<TextField
asTextArea
value={String(configValues[field.name] ?? field.defaultValue ?? '')}
onChange={(value: string) => handleConfigChange(field.name, value)}
style={{ minHeight: 200 }}
/>
)}
</Column>
))}
</Column>
@@ -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;
}
@@ -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<any>;
defaultProps?: Record<string, any>;
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]));