mirror of
https://github.com/umami-software/umami.git
synced 2026-05-30 06:47:25 +00:00
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:
@@ -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]));
|
||||
|
||||
Reference in New Issue
Block a user