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 (
-
-
-
-
-
-
- );
-}
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 (
-
- );
-}
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 && (
+
+ )
)}
+
+
+
);
}
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;
}