Add board type binding support

This commit is contained in:
Mike Cao
2026-03-06 23:54:46 -08:00
parent 07ff25bf7e
commit f76b22b303
16 changed files with 496 additions and 99 deletions
+5
View File
@@ -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",
+9 -1
View File
@@ -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<BoardContextValue>(null);
const createDefaultBoard = (): Partial<Board> => ({
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,
});
},
});
@@ -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<ComponentDefinition | null>(null);
const [configValues, setConfigValues] = useState<Record<string, any>>({});
const [selectedWebsiteId, setSelectedWebsiteId] = useState(
initialConfig?.websiteId || websiteId || defaultWebsiteId,
const [selectedEntityType, setSelectedEntityType] = useState<BoardEntityType>(
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<string, any> = {};
@@ -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 (
<Column gap="4">
@@ -186,14 +229,14 @@ export function BoardComponentSelect({
<Column gap="3" flexGrow={1} style={{ minWidth: 0 }}>
<Panel maxHeight="100%">
{previewConfig && (!needsWebsite || selectedWebsiteId) ? (
<BoardComponentRenderer config={previewConfig} websiteId={selectedWebsiteId} />
{previewConfig && (!needsWebsite || resolvedEntityId) ? (
<BoardComponentRenderer config={previewConfig} websiteId={resolvedEntityId} />
) : (
<Column alignItems="center" justifyContent="center" height="100%">
<Text color="muted">
{selectedWebsiteId
{resolvedEntityId
? t(messages.selectComponentPreview)
: t(messages.selectWebsiteFirst)}
: t(messages.selectBoardEntityFirst)}
</Text>
</Column>
)}
@@ -203,18 +246,50 @@ export function BoardComponentSelect({
<Column gap="3" style={{ width: 320, flexShrink: 0, overflowY: 'auto' }}>
<Text weight="bold">{t(labels.properties)}</Text>
{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>
{needsWebsite && isOpenType && (
<>
<Column gap="2">
<Text size="sm" color="muted">
{t(labels.type)}
</Text>
<Select value={selectedEntityType} onChange={handleEntityTypeChange}>
<ListItem id={BOARD_ENTITY_TYPES.website}>{t(labels.website)}</ListItem>
<ListItem id={BOARD_ENTITY_TYPES.pixel}>{t(labels.pixel)}</ListItem>
<ListItem id={BOARD_ENTITY_TYPES.link}>{t(labels.link)}</ListItem>
</Select>
</Column>
<Column gap="2">
<Text size="sm" color="muted">
{selectedEntityType === BOARD_ENTITY_TYPES.pixel
? t(labels.pixel)
: selectedEntityType === BOARD_ENTITY_TYPES.link
? t(labels.link)
: t(labels.website)}
</Text>
{selectedEntityType === BOARD_ENTITY_TYPES.pixel ? (
<PixelSelect
pixelId={selectedEntityId}
teamId={teamId}
placeholder={t(labels.selectPixel)}
onChange={setSelectedEntityId}
/>
) : selectedEntityType === BOARD_ENTITY_TYPES.link ? (
<LinkSelect
linkId={selectedEntityId}
teamId={teamId}
placeholder={t(labels.selectLink)}
onChange={setSelectedEntityId}
/>
) : (
<WebsiteSelect
websiteId={selectedEntityId}
teamId={teamId}
placeholder={t(labels.selectWebsite)}
onChange={setSelectedEntityId}
/>
)}
</Column>
</>
)}
<Column gap="2">
@@ -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 (
<Box marginBottom="4">
<WebsiteControls websiteId={websiteId} />
{entityType === BOARD_ENTITY_TYPES.pixel ? (
<PixelControls pixelId={entityId} />
) : entityType === BOARD_ENTITY_TYPES.link ? (
<LinkControls linkId={entityId} />
) : (
<WebsiteControls websiteId={entityId} />
)}
</Box>
);
}
@@ -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;
@@ -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 <BoardComponentRenderer config={component} websiteId={websiteId} />;
}, [component, websiteId]);
return <BoardComponentRenderer config={component} websiteId={entityId} />;
}, [component, definition?.requiresWebsite, entityId]);
const handleSelect = (config: BoardComponentConfig) => {
onSetComponent(id, config);
@@ -129,8 +133,9 @@ export function BoardEditColumn({
{() => (
<BoardComponentSelect
teamId={teamId}
websiteId={websiteId}
defaultWebsiteId={boardWebsiteId}
boardType={boardType}
boardEntityType={boardEntityType}
boardEntityId={boardEntityId}
initialConfig={component}
onSelect={handleSelect}
onClose={() => setShowSelect(false)}
+126 -43
View File
@@ -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 (
<WebsiteSelect
websiteId={entityId}
teamId={teamId}
onChange={handleEntityChange}
width="100%"
/>
);
}
if (boardType === BOARD_TYPES.pixel) {
return (
<PixelSelect
pixelId={entityId}
teamId={teamId}
placeholder={t(labels.selectPixel)}
onChange={handleEntityChange}
width="100%"
/>
);
}
if (boardType === BOARD_TYPES.link) {
return (
<LinkSelect
linkId={entityId}
teamId={teamId}
placeholder={t(labels.selectLink)}
onChange={handleEntityChange}
width="100%"
/>
);
}
return null;
};
const entityLabel =
boardType === BOARD_TYPES.pixel
? t(labels.pixel)
: boardType === BOARD_TYPES.link
? t(labels.link)
: t(labels.website);
return (
<Panel title={t(labels.details)} marginBottom="6">
<Form
onSubmit={saveBoard}
values={{
name: board?.name ?? '',
description: board?.description ?? '',
websiteId: board?.parameters?.websiteId ?? '',
}}
>
<FormField name="name" label={t(labels.name)} rules={{ required: t(labels.required) }}>
<TextField
autoComplete="off"
autoFocus={!board?.id}
value={board?.name ?? ''}
placeholder={t(labels.untitled)}
onChange={handleNameChange}
/>
</FormField>
<FormField name="description" label={t(labels.description)}>
<TextField
autoComplete="off"
asTextArea
resize="vertical"
value={board?.description ?? ''}
placeholder={t(labels.addDescription)}
onChange={handleDescriptionChange}
/>
</FormField>
<FormField name="websiteId" label={t(labels.website)}>
<WebsiteSelect
websiteId={board?.parameters?.websiteId}
teamId={teamId}
onChange={handleWebsiteChange}
/>
</FormField>
</Form>
</Panel>
<Row width="100%" justifyContent="center">
<Panel width="100%" maxWidth="600px" marginBottom="6">
<Form
onSubmit={saveBoard}
values={{
name: board?.name ?? '',
description: board?.description ?? '',
type: boardType,
entityId: entityId ?? '',
}}
>
<FormField name="name" label={t(labels.name)} rules={{ required: t(labels.required) }}>
<TextField
autoComplete="off"
autoFocus={!board?.id}
value={board?.name ?? ''}
placeholder={t(labels.untitled)}
onChange={handleNameChange}
/>
</FormField>
<FormField name="description" label={t(labels.description)}>
<TextField
autoComplete="off"
asTextArea
resize="vertical"
value={board?.description ?? ''}
placeholder={t(labels.addDescription)}
onChange={handleDescriptionChange}
/>
</FormField>
<FormField
name="type"
label={t(labels.boardType)}
rules={{ required: t(labels.required) }}
>
<Box width="100%" maxWidth="360px">
<Select value={boardType} onChange={handleTypeChange} width="100%">
<ListItem id={BOARD_TYPES.open}>{t(labels.open)}</ListItem>
<ListItem id={BOARD_TYPES.website}>{t(labels.website)}</ListItem>
<ListItem id={BOARD_TYPES.pixel}>{t(labels.pixel)}</ListItem>
<ListItem id={BOARD_TYPES.link}>{t(labels.link)}</ListItem>
</Select>
</Box>
</FormField>
{requiresBoardEntity(boardType) && (
<FormField
name="entityId"
label={entityLabel}
rules={{ required: t(labels.required) }}
>
<Box width="100%" maxWidth="360px">
{renderEntitySelect()}
</Box>
</FormField>
)}
</Form>
</Panel>
</Row>
);
}
@@ -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
<Panel title={title} description={description} height="100%">
<Column width="100%" height="100%" style={{ minHeight: 0 }}>
<Box width="100%" flexGrow={1} style={{ minHeight: 0 }}>
<BoardComponentRenderer config={component} websiteId={websiteId} />
<BoardComponentRenderer config={component} websiteId={entityId} />
</Box>
</Column>
</Panel>
@@ -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<Board> => ({
type: BOARD_TYPES.dashboard,
name: '',
description: '',
parameters: {
+12 -2
View File
@@ -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) {
+10 -2
View File
@@ -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);
+6 -2
View File
@@ -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<string>(link?.name);
const [search, setSearch] = useState('');
@@ -42,12 +42,16 @@ export function LinkSelect({
return '';
}
const value = name || props.placeholder || t(labels.link);
return (
<Row alignItems="center" gap>
<Icon>
<Link />
</Icon>
<Text truncate>{name}</Text>
<Text truncate color={name ? undefined : 'muted'}>
{value}
</Text>
</Row>
);
};
+6 -2
View File
@@ -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<string>(pixel?.name);
const [search, setSearch] = useState('');
@@ -42,12 +42,16 @@ export function PixelSelect({
return '';
}
const value = name || props.placeholder || t(labels.pixel);
return (
<Row alignItems="center" gap>
<Icon>
<Grid2x2 />
</Icon>
<Text truncate>{name}</Text>
<Text truncate color={name ? undefined : 'muted'}>
{value}
</Text>
</Row>
);
};
+5
View File
@@ -19,6 +19,7 @@ export const labels: Record<string, string> = {
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<string, string> = {
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<string, string> = {
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<string, string> = {
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',
+171
View File
@@ -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<string>(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<Board, 'type' | 'parameters'> | Partial<Board>,
{ 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<Board, 'type' | 'parameters'> | Partial<Board>): {
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<Board, 'type' | 'parameters'> | Partial<Board>,
config?: BoardComponentConfig,
) {
const boardEntity = getBoardEntity(board);
if (boardEntity.entityId) {
return boardEntity;
}
return getComponentEntity(config);
}
export function getFirstBoardComponentEntity(
board?: Pick<Board, 'type' | 'parameters'> | Partial<Board>,
) {
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;
}
+4
View File
@@ -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[];
}