Refine edit layouts and share states
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user