From f76b22b3033a7d69936a5dacff9ab4af4f38c1a0 Mon Sep 17 00:00:00 2001 From: Mike Cao Date: Fri, 6 Mar 2026 17:08:32 -0800 Subject: [PATCH] Add board type binding support --- public/intl/messages/en-US.json | 5 + src/app/(main)/boards/BoardProvider.tsx | 10 +- .../boards/[boardId]/BoardComponentSelect.tsx | 129 ++++++++++--- .../(main)/boards/[boardId]/BoardControls.tsx | 26 ++- .../(main)/boards/[boardId]/BoardEditBody.tsx | 6 +- .../boards/[boardId]/BoardEditColumn.tsx | 19 +- .../(main)/boards/[boardId]/BoardEditForm.tsx | 169 ++++++++++++----- .../boards/[boardId]/BoardViewColumn.tsx | 7 +- .../(main)/dashboard/DashboardProvider.tsx | 2 + src/app/api/boards/[boardId]/route.ts | 14 +- src/app/api/boards/route.ts | 12 +- src/components/input/LinkSelect.tsx | 8 +- src/components/input/PixelSelect.tsx | 8 +- src/components/messages.ts | 5 + src/lib/boards.ts | 171 ++++++++++++++++++ src/lib/types.ts | 4 + 16 files changed, 496 insertions(+), 99 deletions(-) create mode 100644 src/lib/boards.ts diff --git a/public/intl/messages/en-US.json b/public/intl/messages/en-US.json index 285169750..d0957d11e 100644 --- a/public/intl/messages/en-US.json +++ b/public/intl/messages/en-US.json @@ -31,6 +31,7 @@ "behavior": "Behavior", "block-selector": "Block selector", "boards": "Boards", + "board-type": "Board type", "bounce-rate": "Bounce rate", "breakdown": "Breakdown", "browser": "Browser", @@ -201,6 +202,7 @@ "number-of-records": "{x} {x, plural, one {record} other {records}}", "ok": "OK", "online": "Online", + "open": "Open", "organic-search": "Organic search", "organic-shopping": "Organic shopping", "organic-social": "Organic social", @@ -277,6 +279,8 @@ "segment": "Segment", "segments": "Segments", "select": "Select", + "select-link": "Select link", + "select-pixel": "Select pixel", "select-date": "Select date", "select-filter": "Select filter", "select-role": "Select role", @@ -404,6 +408,7 @@ "reset-website": "To reset this website, type {confirmation} in the box below to confirm.", "reset-website-warning": "All statistics for this website will be deleted, but your settings will remain intact.", "saved": "Saved.", + "select-board-entity-first": "Select a website, pixel, or link first.", "select-component-preview": "Select a component to preview", "select-website-first": "Select a website first", "sever-error": "Server error", diff --git a/src/app/(main)/boards/BoardProvider.tsx b/src/app/(main)/boards/BoardProvider.tsx index d0490163e..a196f8d5c 100644 --- a/src/app/(main)/boards/BoardProvider.tsx +++ b/src/app/(main)/boards/BoardProvider.tsx @@ -3,6 +3,7 @@ import { Loading, useToast } from '@umami/react-zen'; import { createContext, type ReactNode, useCallback, useEffect, useRef, useState } from 'react'; import { v4 as uuid } from 'uuid'; import { useApi, useMessages, useModified, useNavigation } from '@/components/hooks'; +import { BOARD_TYPES, getBoardType } from '@/lib/boards'; import { useBoardQuery } from '@/components/hooks/queries/useBoardQuery'; import type { Board, BoardParameters } from '@/lib/types'; import { getComponentDefinition } from './boardComponentRegistry'; @@ -21,6 +22,7 @@ export interface BoardContextValue { export const BoardContext = createContext(null); const createDefaultBoard = (): Partial => ({ + type: BOARD_TYPES.open, name: '', description: '', parameters: { @@ -78,6 +80,7 @@ export function BoardProvider({ if (data) { setBoard({ ...data, + type: getBoardType(data, { coerceDashboard: true }), parameters: sanitizeBoardParameters(data.parameters), }); } @@ -88,7 +91,12 @@ export function BoardProvider({ if (boardData.id) { return post(`/boards/${boardData.id}`, boardData); } - return post('/boards', { ...boardData, type: 'dashboard', slug: '', teamId }); + return post('/boards', { + ...boardData, + type: boardData.type || BOARD_TYPES.open, + slug: '', + teamId, + }); }, }); diff --git a/src/app/(main)/boards/[boardId]/BoardComponentSelect.tsx b/src/app/(main)/boards/[boardId]/BoardComponentSelect.tsx index e3111b191..0849a7818 100644 --- a/src/app/(main)/boards/[boardId]/BoardComponentSelect.tsx +++ b/src/app/(main)/boards/[boardId]/BoardComponentSelect.tsx @@ -11,8 +11,17 @@ import { import { useEffect, useMemo, useState } from 'react'; import { Panel } from '@/components/common/Panel'; import { useMessages } from '@/components/hooks'; +import { LinkSelect } from '@/components/input/LinkSelect'; +import { PixelSelect } from '@/components/input/PixelSelect'; import { WebsiteSelect } from '@/components/input/WebsiteSelect'; import type { BoardComponentConfig } from '@/lib/types'; +import { + BOARD_ENTITY_TYPES, + type BoardEntityType, + type BoardType, + getComponentEntity, + isOpenBoardType, +} from '@/lib/boards'; import { CATEGORIES, type ComponentDefinition, @@ -23,24 +32,30 @@ import { BoardComponentRenderer } from './BoardComponentRenderer'; export function BoardComponentSelect({ teamId, - websiteId, - defaultWebsiteId, + boardType, + boardEntityType, + boardEntityId, initialConfig, onSelect, onClose, }: { teamId?: string; - websiteId?: string; - defaultWebsiteId?: string; + boardType: BoardType; + boardEntityType?: BoardEntityType; + boardEntityId?: string; initialConfig?: BoardComponentConfig; onSelect: (config: BoardComponentConfig) => void; onClose: () => void; }) { const { t, labels, messages } = useMessages(); + const initialEntity = getComponentEntity(initialConfig); const [selectedDef, setSelectedDef] = useState(null); const [configValues, setConfigValues] = useState>({}); - const [selectedWebsiteId, setSelectedWebsiteId] = useState( - initialConfig?.websiteId || websiteId || defaultWebsiteId, + const [selectedEntityType, setSelectedEntityType] = useState( + initialEntity.entityType || boardEntityType || BOARD_ENTITY_TYPES.website, + ); + const [selectedEntityId, setSelectedEntityId] = useState( + initialEntity.entityId || boardEntityId, ); const [title, setTitle] = useState(''); const [description, setDescription] = useState(''); @@ -81,10 +96,20 @@ export function BoardComponentSelect({ setSelectedDef(definition); setConfigValues(getDefaultConfigValues(definition, initialConfig)); - setSelectedWebsiteId(initialConfig.websiteId || websiteId || defaultWebsiteId); + setSelectedEntityType( + initialEntity.entityType || boardEntityType || BOARD_ENTITY_TYPES.website, + ); + setSelectedEntityId(initialEntity.entityId || boardEntityId); setTitle(initialConfig.title ?? definition.name); setDescription(initialConfig.description || ''); - }, [initialConfig, allDefinitions, websiteId, defaultWebsiteId]); + }, [ + initialConfig, + allDefinitions, + boardEntityId, + boardEntityType, + initialEntity.entityId, + initialEntity.entityType, + ]); const handleSelectComponent = (def: ComponentDefinition) => { setSelectedDef(def); @@ -98,9 +123,25 @@ export function BoardComponentSelect({ }; const needsWebsite = selectedDef?.requiresWebsite !== false; + const isOpenType = isOpenBoardType(boardType); + const resolvedEntityType = needsWebsite + ? isOpenType + ? selectedEntityType + : boardEntityType + : undefined; + const resolvedEntityId = needsWebsite + ? isOpenType + ? selectedEntityId + : boardEntityId + : undefined; + + const handleEntityTypeChange = (value: string) => { + setSelectedEntityType(value as BoardEntityType); + setSelectedEntityId(undefined); + }; const handleAdd = () => { - if (!selectedDef || (needsWebsite && !selectedWebsiteId)) return; + if (!selectedDef || (needsWebsite && !resolvedEntityId)) return; const props: Record = {}; @@ -118,7 +159,9 @@ export function BoardComponentSelect({ const config: BoardComponentConfig = { type: selectedDef.type, - ...(needsWebsite ? { websiteId: selectedWebsiteId } : {}), + ...(needsWebsite && isOpenType && resolvedEntityId + ? { entityType: resolvedEntityType, entityId: resolvedEntityId } + : {}), title, description, }; @@ -139,7 +182,7 @@ export function BoardComponentSelect({ } : null; - const canSave = !!selectedDef && (!needsWebsite || !!selectedWebsiteId); + const canSave = !!selectedDef && (!needsWebsite || !!resolvedEntityId); return ( @@ -186,14 +229,14 @@ export function BoardComponentSelect({ - {previewConfig && (!needsWebsite || selectedWebsiteId) ? ( - + {previewConfig && (!needsWebsite || resolvedEntityId) ? ( + ) : ( - {selectedWebsiteId + {resolvedEntityId ? t(messages.selectComponentPreview) - : t(messages.selectWebsiteFirst)} + : t(messages.selectBoardEntityFirst)} )} @@ -203,18 +246,50 @@ export function BoardComponentSelect({ {t(labels.properties)} - {needsWebsite && ( - - - {t(labels.website)} - - - + {needsWebsite && isOpenType && ( + <> + + + {t(labels.type)} + + + + + + {selectedEntityType === BOARD_ENTITY_TYPES.pixel + ? t(labels.pixel) + : selectedEntityType === BOARD_ENTITY_TYPES.link + ? t(labels.link) + : t(labels.website)} + + {selectedEntityType === BOARD_ENTITY_TYPES.pixel ? ( + + ) : selectedEntityType === BOARD_ENTITY_TYPES.link ? ( + + ) : ( + + )} + + )} diff --git a/src/app/(main)/boards/[boardId]/BoardControls.tsx b/src/app/(main)/boards/[boardId]/BoardControls.tsx index bed17ad57..326a415f8 100644 --- a/src/app/(main)/boards/[boardId]/BoardControls.tsx +++ b/src/app/(main)/boards/[boardId]/BoardControls.tsx @@ -1,20 +1,30 @@ import { Box } from '@umami/react-zen'; +import { LinkControls } from '@/app/(main)/links/[linkId]/LinkControls'; +import { PixelControls } from '@/app/(main)/pixels/[pixelId]/PixelControls'; import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls'; import { useBoard } from '@/components/hooks'; +import { BOARD_ENTITY_TYPES, getBoardEntity, getFirstBoardComponentEntity } from '@/lib/boards'; export function BoardControls() { const { board } = useBoard(); - const boardWebsiteId = board?.parameters?.websiteId; - const componentWebsiteIds = board?.parameters?.rows - ?.flatMap(row => row.columns) - .map(column => column.component?.websiteId) - .filter(Boolean); - const fallbackWebsiteId = componentWebsiteIds?.[0]; - const websiteId = boardWebsiteId || fallbackWebsiteId; + const boardEntity = getBoardEntity(board); + const fallbackEntity = getFirstBoardComponentEntity(board); + const entityType = boardEntity.entityType || fallbackEntity.entityType; + const entityId = boardEntity.entityId || fallbackEntity.entityId; + + if (!entityId) { + return null; + } return ( - + {entityType === BOARD_ENTITY_TYPES.pixel ? ( + + ) : entityType === BOARD_ENTITY_TYPES.link ? ( + + ) : ( + + )} ); } diff --git a/src/app/(main)/boards/[boardId]/BoardEditBody.tsx b/src/app/(main)/boards/[boardId]/BoardEditBody.tsx index 7586ac67f..ad94800dd 100644 --- a/src/app/(main)/boards/[boardId]/BoardEditBody.tsx +++ b/src/app/(main)/boards/[boardId]/BoardEditBody.tsx @@ -5,6 +5,7 @@ import { Group, type GroupImperativeHandle, Panel, Separator } from 'react-resiz import { v4 as uuid } from 'uuid'; import { useBoard } from '@/components/hooks'; import { GripHorizontal, Plus } from '@/components/icons'; +import { getBoardEntity, getBoardType, requiresBoardEntity } from '@/lib/boards'; import { BoardEditRow } from './BoardEditRow'; import { BUTTON_ROW_HEIGHT, MAX_ROW_HEIGHT, MIN_ROW_HEIGHT } from './boardConstants'; @@ -102,8 +103,9 @@ export function BoardEditBody({ requiresBoardWebsite = true }: { requiresBoardWe }); }; - const websiteId = board?.parameters?.websiteId; - const canEdit = requiresBoardWebsite ? !!websiteId : true; + const boardType = getBoardType(board); + const { entityId } = getBoardEntity(board); + const canEdit = requiresBoardWebsite ? !requiresBoardEntity(boardType) || !!entityId : true; const rows = board?.parameters?.rows ?? []; const minHeight = (rows.length || 1) * MAX_ROW_HEIGHT + BUTTON_ROW_HEIGHT; diff --git a/src/app/(main)/boards/[boardId]/BoardEditColumn.tsx b/src/app/(main)/boards/[boardId]/BoardEditColumn.tsx index f43f7a8c1..4ea0656b3 100644 --- a/src/app/(main)/boards/[boardId]/BoardEditColumn.tsx +++ b/src/app/(main)/boards/[boardId]/BoardEditColumn.tsx @@ -13,7 +13,9 @@ import { useMemo, useState } from 'react'; import { Panel } from '@/components/common/Panel'; import { useBoard, useMessages, useNavigation } from '@/components/hooks'; import { Pencil, Plus, X } from '@/components/icons'; +import { getBoardEntity, getBoardType, getResolvedComponentEntity } from '@/lib/boards'; import type { BoardComponentConfig } from '@/lib/types'; +import { getComponentDefinition } from '../boardComponentRegistry'; import { BoardComponentRenderer } from './BoardComponentRenderer'; import { BoardComponentSelect } from './BoardComponentSelect'; @@ -37,15 +39,17 @@ export function BoardEditColumn({ const { board } = useBoard(); const { t, labels } = useMessages(); const { teamId } = useNavigation(); - const boardWebsiteId = board?.parameters?.websiteId; - const websiteId = component?.websiteId || boardWebsiteId; + const boardType = getBoardType(board); + const { entityType: boardEntityType, entityId: boardEntityId } = getBoardEntity(board); + const definition = component ? getComponentDefinition(component.type) : undefined; + const { entityId } = getResolvedComponentEntity(board, component); const renderedComponent = useMemo(() => { - if (!component || !websiteId) { + if (!component || (!entityId && definition?.requiresWebsite !== false)) { return null; } - return ; - }, [component, websiteId]); + return ; + }, [component, definition?.requiresWebsite, entityId]); const handleSelect = (config: BoardComponentConfig) => { onSetComponent(id, config); @@ -129,8 +133,9 @@ export function BoardEditColumn({ {() => ( setShowSelect(false)} diff --git a/src/app/(main)/boards/[boardId]/BoardEditForm.tsx b/src/app/(main)/boards/[boardId]/BoardEditForm.tsx index dc297239e..8a9ce75db 100644 --- a/src/app/(main)/boards/[boardId]/BoardEditForm.tsx +++ b/src/app/(main)/boards/[boardId]/BoardEditForm.tsx @@ -1,12 +1,24 @@ -import { Form, FormField, TextField } from '@umami/react-zen'; +import { Box, Form, FormField, ListItem, Row, Select, TextField } from '@umami/react-zen'; import { Panel } from '@/components/common/Panel'; import { useBoard, useMessages, useNavigation } from '@/components/hooks'; +import { LinkSelect } from '@/components/input/LinkSelect'; +import { PixelSelect } from '@/components/input/PixelSelect'; import { WebsiteSelect } from '@/components/input/WebsiteSelect'; +import { + BOARD_TYPES, + type BoardType, + getBoardEntity, + getBoardType, + requiresBoardEntity, + setBoardEntity, +} from '@/lib/boards'; export function BoardEditForm() { const { board, updateBoard, saveBoard } = useBoard(); const { t, labels } = useMessages(); const { teamId } = useNavigation(); + const boardType = getBoardType(board, { coerceDashboard: true }); + const { entityId } = getBoardEntity(board); const handleNameChange = (name: string) => { updateBoard({ name }); @@ -16,52 +28,123 @@ export function BoardEditForm() { updateBoard({ description }); }; - const handleWebsiteChange = (websiteId: string) => { + const handleTypeChange = (type: string) => { updateBoard({ - parameters: { - ...board.parameters, - websiteId, - }, + type, + parameters: setBoardEntity(board.parameters, type as BoardType), }); }; + const handleEntityChange = (nextEntityId: string) => { + updateBoard({ + parameters: setBoardEntity(board.parameters, boardType, nextEntityId), + }); + }; + + const renderEntitySelect = () => { + if (boardType === BOARD_TYPES.website) { + return ( + + ); + } + + if (boardType === BOARD_TYPES.pixel) { + return ( + + ); + } + + if (boardType === BOARD_TYPES.link) { + return ( + + ); + } + + return null; + }; + + const entityLabel = + boardType === BOARD_TYPES.pixel + ? t(labels.pixel) + : boardType === BOARD_TYPES.link + ? t(labels.link) + : t(labels.website); + return ( - -
- - - - - - - - - -
-
+ + +
+ + + + + + + + + + + + {requiresBoardEntity(boardType) && ( + + + {renderEntitySelect()} + + + )} +
+
+
); } diff --git a/src/app/(main)/boards/[boardId]/BoardViewColumn.tsx b/src/app/(main)/boards/[boardId]/BoardViewColumn.tsx index 65df4fdca..2f17aec22 100644 --- a/src/app/(main)/boards/[boardId]/BoardViewColumn.tsx +++ b/src/app/(main)/boards/[boardId]/BoardViewColumn.tsx @@ -1,6 +1,7 @@ import { Box, Column } from '@umami/react-zen'; import { Panel } from '@/components/common/Panel'; import { useBoard } from '@/components/hooks'; +import { getResolvedComponentEntity } from '@/lib/boards'; import type { BoardComponentConfig } from '@/lib/types'; import { getComponentDefinition } from '../boardComponentRegistry'; import { BoardComponentRenderer } from './BoardComponentRenderer'; @@ -8,9 +9,9 @@ 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; + const { entityId } = getResolvedComponentEntity(board, component); - if (!component || (!websiteId && definition?.requiresWebsite !== false)) { + if (!component || (!entityId && definition?.requiresWebsite !== false)) { return null; } @@ -21,7 +22,7 @@ export function BoardViewColumn({ component }: { component?: BoardComponentConfi - + diff --git a/src/app/(main)/dashboard/DashboardProvider.tsx b/src/app/(main)/dashboard/DashboardProvider.tsx index 96e4aacf5..8d8ffb721 100644 --- a/src/app/(main)/dashboard/DashboardProvider.tsx +++ b/src/app/(main)/dashboard/DashboardProvider.tsx @@ -5,9 +5,11 @@ import { v4 as uuid } from 'uuid'; import { BoardContext, type LayoutGetter } from '@/app/(main)/boards/BoardProvider'; import { getComponentDefinition } from '@/app/(main)/boards/boardComponentRegistry'; import { useApi, useDashboardQuery, useMessages, useModified } from '@/components/hooks'; +import { BOARD_TYPES } from '@/lib/boards'; import type { Board, BoardParameters } from '@/lib/types'; const createDefaultBoard = (): Partial => ({ + type: BOARD_TYPES.dashboard, name: '', description: '', parameters: { diff --git a/src/app/api/boards/[boardId]/route.ts b/src/app/api/boards/[boardId]/route.ts index 24bae4dcd..3ae623d9b 100644 --- a/src/app/api/boards/[boardId]/route.ts +++ b/src/app/api/boards/[boardId]/route.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { BOARD_TYPES } from '@/lib/boards'; import { parseRequest } from '@/lib/request'; import { badRequest, json, ok, serverError, unauthorized } from '@/lib/response'; import { canDeleteBoard, canUpdateBoard, canViewBoard } from '@/permissions'; @@ -24,6 +25,15 @@ export async function GET(request: Request, { params }: { params: Promise<{ boar export async function POST(request: Request, { params }: { params: Promise<{ boardId: string }> }) { const schema = z.object({ + type: z + .enum([ + BOARD_TYPES.dashboard, + BOARD_TYPES.open, + BOARD_TYPES.website, + BOARD_TYPES.pixel, + BOARD_TYPES.link, + ]) + .optional(), name: z.string().optional(), description: z.string().optional(), parameters: z.object({}).passthrough().optional(), @@ -36,14 +46,14 @@ export async function POST(request: Request, { params }: { params: Promise<{ boa } const { boardId } = await params; - const { name, description, parameters } = body; + const { type, name, description, parameters } = body; if (!(await canUpdateBoard(auth, boardId))) { return unauthorized(); } try { - const board = await updateBoard(boardId, { name, description, parameters }); + const board = await updateBoard(boardId, { type, name, description, parameters }); return Response.json(board); } catch (e: any) { diff --git a/src/app/api/boards/route.ts b/src/app/api/boards/route.ts index dd81106a2..973db88e9 100644 --- a/src/app/api/boards/route.ts +++ b/src/app/api/boards/route.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { BOARD_TYPES } from '@/lib/boards'; import { uuid } from '@/lib/crypto'; import { getQueryFilters, parseRequest } from '@/lib/request'; import { json, unauthorized } from '@/lib/response'; @@ -27,13 +28,20 @@ export async function GET(request: Request) { export async function POST(request: Request) { const schema = z.object({ - type: z.string(), + type: z.enum([BOARD_TYPES.open, BOARD_TYPES.website, BOARD_TYPES.pixel, BOARD_TYPES.link]), name: z.string().max(100), description: z.string().max(500).optional(), slug: z.string().max(100), userId: z.uuid().nullable().optional(), teamId: z.uuid().nullable().optional(), - parameters: z.object({ websiteId: z.uuid().optional() }).passthrough().optional(), + parameters: z + .object({ + websiteId: z.uuid().optional(), + pixelId: z.uuid().optional(), + linkId: z.uuid().optional(), + }) + .passthrough() + .optional(), }); const { auth, body, error } = await parseRequest(request, schema); diff --git a/src/components/input/LinkSelect.tsx b/src/components/input/LinkSelect.tsx index 665a1a94f..34c864e08 100644 --- a/src/components/input/LinkSelect.tsx +++ b/src/components/input/LinkSelect.tsx @@ -17,7 +17,7 @@ export function LinkSelect({ teamId?: string; isCollapsed?: boolean; } & SelectProps) { - const { t, messages } = useMessages(); + const { t, labels, messages } = useMessages(); const { data: link } = useLinkQuery(linkId); const [name, setName] = useState(link?.name); const [search, setSearch] = useState(''); @@ -42,12 +42,16 @@ export function LinkSelect({ return ''; } + const value = name || props.placeholder || t(labels.link); + return ( - {name} + + {value} + ); }; diff --git a/src/components/input/PixelSelect.tsx b/src/components/input/PixelSelect.tsx index b12c5aa46..5ad68f307 100644 --- a/src/components/input/PixelSelect.tsx +++ b/src/components/input/PixelSelect.tsx @@ -17,7 +17,7 @@ export function PixelSelect({ teamId?: string; isCollapsed?: boolean; } & SelectProps) { - const { t, messages } = useMessages(); + const { t, labels, messages } = useMessages(); const { data: pixel } = usePixelQuery(pixelId); const [name, setName] = useState(pixel?.name); const [search, setSearch] = useState(''); @@ -42,12 +42,16 @@ export function PixelSelect({ return ''; } + const value = name || props.placeholder || t(labels.pixel); + return ( - {name} + + {value} + ); }; diff --git a/src/components/messages.ts b/src/components/messages.ts index 536e920de..9d69bebcc 100644 --- a/src/components/messages.ts +++ b/src/components/messages.ts @@ -19,6 +19,7 @@ export const labels: Record = { admin: 'label.admin', confirm: 'label.confirm', details: 'label.details', + boardType: 'label.board-type', website: 'label.website', websites: 'label.websites', myWebsites: 'label.my-websites', @@ -75,6 +76,7 @@ export const labels: Record = { profile: 'label.profile', profiles: 'label.profiles', dashboard: 'label.dashboard', + open: 'label.open', more: 'label.more', realtime: 'label.realtime', queries: 'label.queries', @@ -133,6 +135,8 @@ export const labels: Record = { allTime: 'label.all-time', customRange: 'label.custom-range', selectWebsite: 'label.select-website', + selectLink: 'label.select-link', + selectPixel: 'label.select-pixel', selectRole: 'label.select-role', selectDate: 'label.select-date', selectFilter: 'label.select-filter', @@ -402,6 +406,7 @@ export const messages: Record = { emptyDashboard: 'message.empty-dashboard', selectComponentPreview: 'message.select-component-preview', selectWebsiteFirst: 'message.select-website-first', + selectBoardEntityFirst: 'message.select-board-entity-first', teamWebsitesInfo: 'message.team-websites-info', noMatchPassword: 'message.no-match-password', goToSettings: 'message.go-to-settings', diff --git a/src/lib/boards.ts b/src/lib/boards.ts new file mode 100644 index 000000000..9626661e4 --- /dev/null +++ b/src/lib/boards.ts @@ -0,0 +1,171 @@ +import type { Board, BoardComponentConfig, BoardParameters } from './types'; + +export const BOARD_TYPES = { + dashboard: 'dashboard', + open: 'open', + website: 'website', + pixel: 'pixel', + link: 'link', +} as const; + +export const BOARD_ENTITY_TYPES = { + website: 'website', + pixel: 'pixel', + link: 'link', +} as const; + +export type BoardType = (typeof BOARD_TYPES)[keyof typeof BOARD_TYPES]; +export type BoardEntityType = (typeof BOARD_ENTITY_TYPES)[keyof typeof BOARD_ENTITY_TYPES]; + +const boardTypes = new Set(Object.values(BOARD_TYPES)); + +export function getLegacyBoardType(parameters?: BoardParameters): BoardType { + if (parameters?.pixelId) { + return BOARD_TYPES.pixel; + } + + if (parameters?.linkId) { + return BOARD_TYPES.link; + } + + if (parameters?.websiteId) { + return BOARD_TYPES.website; + } + + return BOARD_TYPES.open; +} + +export function getBoardType( + board?: Pick | Partial, + { coerceDashboard = false }: { coerceDashboard?: boolean } = {}, +): BoardType { + const type = board?.type; + + if (type === BOARD_TYPES.dashboard && coerceDashboard) { + return getLegacyBoardType(board?.parameters); + } + + if (type && boardTypes.has(type)) { + return type as BoardType; + } + + return getLegacyBoardType(board?.parameters); +} + +export function isOpenBoardType(type?: string) { + return type === BOARD_TYPES.open || type === BOARD_TYPES.dashboard; +} + +export function requiresBoardEntity(type?: string) { + return ( + type === BOARD_TYPES.website || type === BOARD_TYPES.pixel || type === BOARD_TYPES.link + ); +} + +export function getBoardEntity(board?: Pick | Partial): { + entityType?: BoardEntityType; + entityId?: string; +} { + const type = getBoardType(board); + + if (type === BOARD_TYPES.website) { + return { + entityType: BOARD_ENTITY_TYPES.website, + entityId: board?.parameters?.websiteId, + }; + } + + if (type === BOARD_TYPES.pixel) { + return { + entityType: BOARD_ENTITY_TYPES.pixel, + entityId: board?.parameters?.pixelId, + }; + } + + if (type === BOARD_TYPES.link) { + return { + entityType: BOARD_ENTITY_TYPES.link, + entityId: board?.parameters?.linkId, + }; + } + + return {}; +} + +export function getComponentEntity(config?: BoardComponentConfig): { + entityType?: BoardEntityType; + entityId?: string; +} { + if (config?.entityType && config?.entityId) { + return { + entityType: config.entityType, + entityId: config.entityId, + }; + } + + if (config?.websiteId) { + return { + entityType: BOARD_ENTITY_TYPES.website, + entityId: config.websiteId, + }; + } + + return {}; +} + +export function getResolvedComponentEntity( + board?: Pick | Partial, + config?: BoardComponentConfig, +) { + const boardEntity = getBoardEntity(board); + + if (boardEntity.entityId) { + return boardEntity; + } + + return getComponentEntity(config); +} + +export function getFirstBoardComponentEntity( + board?: Pick | Partial, +) { + for (const row of board?.parameters?.rows ?? []) { + for (const column of row.columns ?? []) { + const entity = getComponentEntity(column.component); + + if (entity.entityId) { + return entity; + } + } + } + + return {}; +} + +export function clearBoardEntity(parameters: BoardParameters = {}): BoardParameters { + const { websiteId, pixelId, linkId, ...rest } = parameters; + + return rest; +} + +export function setBoardEntity( + parameters: BoardParameters = {}, + type: BoardType, + entityId?: string, +): BoardParameters { + const next = clearBoardEntity(parameters); + + if (type === BOARD_TYPES.website && entityId) { + next.websiteId = entityId; + } + + if (type === BOARD_TYPES.pixel && entityId) { + next.pixelId = entityId; + } + + if (type === BOARD_TYPES.link && entityId) { + next.linkId = entityId; + } + + return next; +} diff --git a/src/lib/types.ts b/src/lib/types.ts index 039e7f8da..eb39e5021 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -150,6 +150,8 @@ export interface ApiError extends Error { export interface BoardComponentConfig { type: string; + entityType?: 'website' | 'pixel' | 'link'; + entityId?: string; websiteId?: string; title?: string; description?: string; @@ -170,6 +172,8 @@ export interface BoardRow { export interface BoardParameters { websiteId?: string; + pixelId?: string; + linkId?: string; rows?: BoardRow[]; }