diff --git a/apps/client/src/features/app-settings/panel/feature-settings-panel/custom-fields/CustomFieldEntry.tsx b/apps/client/src/features/app-settings/panel/feature-settings-panel/custom-fields/CustomFieldEntry.tsx index 18a7873926..bbea896aa1 100644 --- a/apps/client/src/features/app-settings/panel/feature-settings-panel/custom-fields/CustomFieldEntry.tsx +++ b/apps/client/src/features/app-settings/panel/feature-settings-panel/custom-fields/CustomFieldEntry.tsx @@ -4,6 +4,7 @@ import { IoPencil } from '@react-icons/all-files/io5/IoPencil'; import { IoTrash } from '@react-icons/all-files/io5/IoTrash'; import { CustomField, CustomFieldLabel } from 'ontime-types'; +import CopyTag from '../../../../../common/components/copy-tag/CopyTag'; import Swatch from '../../../../../common/components/input/colour-input/Swatch'; import CustomFieldForm from './CustomFieldForm'; @@ -36,18 +37,25 @@ export default function CustomFieldEntry(props: CustomFieldEntryProps) { onSubmit={handleEdit} initialColour={colour} initialLabel={label} + initialKey={field} /> ); } + console.log(field) + return ( {label} + + {/* TODO: better description */} + {field} + void; initialColour?: string; initialLabel?: string; + initialKey?: string; } export default function CustomFieldForm(props: CustomFieldsFormProps) { - const { onSubmit, onCancel, initialColour, initialLabel } = props; + const { onSubmit, onCancel, initialColour, initialLabel, initialKey } = props; const { data } = useCustomFields(); // we use this to force an update @@ -34,7 +35,7 @@ export default function CustomFieldForm(props: CustomFieldsFormProps) { getValues, formState: { errors, isSubmitting, isValid, isDirty }, } = useForm({ - defaultValues: { label: initialLabel || '', colour: initialColour || '' }, + defaultValues: { label: initialLabel || '', colour: initialColour || '', key: initialKey || '' }, resetOptions: { keepDirtyValues: true, }, @@ -75,9 +76,10 @@ export default function CustomFieldForm(props: CustomFieldsFormProps) { setValue('key', getValues('label').replaceAll(' ', '_')), validate: (value) => { if (value.trim().length === 0) return 'Required field'; - if (!isAlphanumeric(value)) return 'Only alphanumeric characters are allowed'; + if (!isAlphanumericWithSpace(value)) return 'Only alphanumeric characters and space are allowed'; if (Object.keys(data).includes(value)) return 'Custom fields must be unique'; return true; }, @@ -87,6 +89,11 @@ export default function CustomFieldForm(props: CustomFieldsFormProps) { autoComplete='off' /> +
+ {/* TODO: better style and description */} + Key (The key is generated from the label for use in Integrations and API) + +
Colour diff --git a/apps/client/src/features/app-settings/panel/feature-settings-panel/custom-fields/CustomFields.tsx b/apps/client/src/features/app-settings/panel/feature-settings-panel/custom-fields/CustomFields.tsx index aabf23dfe2..9c68b81d2c 100644 --- a/apps/client/src/features/app-settings/panel/feature-settings-panel/custom-fields/CustomFields.tsx +++ b/apps/client/src/features/app-settings/panel/feature-settings-panel/custom-fields/CustomFields.tsx @@ -73,6 +73,7 @@ export default function CustomFields() { Colour Name + diff --git a/apps/server/src/api-data/custom-fields/customFields.validation.ts b/apps/server/src/api-data/custom-fields/customFields.validation.ts index b53bd9a676..2c67503e31 100644 --- a/apps/server/src/api-data/custom-fields/customFields.validation.ts +++ b/apps/server/src/api-data/custom-fields/customFields.validation.ts @@ -1,4 +1,4 @@ -import { isAlphanumeric } from 'ontime-utils'; +import { isAlphanumericWithSpace } from 'ontime-utils'; import { Request, Response, NextFunction } from 'express'; import { body, param, validationResult } from 'express-validator'; @@ -9,7 +9,7 @@ export const validateCustomField = [ .isString() .trim() .custom((value) => { - return isAlphanumeric(value); + return isAlphanumericWithSpace(value); }), body('type').exists().isString().trim(), body('colour').exists().isString().trim(), diff --git a/apps/server/src/services/integration-service/OscIntegration.ts b/apps/server/src/services/integration-service/OscIntegration.ts index 51f50253b0..08f86c535c 100644 --- a/apps/server/src/services/integration-service/OscIntegration.ts +++ b/apps/server/src/services/integration-service/OscIntegration.ts @@ -78,6 +78,7 @@ export class OscIntegration implements IIntegration { describe('createCustomField()', () => { it('creates a field from given parameters', async () => { const expected = { - lighting: { + Lighting: { label: 'Lighting', type: 'string', colour: 'blue', @@ -942,19 +942,19 @@ describe('custom fields', () => { await createCustomField({ label: 'Sound', type: 'string', colour: 'blue' }); const expected = { - lighting: { + Lighting: { label: 'Lighting', type: 'string', colour: 'blue', }, - sound: { + Sound: { label: 'Sound', type: 'string', colour: 'green', }, }; - const customField = await editCustomField('sound', { label: 'Sound', type: 'string', colour: 'green' }); + const customField = await editCustomField('Sound', { label: 'Sound', type: 'string', colour: 'green' }); expect(customFieldChangelog).toStrictEqual(new Map()); expect(customField).toStrictEqual(expected); @@ -964,17 +964,17 @@ describe('custom fields', () => { const created = await createCustomField({ label: 'Video', type: 'string', colour: 'red' }); const expected = { - lighting: { + Lighting: { label: 'Lighting', type: 'string', colour: 'blue', }, - sound: { + Sound: { label: 'Sound', type: 'string', colour: 'green', }, - video: { + Video: { label: 'Video', type: 'string', colour: 'red', @@ -984,17 +984,17 @@ describe('custom fields', () => { expect(created).toStrictEqual(expected); const expectedAfter = { - lighting: { + Lighting: { label: 'Lighting', type: 'string', colour: 'blue', }, - sound: { + Sound: { label: 'Sound', type: 'string', colour: 'green', }, - av: { + AV: { label: 'AV', type: 'string', colour: 'red', @@ -1003,10 +1003,10 @@ describe('custom fields', () => { // We need to flush all scheduled tasks for the generate function to settle vi.useFakeTimers(); - const customField = await editCustomField('video', { label: 'AV', type: 'string', colour: 'red' }); + const customField = await editCustomField('Video', { label: 'AV', type: 'string', colour: 'red' }); expect(customField).toStrictEqual(expectedAfter); - expect(customFieldChangelog).toStrictEqual(new Map([['video', 'av']])); - await editCustomField('av', { label: 'video' }); + expect(customFieldChangelog).toStrictEqual(new Map([['Video', 'AV']])); + await editCustomField('AV', { label: 'Video' }); vi.runAllTimers(); expect(customFieldChangelog).toStrictEqual(new Map()); vi.useRealTimers(); @@ -1016,19 +1016,19 @@ describe('custom fields', () => { describe('removeCustomField()', () => { it('deletes a field with a given label', async () => { const expected = { - lighting: { + Lighting: { label: 'Lighting', type: 'string', colour: 'blue', }, - video: { - label: 'video', + Video: { + label: 'Video', type: 'string', colour: 'red', }, }; - const customField = await removeCustomField('sound'); + const customField = await removeCustomField('Sound'); expect(customField).toStrictEqual(expected); }); diff --git a/apps/server/src/services/rundown-service/rundownCache.ts b/apps/server/src/services/rundown-service/rundownCache.ts index d42591bbd8..448c9c7b20 100644 --- a/apps/server/src/services/rundown-service/rundownCache.ts +++ b/apps/server/src/services/rundown-service/rundownCache.ts @@ -11,7 +11,15 @@ import { OntimeRundownEntry, PlayableEvent, } from 'ontime-types'; -import { generateId, insertAtIndex, reorderArray, swapEventData, getTimeFromPrevious, isNewLatest } from 'ontime-utils'; +import { + generateId, + insertAtIndex, + reorderArray, + swapEventData, + getTimeFromPrevious, + isNewLatest, + customFieldLabelToKey, +} from 'ontime-utils'; import { getDataProvider } from '../../classes/data-provider/DataProvider.js'; import { createPatch } from '../../utils/parser.js'; import { apply } from './delayUtils.js'; @@ -447,7 +455,7 @@ function scheduleCustomFieldPersist(persistedCustomFields: CustomFields) { */ export const createCustomField = async (field: CustomField) => { const { label, type, colour } = field; - const key = label.toLowerCase(); + const key = customFieldLabelToKey(label); // check if label already exists const alreadyExists = Object.hasOwn(persistedCustomFields, key); @@ -479,7 +487,7 @@ export const editCustomField = async (key: string, newField: Partial { it('enforce name cohesion', () => { const customFields: CustomFields = { - test: { label: 'New Name', type: 'string', colour: 'red' }, + test: { label: 'NewName', type: 'string', colour: 'red' }, }; const expectedCustomFields: CustomFields = { - 'New Name': { label: 'New Name', type: 'string', colour: 'red' }, + NewName: { label: 'NewName', type: 'string', colour: 'red' }, + }; + const sanitationResult = sanitiseCustomFields(customFields); + expect(sanitationResult).toStrictEqual(expectedCustomFields); + }); + + it('allow old keys', () => { + const customFields: CustomFields = { + test: { label: 'Test', type: 'string', colour: 'red' }, + }; + const expectedCustomFields: CustomFields = { + test: { label: 'Test', type: 'string', colour: 'red' }, + }; + const sanitationResult = sanitiseCustomFields(customFields); + expect(sanitationResult).toStrictEqual(expectedCustomFields); + }); + + it('labels with space', () => { + const customFields: CustomFields = { + Test_with_Space: { label: 'Test with Space', type: 'string', colour: 'red' }, + }; + const expectedCustomFields: CustomFields = { + Test_with_Space: { label: 'Test with Space', type: 'string', colour: 'red' }, }; const sanitationResult = sanitiseCustomFields(customFields); expect(sanitationResult).toStrictEqual(expectedCustomFields); diff --git a/apps/server/src/utils/parserFunctions.ts b/apps/server/src/utils/parserFunctions.ts index 469e9605f6..db95bca385 100644 --- a/apps/server/src/utils/parserFunctions.ts +++ b/apps/server/src/utils/parserFunctions.ts @@ -19,7 +19,13 @@ import { isOntimeDelay, isOntimeEvent, } from 'ontime-types'; -import { generateId, getErrorMessage, getLastEvent } from 'ontime-utils'; +import { + customFieldLabelToKey, + generateId, + getErrorMessage, + getLastEvent, + isAlphanumericWithSpace, +} from 'ontime-utils'; import { dbModel } from '../models/dataModel.js'; import { block as blockDef, delay as delayDef } from '../models/eventsDefinition.js'; @@ -305,12 +311,18 @@ export function parseCustomFields(data: Partial, emitError?: Erro export function sanitiseCustomFields(data: object): CustomFields { const newCustomFields: CustomFields = {}; - for (const [_key, field] of Object.entries(data)) { + for (const [originalKey, field] of Object.entries(data)) { if (!isValidField(field)) { continue; } - const key = field.label; + if (!isAlphanumericWithSpace(field.label)) { + continue; + } + + const keyFromLabel = customFieldLabelToKey(field.label); + //Test label and key cohesion, but allow old lowercased keys to stay + const key = originalKey.toLocaleLowerCase() === keyFromLabel.toLocaleLowerCase() ? originalKey : keyFromLabel; if (key in newCustomFields) { continue; } diff --git a/packages/utils/index.ts b/packages/utils/index.ts index 7a83511d62..1429824eed 100644 --- a/packages/utils/index.ts +++ b/packages/utils/index.ts @@ -53,10 +53,12 @@ export { removeTrailingZero, } from './src/date-utils/timeFormatting.js'; export { parseUserTime } from './src/date-utils/parseUserTime.js'; -export { isAlphanumeric } from './src/regex-utils/isAlphanumeric.js'; +export { isAlphanumeric, isAlphanumericWithSpace } from './src/regex-utils/isAlphanumeric.js'; export { isColourHex } from './src/regex-utils/isColourHex.js'; export { splitWhitespace } from './src/regex-utils/splitWhitespace.js'; +export { customFieldLabelToKey } from './src/customField-utils/customFieldLabelToKey.js'; + // helpers from externals export { deepmerge } from './src/externals/deepmerge.js'; diff --git a/packages/utils/src/customField-utils/customFieldLabelToKey.ts b/packages/utils/src/customField-utils/customFieldLabelToKey.ts new file mode 100644 index 0000000000..fd56e29dc0 --- /dev/null +++ b/packages/utils/src/customField-utils/customFieldLabelToKey.ts @@ -0,0 +1,12 @@ +import { isAlphanumericWithSpace } from '../regex-utils/isAlphanumeric.js'; + +/** + * @description Transforms a Custom field label into a valid key or returns null if not possible + * @returns {string | null} + */ +export const customFieldLabelToKey = (label: string): string | null => { + if (isAlphanumericWithSpace(label)) { + return label.trim().replaceAll(' ', '_'); + } + return null; +}; diff --git a/packages/utils/src/regex-utils/isAlphanumeric.ts b/packages/utils/src/regex-utils/isAlphanumeric.ts index c4e514faa0..64b375b93b 100644 --- a/packages/utils/src/regex-utils/isAlphanumeric.ts +++ b/packages/utils/src/regex-utils/isAlphanumeric.ts @@ -6,3 +6,12 @@ export const isAlphanumeric = (text: string): boolean => { const regex = /^[a-z0-9]+$/i; return regex.test(text); }; + +/** + * @description Validates a alphanumeric string allow space + * @returns {boolean} + */ +export const isAlphanumericWithSpace = (text: string): boolean => { + const regex = /^[a-z0-9_ ]+$/i; + return regex.test(text); +};