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; }