Add board type binding support
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user