Refine edit layouts and share states

This commit is contained in:
Mike Cao
2026-03-10 02:52:13 -07:00
parent cdcb1f379b
commit 6b8159e535
9 changed files with 164 additions and 153 deletions
+93 -111
View File
@@ -10,7 +10,6 @@ import {
Select,
TextField,
} from '@umami/react-zen';
import { useEffect, useState } from 'react';
import { useBoardQuery, useMessages, useNavigation, useUpdateQuery } from '@/components/hooks';
import { LinkSelect } from '@/components/input/LinkSelect';
import { PixelSelect } from '@/components/input/PixelSelect';
@@ -58,7 +57,7 @@ export function BoardEditForm({
const { t, labels, messages, getErrorMessage } = useMessages();
const { teamId: navigationTeamId } = useNavigation();
const resolvedTeamId = teamId ?? navigationTeamId;
const { data, isLoading } = useBoardQuery(boardId || '');
const { data: board, isLoading } = useBoardQuery(boardId || '');
const { mutateAsync, error, isPending, touch, toast } = useUpdateQuery(
boardId ? `/boards/${boardId}` : '/boards',
{
@@ -66,20 +65,14 @@ export function BoardEditForm({
teamId: resolvedTeamId,
},
);
const [values, setValues] = useState<BoardFormValues>(getDefaultValues());
const values = getDefaultValues(board);
useEffect(() => {
if (data) {
setValues(getDefaultValues(data));
}
}, [data]);
const handleSubmit = async () => {
const handleSubmit = async (data: BoardFormValues) => {
const result = await mutateAsync({
name: values.name,
description: values.description,
type: values.type,
parameters: setBoardEntity({}, values.type, values.entityId || undefined),
name: data.name,
description: data.description,
type: data.type,
parameters: setBoardEntity(board?.parameters, data.type, data.entityId || undefined),
});
toast(t(messages.saved));
@@ -89,111 +82,100 @@ export function BoardEditForm({
onClose?.();
};
const handleNameChange = (name: string) => {
setValues(current => ({ ...current, name }));
};
const handleDescriptionChange = (description: string) => {
setValues(current => ({ ...current, description }));
};
const handleTypeChange = (type: string) => {
setValues(current => ({
...current,
type: type as BoardType,
entityId: '',
}));
};
const handleEntityChange = (entityId: string) => {
setValues(current => ({ ...current, entityId }));
};
if (boardId && isLoading) {
return <Loading placement="absolute" />;
}
const entityLabel =
values.type === BOARD_TYPES.pixel
? t(labels.pixel)
: values.type === BOARD_TYPES.link
? t(labels.link)
: t(labels.website);
return (
<Form onSubmit={handleSubmit} error={getErrorMessage(error)} values={values}>
<FormField name="name" label={t(labels.name)} rules={{ required: t(labels.required) }}>
<TextField
autoComplete="off"
autoFocus={!boardId}
value={values.name}
placeholder={t(labels.untitled)}
onChange={handleNameChange}
/>
</FormField>
<FormField name="description" label={t(labels.description)}>
<TextField
autoComplete="off"
asTextArea
resize="vertical"
value={values.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={values.type} onChange={handleTypeChange}>
<ListItem id={BOARD_TYPES.mixed}>{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(values.type) && (
<FormField
name="entityId"
label={entityLabel}
rules={{ required: t(labels.required) }}
>
<Box width="100%" maxWidth="360px">
{values.type === BOARD_TYPES.website ? (
<WebsiteSelect
websiteId={values.entityId}
teamId={resolvedTeamId}
onChange={handleEntityChange}
/>
) : values.type === BOARD_TYPES.pixel ? (
<PixelSelect
pixelId={values.entityId}
teamId={resolvedTeamId}
placeholder={t(labels.selectPixel)}
onChange={handleEntityChange}
/>
) : (
<LinkSelect
linkId={values.entityId}
teamId={resolvedTeamId}
placeholder={t(labels.selectLink)}
onChange={handleEntityChange}
{({ watch, setValue }) => {
const type = watch('type') as BoardType;
const entityId = watch('entityId') as string;
const entityLabel =
type === BOARD_TYPES.pixel
? t(labels.pixel)
: type === BOARD_TYPES.link
? t(labels.link)
: t(labels.website);
const handleTypeChange = (value: string) => {
setValue('type', value as BoardType, { shouldDirty: true });
setValue('entityId', '', { shouldDirty: true });
};
const handleEntityChange = (value: string) => {
setValue('entityId', value, { shouldDirty: true });
};
return (
<>
<FormField name="name" label={t(labels.name)} rules={{ required: t(labels.required) }}>
<TextField autoComplete="off" autoFocus={!boardId} placeholder={t(labels.untitled)} />
</FormField>
<FormField name="description" label={t(labels.description)}>
<TextField
autoComplete="off"
asTextArea
resize="vertical"
placeholder={t(labels.addDescription)}
/>
</FormField>
<FormField
name="type"
label={t(labels.boardType)}
rules={{ required: t(labels.required) }}
>
<Box width="100%" maxWidth="360px">
<Select value={type} onChange={handleTypeChange}>
<ListItem id={BOARD_TYPES.mixed}>{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(type) && (
<FormField
name="entityId"
label={entityLabel}
rules={{ required: t(labels.required) }}
>
<Box width="100%" maxWidth="360px">
{type === BOARD_TYPES.website ? (
<WebsiteSelect
websiteId={entityId}
teamId={resolvedTeamId}
onChange={handleEntityChange}
/>
) : type === BOARD_TYPES.pixel ? (
<PixelSelect
pixelId={entityId}
teamId={resolvedTeamId}
placeholder={t(labels.selectPixel)}
onChange={handleEntityChange}
/>
) : (
<LinkSelect
linkId={entityId}
teamId={resolvedTeamId}
placeholder={t(labels.selectLink)}
onChange={handleEntityChange}
/>
)}
</Box>
</FormField>
)}
</Box>
</FormField>
)}
<Row justifyContent="flex-end" paddingTop="3" gap="3">
{onClose && (
<Button isDisabled={isPending} onPress={onClose}>
{t(labels.cancel)}
</Button>
)}
<FormSubmitButton isDisabled={isPending}>{t(labels.save)}</FormSubmitButton>
</Row>
<Row justifyContent="flex-end" paddingTop="3" gap="3">
{onClose && (
<Button isDisabled={isPending} onPress={onClose}>
{t(labels.cancel)}
</Button>
)}
<FormSubmitButton isDisabled={isPending}>{t(labels.save)}</FormSubmitButton>
</Row>
</>
);
}}
</Form>
);
}
@@ -30,13 +30,13 @@ function BoardShareDialogContent({
}) {
const { t, labels, messages } = useMessages();
const [isCreating, setIsCreating] = useState(false);
const showCreateForm = !hasShares || isCreating;
const showCreateForm = isCreating;
return (
<Column gap="4">
<Row justifyContent="space-between" alignItems="center">
<Heading>{t(labels.share)}</Heading>
{hasShares && !isCreating && (
{!isCreating && (
<Button variant="primary" onPress={() => setIsCreating(true)}>
<IconLabel icon={<Plus size={16} />} label={t(labels.add)} />
</Button>
@@ -46,17 +46,18 @@ function BoardShareDialogContent({
<BoardShareCreateForm
boardId={boardId}
onSave={() => setIsCreating(false)}
onCancel={hasShares ? () => setIsCreating(false) : undefined}
onCancel={() => setIsCreating(false)}
/>
)}
{hasShares ? (
<>
<Text>{t(messages.shareUrl)}</Text>
<BoardSharesTable data={shares} />
</>
) : (
!showCreateForm && <Text color="muted">{t(messages.noDataAvailable)}</Text>
{hasShares && (
<Text>{t(messages.shareUrl)}</Text>
)}
{!showCreateForm &&
(hasShares ? (
<BoardSharesTable data={shares} />
) : (
<Text color="muted">{t(messages.noDataAvailable)}</Text>
))}
</Column>
);
}
@@ -20,7 +20,8 @@ export function BoardEditPage({ boardId }: { boardId: string }) {
<Column
margin="2"
width="100%"
style={{ minWidth: 'min(760px, 100%)', marginInline: 'auto' }}
maxWidth="800px"
style={{ marginInline: 'auto' }}
>
<>
<Column marginTop="6">
+11 -10
View File
@@ -30,13 +30,13 @@ function LinkShareFormContent({
}) {
const { t, labels, messages } = useMessages();
const [isCreating, setIsCreating] = useState(false);
const showCreateForm = !hasShares || isCreating;
const showCreateForm = isCreating;
return (
<Column gap="4">
<Row justifyContent="space-between" alignItems="center">
<Heading>{t(labels.share)}</Heading>
{hasShares && !isCreating && (
{!isCreating && (
<Button variant="primary" onPress={() => setIsCreating(true)}>
<IconLabel icon={<Plus size={16} />} label={t(labels.add)} />
</Button>
@@ -46,17 +46,18 @@ function LinkShareFormContent({
<SimpleShareCreateForm
createPath={`/links/${linkId}/shares`}
onSave={() => setIsCreating(false)}
onCancel={hasShares ? () => setIsCreating(false) : undefined}
onCancel={() => setIsCreating(false)}
/>
)}
{hasShares ? (
<>
<Text>{t(messages.shareUrl)}</Text>
<SimpleSharesTable data={shares} />
</>
) : (
!showCreateForm && <Text color="muted">{t(messages.noDataAvailable)}</Text>
{hasShares && (
<Text>{t(messages.shareUrl)}</Text>
)}
{!showCreateForm &&
(hasShares ? (
<SimpleSharesTable data={shares} />
) : (
<Text color="muted">{t(messages.noDataAvailable)}</Text>
))}
</Column>
);
}
@@ -30,13 +30,13 @@ function PixelShareFormContent({
}) {
const { t, labels, messages } = useMessages();
const [isCreating, setIsCreating] = useState(false);
const showCreateForm = !hasShares || isCreating;
const showCreateForm = isCreating;
return (
<Column gap="4">
<Row justifyContent="space-between" alignItems="center">
<Heading>{t(labels.share)}</Heading>
{hasShares && !isCreating && (
{!isCreating && (
<Button variant="primary" onPress={() => setIsCreating(true)}>
<IconLabel icon={<Plus size={16} />} label={t(labels.add)} />
</Button>
@@ -46,17 +46,18 @@ function PixelShareFormContent({
<SimpleShareCreateForm
createPath={`/pixels/${pixelId}/shares`}
onSave={() => setIsCreating(false)}
onCancel={hasShares ? () => setIsCreating(false) : undefined}
onCancel={() => setIsCreating(false)}
/>
)}
{hasShares ? (
<>
<Text>{t(messages.shareUrl)}</Text>
<SimpleSharesTable data={shares} />
</>
) : (
!showCreateForm && <Text color="muted">{t(messages.noDataAvailable)}</Text>
{hasShares && (
<Text>{t(messages.shareUrl)}</Text>
)}
{!showCreateForm &&
(hasShares ? (
<SimpleSharesTable data={shares} />
) : (
<Text color="muted">{t(messages.noDataAvailable)}</Text>
))}
</Column>
);
}
@@ -7,7 +7,7 @@ import { WebsiteProvider } from '@/app/(main)/websites/WebsiteProvider';
export function WebsiteSettingsPage({ websiteId }: { websiteId: string }) {
return (
<WebsiteProvider websiteId={websiteId}>
<Column margin="2">
<Column margin="2" width="100%" maxWidth="800px" style={{ marginInline: 'auto' }}>
<WebsiteSettingsHeader />
<WebsiteSettings websiteId={websiteId} />
</Column>
+11
View File
@@ -2,6 +2,10 @@ import {
BOARD_ENTITY_TYPES,
isBoardComponentSupported,
} from '../boards';
import {
BOARD_COMPONENT_COMPATIBILITY_MATRIX,
getSupportedBoardComponentEntityTypes,
} from '../boardComponentCompatibility';
test('isBoardComponentSupported allows events chart on website boards', () => {
expect(isBoardComponentSupported('EventsChart', BOARD_ENTITY_TYPES.website)).toBe(true);
@@ -12,6 +16,13 @@ test('isBoardComponentSupported rejects events chart on pixel and link boards',
expect(isBoardComponentSupported('EventsChart', BOARD_ENTITY_TYPES.link)).toBe(false);
});
test('board component compatibility matrix defines website-only components explicitly', () => {
expect(BOARD_COMPONENT_COMPATIBILITY_MATRIX.EventsChart).toEqual([BOARD_ENTITY_TYPES.website]);
expect(getSupportedBoardComponentEntityTypes('EventsChart')).toEqual([
BOARD_ENTITY_TYPES.website,
]);
});
test('isBoardComponentSupported leaves other components available for all board entities', () => {
expect(isBoardComponentSupported('WebsiteChart', BOARD_ENTITY_TYPES.pixel)).toBe(true);
expect(isBoardComponentSupported('TextBlock', BOARD_ENTITY_TYPES.link)).toBe(true);
+22
View File
@@ -0,0 +1,22 @@
import type { BoardEntityType } from './boards';
export const BOARD_COMPONENT_COMPATIBILITY_MATRIX = {
EventsChart: ['website'],
} as const satisfies Partial<Record<string, readonly BoardEntityType[]>>;
export function getSupportedBoardComponentEntityTypes(componentType: string) {
return BOARD_COMPONENT_COMPATIBILITY_MATRIX[componentType];
}
export function isBoardComponentSupportedByEntityType(
componentType: string,
entityType?: BoardEntityType,
) {
const supportedEntityTypes = getSupportedBoardComponentEntityTypes(componentType);
if (!entityType || !supportedEntityTypes) {
return true;
}
return supportedEntityTypes.includes(entityType);
}
+2 -10
View File
@@ -1,4 +1,5 @@
import type { Board, BoardComponentConfig, BoardParameters } from './types';
import { isBoardComponentSupportedByEntityType } from './boardComponentCompatibility';
export const BOARD_TYPES = {
dashboard: 'dashboard',
@@ -18,9 +19,6 @@ 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));
const boardComponentEntityTypes: Partial<Record<string, readonly BoardEntityType[]>> = {
EventsChart: [BOARD_ENTITY_TYPES.website],
};
export function getLegacyBoardType(parameters?: BoardParameters): BoardType {
if (parameters?.pixelId) {
@@ -147,13 +145,7 @@ export function isBoardComponentSupported(
componentType: string,
entityType?: BoardEntityType,
) {
const supportedEntityTypes = boardComponentEntityTypes[componentType];
if (!entityType || !supportedEntityTypes) {
return true;
}
return supportedEntityTypes.includes(entityType);
return isBoardComponentSupportedByEntityType(componentType, entityType);
}
export function getFirstBoardComponentEntity(