make custom fields case sensitive (#1242)

* remove custom field lowercasing

* don't allow custom field form to submit duplicate field

* remove unused

* update custom field tests

* also keep upper case when editing

* show key in UI

* also kep upper case when creating a new field

* fix test

* allow old forced case keys to stay as is

* allow space

* add extension to import

* supply initial key to CustomFieldForm

* refactor: improve element contrast

* refactor: style and composition

* consistent use of `customFieldLabelToKey`

* fix CopyTag

* remove log

---------

Co-authored-by: arc-alex <ac@omnivox.dk>
Co-authored-by: Carlos Valente <carlosvalente@pm.me>
This commit is contained in:
2024-10-18 11:53:23 -05:00
committed by GitHub
parent b73b529c00
commit c5870452e3
15 changed files with 161 additions and 59 deletions
+1
View File
@@ -3,3 +3,4 @@ export const apiRepoLatest = 'https://api.github.com/repos/cpvalente/ontime/rele
export const websiteUrl = 'https://www.getontime.no'; export const websiteUrl = 'https://www.getontime.no';
export const documentationUrl = 'https://docs.getontime.no'; export const documentationUrl = 'https://docs.getontime.no';
export const customFieldsDocsUrl = 'https://docs.getontime.no/features/custom-fields/';
@@ -1,3 +1,7 @@
.halfWidth {
width: 50%;
}
.fullWidth { .fullWidth {
width: 100%; width: 100%;
} }
@@ -37,3 +41,9 @@
.flex { .flex {
display: flex; display: flex;
} }
.twoCols {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
@@ -4,6 +4,7 @@ import { IoPencil } from '@react-icons/all-files/io5/IoPencil';
import { IoTrash } from '@react-icons/all-files/io5/IoTrash'; import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
import { CustomField, CustomFieldLabel } from 'ontime-types'; import { CustomField, CustomFieldLabel } from 'ontime-types';
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 CustomFieldForm from './CustomFieldForm'; import CustomFieldForm from './CustomFieldForm';
@@ -36,6 +37,7 @@ export default function CustomFieldEntry(props: CustomFieldEntryProps) {
onSubmit={handleEdit} onSubmit={handleEdit}
initialColour={colour} initialColour={colour}
initialLabel={label} initialLabel={label}
initialKey={field}
/> />
</td> </td>
</tr> </tr>
@@ -47,7 +49,12 @@ export default function CustomFieldEntry(props: CustomFieldEntryProps) {
<td> <td>
<Swatch color={colour} /> <Swatch color={colour} />
</td> </td>
<td className={style.fullWidth}>{label}</td> <td className={style.halfWidth}>{label}</td>
<td className={style.fullWidth}>
<CopyTag label='Copy key to use in integrations' copyValue={field}>
{field}
</CopyTag>
</td>
<td className={style.actions}> <td className={style.actions}>
<IconButton <IconButton
size='sm' size='sm'
@@ -2,10 +2,11 @@ import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { Button, Input } from '@chakra-ui/react'; import { Button, Input } from '@chakra-ui/react';
import { CustomField } from 'ontime-types'; import { CustomField } from 'ontime-types';
import { isAlphanumeric } from 'ontime-utils'; import { customFieldLabelToKey, isAlphanumericWithSpace } from 'ontime-utils';
import { maybeAxiosError } from '../../../../../common/api/utils'; import { maybeAxiosError } from '../../../../../common/api/utils';
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 * as Panel from '../../PanelUtils'; import * as Panel from '../../PanelUtils';
import style from '../FeatureSettings.module.scss'; import style from '../FeatureSettings.module.scss';
@@ -15,10 +16,13 @@ interface CustomFieldsFormProps {
onCancel: () => void; onCancel: () => void;
initialColour?: string; initialColour?: string;
initialLabel?: string; initialLabel?: string;
initialKey?: string;
} }
export default function CustomFieldForm(props: CustomFieldsFormProps) { 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 // we use this to force an update
const [_, setColour] = useState(initialColour || ''); const [_, setColour] = useState(initialColour || '');
@@ -31,7 +35,7 @@ export default function CustomFieldForm(props: CustomFieldsFormProps) {
getValues, getValues,
formState: { errors, isSubmitting, isValid, isDirty }, formState: { errors, isSubmitting, isValid, isDirty },
} = useForm({ } = useForm({
defaultValues: { label: initialLabel || '', colour: initialColour || '' }, defaultValues: { label: initialLabel || '', colour: initialColour || '', key: initialKey || '' },
resetOptions: { resetOptions: {
keepDirtyValues: true, keepDirtyValues: true,
}, },
@@ -66,28 +70,38 @@ export default function CustomFieldForm(props: CustomFieldsFormProps) {
return ( return (
<form onSubmit={handleSubmit(setupSubmit)} className={style.fieldForm}> <form onSubmit={handleSubmit(setupSubmit)} className={style.fieldForm}>
<div className={style.column}> <div className={style.twoCols}>
<Panel.Description>Label (only alphanumeric characters are allowed)</Panel.Description> <div>
{errors.label && <Panel.Error>{errors.label.message}</Panel.Error>} <Panel.Description>Label (only alphanumeric characters are allowed)</Panel.Description>
<Input {errors.label && <Panel.Error>{errors.label.message}</Panel.Error>}
{...register('label', { <Input
required: { value: true, message: 'Required field' }, {...register('label', {
validate: (value) => { required: { value: true, message: 'Required field' },
if (value.trim().length === 0) return 'Required field'; onChange: () => setValue('key', customFieldLabelToKey(getValues('label')) ?? 'N/A'),
if (!isAlphanumeric(value)) return 'Only alphanumeric characters are allowed'; validate: (value) => {
return true; if (value.trim().length === 0) return 'Required field';
}, if (!isAlphanumericWithSpace(value)) return 'Only alphanumeric characters and space are allowed';
})} if (Object.keys(data).includes(value)) return 'Custom fields must be unique';
size='sm' return true;
variant='ontime-filled' },
autoComplete='off' })}
/> size='sm'
variant='ontime-filled'
autoComplete='off'
/>
</div>
<div>
<Panel.Description>Key (auto-generated value for use in Integrations and API)</Panel.Description>
<Input {...register('key')} disabled size='sm' variant='ontime-filled' autoComplete='off' />
</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>}
<div className={style.buttonRow}> <div className={style.buttonRow}>
<Button size='sm' variant='ontime-ghosted' onClick={onCancel}> <Button size='sm' variant='ontime-ghosted' onClick={onCancel}>
@@ -6,13 +6,12 @@ import { CustomField, CustomFieldLabel } from 'ontime-types';
import { deleteCustomField, editCustomField, postCustomField } from '../../../../../common/api/customFields'; import { deleteCustomField, editCustomField, postCustomField } from '../../../../../common/api/customFields';
import ExternalLink from '../../../../../common/components/external-link/ExternalLink'; import ExternalLink from '../../../../../common/components/external-link/ExternalLink';
import useCustomFields from '../../../../../common/hooks-query/useCustomFields'; import useCustomFields from '../../../../../common/hooks-query/useCustomFields';
import { customFieldsDocsUrl } from '../../../../../externals';
import * as Panel from '../../PanelUtils'; import * as Panel from '../../PanelUtils';
import CustomFieldEntry from './CustomFieldEntry'; import CustomFieldEntry from './CustomFieldEntry';
import CustomFieldForm from './CustomFieldForm'; import CustomFieldForm from './CustomFieldForm';
const customFieldsDocsUrl = 'https://docs.getontime.no/features/custom-fields/';
export default function CustomFields() { export default function CustomFields() {
const { data, refetch } = useCustomFields(); const { data, refetch } = useCustomFields();
const [isAdding, setIsAdding] = useState(false); const [isAdding, setIsAdding] = useState(false);
@@ -59,9 +58,14 @@ export default function CustomFields() {
<Alert status='info' variant='ontime-on-dark-info'> <Alert status='info' variant='ontime-on-dark-info'>
<AlertIcon /> <AlertIcon />
<AlertDescription> <AlertDescription>
Custom fields allow for additional information to be added to an event (eg. light, sound, camera). <br /> Custom fields allow for additional information to be added to an event.
<br /> <br />
This data is not used by Ontime. <br />
This data is not used by Ontime, but provides place for cueing or department specific information (eg.
light, sound, camera).
<br />
<br />
Custom fields can be used width the Integrations feature using the generated key.
<ExternalLink href={customFieldsDocsUrl}>See the docs</ExternalLink> <ExternalLink href={customFieldsDocsUrl}>See the docs</ExternalLink>
</AlertDescription> </AlertDescription>
</Alert> </Alert>
@@ -73,6 +77,7 @@ export default function CustomFields() {
<tr> <tr>
<th>Colour</th> <th>Colour</th>
<th>Name</th> <th>Name</th>
<th>Key (used in Integrations)</th>
<th /> <th />
</tr> </tr>
</thead> </thead>
+1 -1
View File
@@ -1,7 +1,7 @@
export const ontimeAlertOnDark = { export const ontimeAlertOnDark = {
container: { container: {
fontSize: 'calc(1rem - 1px)', fontSize: 'calc(1rem - 1px)',
backgroundColor: '#1a1a1a', // $gray-1300 backgroundColor: '#202020', // $gray-1200
color: '#e2e2e2', // $gray-200 color: '#e2e2e2', // $gray-200
borderRadius: '3px', borderRadius: '3px',
}, },
@@ -1,4 +1,4 @@
import { isAlphanumeric } from 'ontime-utils'; import { isAlphanumericWithSpace } from 'ontime-utils';
import { Request, Response, NextFunction } from 'express'; import { Request, Response, NextFunction } from 'express';
import { body, param, validationResult } from 'express-validator'; import { body, param, validationResult } from 'express-validator';
@@ -9,7 +9,7 @@ export const validateCustomField = [
.isString() .isString()
.trim() .trim()
.custom((value) => { .custom((value) => {
return isAlphanumeric(value); return isAlphanumericWithSpace(value);
}), }),
body('type').exists().isString().trim(), body('type').exists().isString().trim(),
body('colour').exists().isString().trim(), body('colour').exists().isString().trim(),
@@ -85,7 +85,7 @@ export class OscIntegration implements IIntegration<OscSubscription, OSCSettings
private initTX(enabledOut: boolean, targetIP: string, portOut: number, subscriptions: OscSubscription[]) { private initTX(enabledOut: boolean, targetIP: string, portOut: number, subscriptions: OscSubscription[]) {
this.initSubscriptions(subscriptions); this.initSubscriptions(subscriptions);
if (!enabledOut && this.enabledOut) { if (!enabledOut) {
this.targetIP = targetIP; this.targetIP = targetIP;
this.portOut = portOut; this.portOut = portOut;
this.enabledOut = enabledOut; this.enabledOut = enabledOut;
@@ -104,6 +104,7 @@ export class OscIntegration implements IIntegration<OscSubscription, OSCSettings
try { try {
this.oscClient = new Client(targetIP, portOut); this.oscClient = new Client(targetIP, portOut);
logger.info(LogOrigin.Tx, `Starting OSC Clint on port: ${portOut}`);
} catch (error) { } catch (error) {
this.oscClient = null; this.oscClient = null;
throw new Error(`Failed initialising OSC client: ${error}`); throw new Error(`Failed initialising OSC client: ${error}`);
@@ -111,7 +112,7 @@ export class OscIntegration implements IIntegration<OscSubscription, OSCSettings
} }
private initRX(enabledIn: boolean, portIn: number) { private initRX(enabledIn: boolean, portIn: number) {
if (!enabledIn && this.enabledIn) { if (!enabledIn) {
this.shutdownRX(); this.shutdownRX();
return; return;
} }
@@ -925,7 +925,7 @@ describe('custom fields', () => {
describe('createCustomField()', () => { describe('createCustomField()', () => {
it('creates a field from given parameters', async () => { it('creates a field from given parameters', async () => {
const expected = { const expected = {
lighting: { Lighting: {
label: 'Lighting', label: 'Lighting',
type: 'string', type: 'string',
colour: 'blue', colour: 'blue',
@@ -942,19 +942,19 @@ describe('custom fields', () => {
await createCustomField({ label: 'Sound', type: 'string', colour: 'blue' }); await createCustomField({ label: 'Sound', type: 'string', colour: 'blue' });
const expected = { const expected = {
lighting: { Lighting: {
label: 'Lighting', label: 'Lighting',
type: 'string', type: 'string',
colour: 'blue', colour: 'blue',
}, },
sound: { Sound: {
label: 'Sound', label: 'Sound',
type: 'string', type: 'string',
colour: 'green', 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(customFieldChangelog).toStrictEqual(new Map());
expect(customField).toStrictEqual(expected); expect(customField).toStrictEqual(expected);
@@ -964,17 +964,17 @@ describe('custom fields', () => {
const created = await createCustomField({ label: 'Video', type: 'string', colour: 'red' }); const created = await createCustomField({ label: 'Video', type: 'string', colour: 'red' });
const expected = { const expected = {
lighting: { Lighting: {
label: 'Lighting', label: 'Lighting',
type: 'string', type: 'string',
colour: 'blue', colour: 'blue',
}, },
sound: { Sound: {
label: 'Sound', label: 'Sound',
type: 'string', type: 'string',
colour: 'green', colour: 'green',
}, },
video: { Video: {
label: 'Video', label: 'Video',
type: 'string', type: 'string',
colour: 'red', colour: 'red',
@@ -984,17 +984,17 @@ describe('custom fields', () => {
expect(created).toStrictEqual(expected); expect(created).toStrictEqual(expected);
const expectedAfter = { const expectedAfter = {
lighting: { Lighting: {
label: 'Lighting', label: 'Lighting',
type: 'string', type: 'string',
colour: 'blue', colour: 'blue',
}, },
sound: { Sound: {
label: 'Sound', label: 'Sound',
type: 'string', type: 'string',
colour: 'green', colour: 'green',
}, },
av: { AV: {
label: 'AV', label: 'AV',
type: 'string', type: 'string',
colour: 'red', colour: 'red',
@@ -1003,10 +1003,10 @@ describe('custom fields', () => {
// We need to flush all scheduled tasks for the generate function to settle // We need to flush all scheduled tasks for the generate function to settle
vi.useFakeTimers(); 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(customField).toStrictEqual(expectedAfter);
expect(customFieldChangelog).toStrictEqual(new Map([['video', 'av']])); expect(customFieldChangelog).toStrictEqual(new Map([['Video', 'AV']]));
await editCustomField('av', { label: 'video' }); await editCustomField('AV', { label: 'Video' });
vi.runAllTimers(); vi.runAllTimers();
expect(customFieldChangelog).toStrictEqual(new Map()); expect(customFieldChangelog).toStrictEqual(new Map());
vi.useRealTimers(); vi.useRealTimers();
@@ -1016,19 +1016,19 @@ describe('custom fields', () => {
describe('removeCustomField()', () => { describe('removeCustomField()', () => {
it('deletes a field with a given label', async () => { it('deletes a field with a given label', async () => {
const expected = { const expected = {
lighting: { Lighting: {
label: 'Lighting', label: 'Lighting',
type: 'string', type: 'string',
colour: 'blue', colour: 'blue',
}, },
video: { Video: {
label: 'video', label: 'Video',
type: 'string', type: 'string',
colour: 'red', colour: 'red',
}, },
}; };
const customField = await removeCustomField('sound'); const customField = await removeCustomField('Sound');
expect(customField).toStrictEqual(expected); expect(customField).toStrictEqual(expected);
}); });
@@ -11,7 +11,15 @@ import {
OntimeRundownEntry, OntimeRundownEntry,
PlayableEvent, PlayableEvent,
} from 'ontime-types'; } 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 { getDataProvider } from '../../classes/data-provider/DataProvider.js';
import { createPatch } from '../../utils/parser.js'; import { createPatch } from '../../utils/parser.js';
import { apply } from './delayUtils.js'; import { apply } from './delayUtils.js';
@@ -447,7 +455,7 @@ function scheduleCustomFieldPersist(persistedCustomFields: CustomFields) {
*/ */
export const createCustomField = async (field: CustomField) => { export const createCustomField = async (field: CustomField) => {
const { label, type, colour } = field; const { label, type, colour } = field;
const key = label.toLowerCase(); const key = customFieldLabelToKey(label);
// check if label already exists // check if label already exists
const alreadyExists = Object.hasOwn(persistedCustomFields, key); const alreadyExists = Object.hasOwn(persistedCustomFields, key);
@@ -479,7 +487,7 @@ export const editCustomField = async (key: string, newField: Partial<CustomField
throw new Error('Change of field type is not allowed'); throw new Error('Change of field type is not allowed');
} }
const newKey = newField.label.toLowerCase(); const newKey = customFieldLabelToKey(newField.label);
persistedCustomFields[newKey] = { ...existingField, ...newField }; persistedCustomFields[newKey] = { ...existingField, ...newField };
if (key !== newKey) { if (key !== newKey) {
@@ -279,7 +279,7 @@ describe('sanitiseCustomFields()', () => {
const customFields: CustomFields = { const customFields: CustomFields = {
test: { label: 'test', type: 'string', colour: 'red' }, test: { label: 'test', type: 'string', colour: 'red' },
test2: { label: 'test2', type: 'string', colour: 'green' }, test2: { label: 'test2', type: 'string', colour: 'green' },
test3: { label: 'Test3', type: 'string', colour: '' }, Test3: { label: 'Test3', type: 'string', colour: '' },
}; };
const sanitationResult = sanitiseCustomFields(customFields); const sanitationResult = sanitiseCustomFields(customFields);
expect(sanitationResult).toStrictEqual(customFields); expect(sanitationResult).toStrictEqual(customFields);
@@ -328,10 +328,32 @@ describe('sanitiseCustomFields()', () => {
it('enforce name cohesion', () => { it('enforce name cohesion', () => {
const customFields: CustomFields = { const customFields: CustomFields = {
test: { label: 'New Name', type: 'string', colour: 'red' }, test: { label: 'NewName', type: 'string', colour: 'red' },
}; };
const expectedCustomFields: CustomFields = { 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); const sanitationResult = sanitiseCustomFields(customFields);
expect(sanitationResult).toStrictEqual(expectedCustomFields); expect(sanitationResult).toStrictEqual(expectedCustomFields);
@@ -342,12 +364,12 @@ describe('sanitiseCustomFields()', () => {
test: { label: 'test', type: 'string', colour: 'red' }, test: { label: 'test', type: 'string', colour: 'red' },
test2: { label: 'test2', type: 'string', colour: 'green' }, test2: { label: 'test2', type: 'string', colour: 'green' },
bad: { label: '', type: 'string', colour: '' }, bad: { label: '', type: 'string', colour: '' },
test3: { label: 'Test3', type: 'string', colour: '' }, Test3: { label: 'Test3', type: 'string', colour: '' },
}; };
const expectedCustomFields: CustomFields = { const expectedCustomFields: CustomFields = {
test: { label: 'test', type: 'string', colour: 'red' }, test: { label: 'test', type: 'string', colour: 'red' },
test2: { label: 'test2', type: 'string', colour: 'green' }, test2: { label: 'test2', type: 'string', colour: 'green' },
test3: { label: 'Test3', type: 'string', colour: '' }, Test3: { label: 'Test3', type: 'string', colour: '' },
}; };
const sanitationResult = sanitiseCustomFields(customFields); const sanitationResult = sanitiseCustomFields(customFields);
expect(sanitationResult).toStrictEqual(expectedCustomFields); expect(sanitationResult).toStrictEqual(expectedCustomFields);
+15 -4
View File
@@ -19,7 +19,13 @@ import {
isOntimeDelay, isOntimeDelay,
isOntimeEvent, isOntimeEvent,
} from 'ontime-types'; } 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 { dbModel } from '../models/dataModel.js';
import { block as blockDef, delay as delayDef } from '../models/eventsDefinition.js'; import { block as blockDef, delay as delayDef } from '../models/eventsDefinition.js';
@@ -305,13 +311,18 @@ export function parseCustomFields(data: Partial<DatabaseModel>, emitError?: Erro
export function sanitiseCustomFields(data: object): CustomFields { export function sanitiseCustomFields(data: object): CustomFields {
const newCustomFields: CustomFields = {}; const newCustomFields: CustomFields = {};
for (const [_key, field] of Object.entries(data)) { for (const [originalKey, field] of Object.entries(data)) {
if (!isValidField(field)) { if (!isValidField(field)) {
continue; continue;
} }
// make a new key to avoid mismatches if (!isAlphanumericWithSpace(field.label)) {
const key = field.label.toLowerCase(); 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) { if (key in newCustomFields) {
continue; continue;
} }
+3 -1
View File
@@ -53,10 +53,12 @@ export {
removeTrailingZero, removeTrailingZero,
} from './src/date-utils/timeFormatting.js'; } from './src/date-utils/timeFormatting.js';
export { parseUserTime } from './src/date-utils/parseUserTime.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 { isColourHex } from './src/regex-utils/isColourHex.js';
export { splitWhitespace } from './src/regex-utils/splitWhitespace.js'; export { splitWhitespace } from './src/regex-utils/splitWhitespace.js';
export { customFieldLabelToKey } from './src/customField-utils/customFieldLabelToKey.js';
// helpers from externals // helpers from externals
export { deepmerge } from './src/externals/deepmerge.js'; export { deepmerge } from './src/externals/deepmerge.js';
@@ -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;
};
@@ -6,3 +6,12 @@ export const isAlphanumeric = (text: string): boolean => {
const regex = /^[a-z0-9]+$/i; const regex = /^[a-z0-9]+$/i;
return regex.test(text); 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);
};