feat: add custom field type of image

This commit is contained in:
Carlos Valente
2025-02-19 20:29:13 +01:00
committed by Carlos Valente
parent d0b4c6a279
commit 570ff59cfc
18 changed files with 306 additions and 56 deletions
@@ -0,0 +1,45 @@
import { CustomFields } from 'ontime-types';
import { makeOptionsFromCustomFields } from '../constants';
describe('makeOptionsFromCustomFields', () => {
const testCustomFields: CustomFields = {
field1: { label: 'Field 1', colour: 'red', type: 'string' },
field2: { label: 'Field 2', colour: 'blue', type: 'string' },
};
it('creates a record of keys for the given custom fields', () => {
const result = makeOptionsFromCustomFields(testCustomFields);
expect(result).toStrictEqual({
'custom-field1': 'Custom: Field 1',
'custom-field2': 'Custom: Field 2',
});
});
it('appends additional data', () => {
const additionalData = {
test1: 'test1',
test2: 'test2',
};
const result = makeOptionsFromCustomFields(testCustomFields, additionalData);
expect(result).toStrictEqual({
'custom-field1': 'Custom: Field 1',
'custom-field2': 'Custom: Field 2',
test1: 'test1',
test2: 'test2',
});
});
it('filtersImageTypes', () => {
const customFieldsWIthImage: CustomFields = {
...testCustomFields,
field3: { label: 'Field 3', colour: 'green', type: 'image' },
};
const result = makeOptionsFromCustomFields(customFieldsWIthImage);
expect(result).toStrictEqual({
'custom-field1': 'Custom: Field 1',
'custom-field2': 'Custom: Field 2',
});
});
});
@@ -5,11 +5,17 @@ import type { ParamField } from './types';
export const makeOptionsFromCustomFields = ( export const makeOptionsFromCustomFields = (
customFields: CustomFields, customFields: CustomFields,
additionalOptions: Record<string, string> = {}, additionalOptions: Record<string, string> = {},
filterImageType = true,
) => { ) => {
return Object.entries(customFields).reduce((options, [key, value]) => { const options = structuredClone(additionalOptions);
for (const [key, value] of Object.entries(customFields)) {
if (filterImageType && value.type === 'image') {
continue;
}
options[`custom-${key}`] = `Custom: ${value.label}`; options[`custom-${key}`] = `Custom: ${value.label}`;
return options; }
}, additionalOptions); return options;
}; };
export const getTimeOption = (timeFormat: string): ParamField => { export const getTimeOption = (timeFormat: string): ParamField => {
@@ -6,6 +6,7 @@ import { CustomField, CustomFieldLabel } from 'ontime-types';
import CopyTag from '../../../../../common/components/copy-tag/CopyTag'; import CopyTag from '../../../../../common/components/copy-tag/CopyTag';
import Swatch from '../../../../../common/components/input/colour-input/Swatch'; import Swatch from '../../../../../common/components/input/colour-input/Swatch';
import Tag from '../../../../../common/components/tag/Tag';
import * as Panel from '../../../panel-utils/PanelUtils'; import * as Panel from '../../../panel-utils/PanelUtils';
import CustomFieldForm from './CustomFieldForm'; import CustomFieldForm from './CustomFieldForm';
@@ -13,19 +14,20 @@ import CustomFieldForm from './CustomFieldForm';
import style from '../FeatureSettings.module.scss'; import style from '../FeatureSettings.module.scss';
interface CustomFieldEntryProps { interface CustomFieldEntryProps {
field: string;
colour: string; colour: string;
label: string; label: string;
fieldKey: string;
type: 'string' | 'image';
onEdit: (label: CustomFieldLabel, patch: CustomField) => Promise<void>; onEdit: (label: CustomFieldLabel, patch: CustomField) => Promise<void>;
onDelete: (label: CustomFieldLabel) => Promise<void>; onDelete: (label: CustomFieldLabel) => Promise<void>;
} }
export default function CustomFieldEntry(props: CustomFieldEntryProps) { export default function CustomFieldEntry(props: CustomFieldEntryProps) {
const { colour, label, onEdit, onDelete, field } = props; const { colour, label, fieldKey, type, onEdit, onDelete } = props;
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
const handleEdit = async (patch: CustomField) => { const handleEdit = async (patch: CustomField) => {
await onEdit(field, patch); await onEdit(fieldKey, patch);
setIsEditing(false); setIsEditing(false);
}; };
@@ -38,7 +40,7 @@ export default function CustomFieldEntry(props: CustomFieldEntryProps) {
onSubmit={handleEdit} onSubmit={handleEdit}
initialColour={colour} initialColour={colour}
initialLabel={label} initialLabel={label}
initialKey={field} initialKey={fieldKey}
/> />
</td> </td>
</tr> </tr>
@@ -50,10 +52,13 @@ export default function CustomFieldEntry(props: CustomFieldEntryProps) {
<td> <td>
<Swatch color={colour} /> <Swatch color={colour} />
</td> </td>
<td>
<Tag>{type}</Tag>
</td>
<td className={style.halfWidth}>{label}</td> <td className={style.halfWidth}>{label}</td>
<td className={style.fullWidth}> <td className={style.fullWidth}>
<CopyTag label='Copy key to use in integrations' copyValue={field}> <CopyTag label='Copy key to use in integrations' copyValue={fieldKey}>
{field} {fieldKey}
</CopyTag> </CopyTag>
</td> </td>
<Panel.InlineElements relation='inner' as='td'> <Panel.InlineElements relation='inner' as='td'>
@@ -71,7 +76,7 @@ export default function CustomFieldEntry(props: CustomFieldEntryProps) {
color='#FA5656' // $red-500 color='#FA5656' // $red-500
icon={<IoTrash />} icon={<IoTrash />}
aria-label='Delete entry' aria-label='Delete entry'
onClick={() => onDelete(field)} onClick={() => onDelete(fieldKey)}
/> />
</Panel.InlineElements> </Panel.InlineElements>
</tr> </tr>
@@ -1,10 +1,11 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form'; import { Controller, useForm } from 'react-hook-form';
import { Button, Input } from '@chakra-ui/react'; import { Button, Input, Radio, RadioGroup } from '@chakra-ui/react';
import { CustomField } from 'ontime-types'; import { CustomField } from 'ontime-types';
import { customFieldLabelToKey, isAlphanumericWithSpace } from 'ontime-utils'; import { customFieldLabelToKey, isAlphanumericWithSpace } from 'ontime-utils';
import { maybeAxiosError } from '../../../../../common/api/utils'; import { maybeAxiosError } from '../../../../../common/api/utils';
import Info from '../../../../../common/components/info/Info';
import SwatchSelect from '../../../../../common/components/input/colour-input/SwatchSelect'; import SwatchSelect from '../../../../../common/components/input/colour-input/SwatchSelect';
import useCustomFields from '../../../../../common/hooks-query/useCustomFields'; import useCustomFields from '../../../../../common/hooks-query/useCustomFields';
import { preventEscape } from '../../../../../common/utils/keyEvent'; import { preventEscape } from '../../../../../common/utils/keyEvent';
@@ -20,6 +21,8 @@ interface CustomFieldsFormProps {
initialKey?: string; initialKey?: string;
} }
type CustomFieldFormData = CustomField & { key: string };
export default function CustomFieldForm(props: CustomFieldsFormProps) { export default function CustomFieldForm(props: CustomFieldsFormProps) {
const { onSubmit, onCancel, initialColour, initialLabel, initialKey } = props; const { onSubmit, onCancel, initialColour, initialLabel, initialKey } = props;
const { data } = useCustomFields(); const { data } = useCustomFields();
@@ -28,6 +31,7 @@ export default function CustomFieldForm(props: CustomFieldsFormProps) {
const [_, setColour] = useState(initialColour || ''); const [_, setColour] = useState(initialColour || '');
const { const {
control,
handleSubmit, handleSubmit,
register, register,
setFocus, setFocus,
@@ -35,17 +39,17 @@ export default function CustomFieldForm(props: CustomFieldsFormProps) {
setValue, setValue,
getValues, getValues,
formState: { errors, isSubmitting, isValid, isDirty }, formState: { errors, isSubmitting, isValid, isDirty },
} = useForm({ } = useForm<CustomFieldFormData>({
defaultValues: { label: initialLabel || '', colour: initialColour || '', key: initialKey || '' }, defaultValues: { type: 'string', label: initialLabel || '', colour: initialColour || '' },
resetOptions: { resetOptions: {
keepDirtyValues: true, keepDirtyValues: true,
}, },
}); });
const setupSubmit = async (values: { label: string; colour: string }) => { const setupSubmit = async (values: CustomFieldFormData) => {
const { label, colour } = values; const { type, label, colour } = values;
const newField: CustomField = { const newField: CustomField = {
type: 'string', // type is not user definable yet type,
colour, colour,
label, label,
}; };
@@ -77,6 +81,26 @@ export default function CustomFieldForm(props: CustomFieldsFormProps) {
className={style.fieldForm} className={style.fieldForm}
onKeyDown={(event) => preventEscape(event, onCancel)} onKeyDown={(event) => preventEscape(event, onCancel)}
> >
<Info>
Please note that images can quickly deteriorate your app&apos;s performance.
<br />
Prefer using small, and compressed images.
</Info>
<div>
<Panel.Description>Type</Panel.Description>
<Controller
name='type'
control={control}
render={({ field }) => (
<RadioGroup {...field} size='sm' isDisabled={isEditMode} variant='ontime'>
<Panel.InlineElements relation='component'>
<Radio value='string'>Text</Radio>
<Radio value='image'>Image</Radio>
</Panel.InlineElements>
</RadioGroup>
)}
/>
</div>
<div className={style.twoCols}> <div className={style.twoCols}>
<div> <div>
<Panel.Description>Label (only alphanumeric characters are allowed)</Panel.Description> <Panel.Description>Label (only alphanumeric characters are allowed)</Panel.Description>
@@ -105,12 +129,10 @@ export default function CustomFieldForm(props: CustomFieldsFormProps) {
<Input {...register('key')} disabled size='sm' variant='ontime-filled' autoComplete='off' /> <Input {...register('key')} disabled size='sm' variant='ontime-filled' autoComplete='off' />
</div> </div>
</div> </div>
<div> <div>
<Panel.Description>Colour</Panel.Description> <Panel.Description>Colour</Panel.Description>
<SwatchSelect name='colour' value={colour} handleChange={(_field, value) => handleSelectColour(value)} /> <SwatchSelect name='colour' value={colour} handleChange={(_field, value) => handleSelectColour(value)} />
</div> </div>
{errors.root && <Panel.Error>{errors.root.message}</Panel.Error>} {errors.root && <Panel.Error>{errors.root.message}</Panel.Error>}
<Panel.InlineElements relation='inner' align='end'> <Panel.InlineElements relation='inner' align='end'>
<Button size='sm' variant='ontime-ghosted' onClick={onCancel}> <Button size='sm' variant='ontime-ghosted' onClick={onCancel}>
@@ -74,19 +74,21 @@ export default function CustomFields() {
<thead> <thead>
<tr> <tr>
<th>Colour</th> <th>Colour</th>
<th>Type</th>
<th>Name</th> <th>Name</th>
<th>Key (used in Integrations)</th> <th>Key (used in Integrations)</th>
<th /> <th />
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{Object.entries(data).map(([key, { colour, label }]) => { {Object.entries(data).map(([key, { colour, label, type }]) => {
return ( return (
<CustomFieldEntry <CustomFieldEntry
key={key} key={key}
field={key} fieldKey={key}
colour={colour} colour={colour}
label={label} label={label}
type={type}
onEdit={handleEditField} onEdit={handleEditField}
onDelete={handleDelete} onDelete={handleDelete}
/> />
@@ -78,3 +78,9 @@
font-size: 1.25em; font-size: 1.25em;
margin-left: 0.25em; margin-left: 0.25em;
} }
.customImage {
display: grid;
grid-template-columns: 1fr 72px;
gap: 1rem;
}
@@ -8,9 +8,11 @@ import useCustomFields from '../../../common/hooks-query/useCustomFields';
import { getAccessibleColour } from '../../../common/utils/styleUtils'; import { getAccessibleColour } from '../../../common/utils/styleUtils';
import * as Editor from '../../editors/editor-utils/EditorUtils'; import * as Editor from '../../editors/editor-utils/EditorUtils';
import EventEditorImage from './composite/EventEditorImage';
import EventEditorTimes from './composite/EventEditorTimes'; import EventEditorTimes from './composite/EventEditorTimes';
import EventEditorTitles from './composite/EventEditorTitles'; import EventEditorTitles from './composite/EventEditorTitles';
import EventTextArea from './composite/EventTextArea'; import EventTextArea from './composite/EventTextArea';
import EventTextInput from './composite/EventTextInput';
import EventEditorEmpty from './EventEditorEmpty'; import EventEditorEmpty from './EventEditorEmpty';
import style from './EventEditor.module.scss'; import style from './EventEditor.module.scss';
@@ -87,6 +89,7 @@ export default function EventEditor(props: EventEditorProps) {
</Button> </Button>
)} )}
</Editor.Title> </Editor.Title>
{Object.keys(customFields).map((fieldKey) => { {Object.keys(customFields).map((fieldKey) => {
const key = `${event.id}-${fieldKey}`; const key = `${event.id}-${fieldKey}`;
const fieldName = `custom-${fieldKey}`; const fieldName = `custom-${fieldKey}`;
@@ -94,17 +97,41 @@ export default function EventEditor(props: EventEditorProps) {
const { backgroundColor, color } = getAccessibleColour(customFields[fieldKey].colour); const { backgroundColor, color } = getAccessibleColour(customFields[fieldKey].colour);
const labelText = customFields[fieldKey].label; const labelText = customFields[fieldKey].label;
return ( if (customFields[fieldKey].type === 'string') {
<EventTextArea return (
key={key} <EventTextArea
field={fieldName} key={key}
label={labelText} field={fieldName}
initialValue={initialValue} label={labelText}
submitHandler={handleSubmit} initialValue={initialValue}
className={style.decorated} submitHandler={handleSubmit}
style={{ '--decorator-bg': backgroundColor, '--decorator-color': color } as CSSProperties} className={style.decorated}
/> style={{ '--decorator-bg': backgroundColor, '--decorator-color': color } as CSSProperties}
); />
);
}
if (customFields[fieldKey].type === 'image') {
return (
<div key={key} className={style.customImage}>
<EventTextInput
key={key}
field={fieldName}
label={labelText}
initialValue={initialValue}
placeholder='Paste image URL'
submitHandler={handleSubmit}
className={style.decorated}
maxLength={255}
style={{ '--decorator-bg': backgroundColor, '--decorator-color': color } as CSSProperties}
/>
<EventEditorImage src={initialValue} />
</div>
);
}
// we should have exhausted all types by now
return null;
})} })}
</div> </div>
</div> </div>
@@ -0,0 +1,12 @@
.imageContainer {
width: 100%;
height: 100%;
background-color: $gray-1250;
display: grid;
place-content: center;
}
.imageOverlay {
width: 100%;
height: 100%;
}
@@ -0,0 +1,16 @@
import style from './EventEditorImage.module.scss';
interface EventEditorImageProps {
src: string;
}
export default function EventEditorImage(props: EventEditorImageProps) {
const { src } = props;
return (
<div className={style.imageContainer}>
<img loading='lazy' src={src} />
<div className={style.imageOverlay} />
</div>
);
}
@@ -9,11 +9,12 @@ interface EventTextInputProps extends InputProps {
field: EditorUpdateFields; field: EditorUpdateFields;
label: string; label: string;
initialValue: string; initialValue: string;
placeholder?: string;
submitHandler: (field: EditorUpdateFields, value: string) => void; submitHandler: (field: EditorUpdateFields, value: string) => void;
} }
export default function EventTextInput(props: EventTextInputProps) { export default function EventTextInput(props: EventTextInputProps) {
const { field, label, initialValue, submitHandler, maxLength } = props; const { className, field, label, initialValue, style: givenStyles, submitHandler, maxLength, placeholder } = props;
const ref = useRef<HTMLInputElement | null>(null); const ref = useRef<HTMLInputElement | null>(null);
const submitCallback = useCallback((newValue: string) => submitHandler(field, newValue), [field, submitHandler]); const submitCallback = useCallback((newValue: string) => submitHandler(field, newValue), [field, submitHandler]);
@@ -23,7 +24,9 @@ export default function EventTextInput(props: EventTextInputProps) {
return ( return (
<div> <div>
<Editor.Label htmlFor={field}>{label}</Editor.Label> <Editor.Label className={className} htmlFor={field} style={givenStyles}>
{label}
</Editor.Label>
<Input <Input
id={field} id={field}
ref={ref} ref={ref}
@@ -32,6 +35,7 @@ export default function EventTextInput(props: EventTextInputProps) {
data-testid='input-textfield' data-testid='input-textfield'
value={value} value={value}
maxLength={maxLength || 100} maxLength={maxLength || 100}
placeholder={placeholder}
onChange={onChange} onChange={onChange}
onBlur={onBlur} onBlur={onBlur}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
@@ -12,6 +12,7 @@ $white-60: rgba(255, 255, 255, 0.60);
$white-90: rgba(255, 255, 255, 0.90); $white-90: rgba(255, 255, 255, 0.90);
$black-10: rgba(0, 0, 0, 0.10); $black-10: rgba(0, 0, 0, 0.10);
$black-60: rgba(0, 0, 0, 0.60);
$gray-50: #f6f6f6; $gray-50: #f6f6f6;
$gray-100: #ececec; $gray-100: #ececec;
@@ -0,0 +1,23 @@
.imageCell {
position: relative;
min-height: 2rem;
&:hover {
.overlay {
display: grid;
place-content: center;
background-color: $black-60;
}
}
}
.overlay {
position: absolute;
inset: 0;
display: none;
}
.image {
background-color: $gray-1350;
min-height: 2rem;
}
@@ -0,0 +1,55 @@
import { memo } from 'react';
import { Input } from '@chakra-ui/react';
import style from './EditableImage.module.scss';
interface EditableImageProps {
initialValue: string;
updateValue: (newValue: string) => void;
}
export default memo(EditableImage);
function EditableImage(props: EditableImageProps) {
const { initialValue, updateValue } = props;
const handleUpdate = (newValue: string) => {
if (newValue === initialValue) {
return;
}
if (newValue !== '' && !newValue.startsWith('http')) {
return;
}
updateValue(newValue);
};
if (!initialValue) {
return (
<Input
size='sm'
variant='ontime-transparent'
padding={0}
fontSize='md'
placeholder='Paste image URL'
onBlur={(event) => handleUpdate(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
handleUpdate(event.currentTarget.value);
}
}}
defaultValue={initialValue}
spellCheck={false}
autoComplete='off'
/>
);
}
return (
<div className={style.imageCell}>
<div className={style.overlay}>
<button onClick={() => handleUpdate('')}>Delete</button>
</div>
<img loading='lazy' src={initialValue} className={style.image} />
</div>
);
}
@@ -6,6 +6,7 @@ import { millisToString, removeSeconds } from 'ontime-utils';
import DelayIndicator from '../../../../common/components/delay-indicator/DelayIndicator'; import DelayIndicator from '../../../../common/components/delay-indicator/DelayIndicator';
import { formatDuration } from '../../../../common/utils/time'; import { formatDuration } from '../../../../common/utils/time';
import EditableImage from './EditableImage';
import MultiLineCell from './MultiLineCell'; import MultiLineCell from './MultiLineCell';
import SingleLineCell from './SingleLineCell'; import SingleLineCell from './SingleLineCell';
import TimeInput from './TimeInput'; import TimeInput from './TimeInput';
@@ -105,6 +106,24 @@ function MakeMultiLineField({ row, column, table }: CellContext<OntimeRundownEnt
return <MultiLineCell initialValue={initialValue} handleUpdate={update} />; return <MultiLineCell initialValue={initialValue} handleUpdate={update} />;
} }
function LazyImage({ row, column, table }: CellContext<OntimeRundownEntry, unknown>) {
const update = useCallback(
(newValue: string) => {
table.options.meta?.handleUpdate(row.index, column.id, newValue, true);
},
// eslint-disable-next-line react-hooks/exhaustive-deps -- we skip table.options.meta since the reference seems unstable
[column.id, row.index],
);
const event = row.original;
if (!isOntimeEvent(event)) {
return null;
}
const initialValue = event.custom[column.id];
return <EditableImage initialValue={initialValue} updateValue={update} />;
}
function MakeSingleLineField({ row, column, table }: CellContext<OntimeRundownEntry, unknown>) { function MakeSingleLineField({ row, column, table }: CellContext<OntimeRundownEntry, unknown>) {
const update = useCallback( const update = useCallback(
(newValue: string) => { (newValue: string) => {
@@ -139,7 +158,6 @@ function MakeCustomField({ row, column, table }: CellContext<OntimeRundownEntry,
} }
const initialValue = event.custom[column.id] ?? ''; const initialValue = event.custom[column.id] ?? '';
return <MultiLineCell initialValue={initialValue} handleUpdate={update} />; return <MultiLineCell initialValue={initialValue} handleUpdate={update} />;
} }
@@ -148,8 +166,8 @@ export function makeCuesheetColumns(customFields: CustomFields): ColumnDef<Ontim
accessorKey: key, accessorKey: key,
id: key, id: key,
header: customFields[key].label, header: customFields[key].label,
meta: { colour: customFields[key].colour }, meta: { colour: customFields[key].colour, type: customFields[key].type },
cell: MakeCustomField, cell: customFields[key].type === 'string' ? MakeCustomField : LazyImage,
size: 250, size: 250,
minSize: 75, minSize: 75,
})); }));
@@ -11,7 +11,7 @@ export const validateCustomField = [
.custom((value) => { .custom((value) => {
return isAlphanumericWithSpace(value); return isAlphanumericWithSpace(value);
}), }),
body('type').exists().isString().trim(), body('type').exists().isIn(['string', 'image']),
body('colour').exists().isString().trim(), body('colour').exists().isString().trim(),
(req: Request, res: Response, next: NextFunction) => { (req: Request, res: Response, next: NextFunction) => {
@@ -23,8 +23,14 @@ export const validateCustomField = [
export const validateEditCustomField = [ export const validateEditCustomField = [
param('label').exists().isString().trim(), param('label').exists().isString().trim(),
body('label').exists().isString().trim(), body('label')
body('type').exists().isString().trim(), .exists()
.isString()
.trim()
.custom((value) => {
return isAlphanumericWithSpace(value);
}),
body('type').exists().isIn(['string', 'image']),
body('colour').exists().isString().trim(), body('colour').exists().isString().trim(),
(req: Request, res: Response, next: NextFunction) => { (req: Request, res: Response, next: NextFunction) => {
@@ -156,7 +156,7 @@ describe('parseCustomFields()', () => {
}); });
describe('sanitiseCustomFields()', () => { describe('sanitiseCustomFields()', () => {
it('returns an empty array if not an array', () => { it('returns an empty object the type is incorrect', () => {
expect(sanitiseCustomFields({})).toEqual({}); expect(sanitiseCustomFields({})).toEqual({});
}); });
@@ -170,16 +170,16 @@ describe('sanitiseCustomFields()', () => {
expect(sanitationResult).toStrictEqual(customFields); expect(sanitationResult).toStrictEqual(customFields);
}); });
it('type is forced to be string', () => { it('type should be one of (image | string)', () => {
const customFields: CustomFields = { const testTypes = sanitiseCustomFields({
// @ts-expect-error intentional bad data test1: { label: 'test1', type: 'another', colour: 'red' },
test: { label: 'test', type: 'another', colour: 'red' }, test2: { label: 'test2', type: 'image', colour: 'red' },
}; test3: { label: 'test3', type: 'string', colour: 'red' },
const expectedCustomFields: CustomFields = { });
test: { label: 'test', type: 'string', colour: 'red' }, expect(testTypes).toMatchObject({
}; test2: { label: 'test2', type: 'image', colour: 'red' },
const sanitationResult = sanitiseCustomFields(customFields); test3: { label: 'test3', type: 'string', colour: 'red' },
expect(sanitationResult).toStrictEqual(expectedCustomFields); });
}); });
it('colour must be a string', () => { it('colour must be a string', () => {
+6 -4
View File
@@ -217,15 +217,15 @@ export function sanitiseCustomFields(data: object): CustomFields {
} }
const keyFromLabel = customFieldLabelToKey(field.label); const keyFromLabel = customFieldLabelToKey(field.label);
//Test label and key cohesion, but allow old lowercased keys to stay // Test label and key cohesion, but allow old lowercased keys to stay
//TODO: the `toLocaleLowerCase` part here is to conserve keys from old projects and could be removed at some point (okt. 2024) // TODO: the `toLocaleLowerCase` part here is to conserve keys from old projects and could be removed at some point (okt. 2024)
const key = originalKey.toLocaleLowerCase() === keyFromLabel.toLocaleLowerCase() ? originalKey : keyFromLabel; const key = originalKey.toLocaleLowerCase() === keyFromLabel.toLocaleLowerCase() ? originalKey : keyFromLabel;
if (key in newCustomFields) { if (key in newCustomFields) {
continue; continue;
} }
newCustomFields[key] = { newCustomFields[key] = {
type: 'string', type: field.type,
colour: field.colour, colour: field.colour,
label: field.label, label: field.label,
}; };
@@ -238,7 +238,9 @@ export function sanitiseCustomFields(data: object): CustomFields {
'label' in data && 'label' in data &&
data.label !== '' && data.label !== '' &&
'colour' in data && 'colour' in data &&
typeof data.colour === 'string' typeof data.colour === 'string' &&
'type' in data &&
(data.type === 'string' || data.type === 'image')
); );
} }
@@ -1,7 +1,7 @@
export type CustomFieldLabel = string; export type CustomFieldLabel = string;
export type CustomField = { export type CustomField = {
type: 'string'; type: 'string' | 'image';
colour: string; colour: string;
label: CustomFieldLabel; label: CustomFieldLabel;
}; };