diff --git a/apps/server/src/classes/data-provider/DataProvider.ts b/apps/server/src/classes/data-provider/DataProvider.ts index e62530979..6cd09b875 100644 --- a/apps/server/src/classes/data-provider/DataProvider.ts +++ b/apps/server/src/classes/data-provider/DataProvider.ts @@ -11,6 +11,7 @@ import { UserFields, Alias, Settings, + CustomFields, HttpSettings, } from 'ontime-types'; @@ -32,6 +33,16 @@ export class DataProvider { return data.project; } + static async setCustomFields(newData: CustomFields): Promise { + data.customFields = { ...newData }; + await this.persist(); + return data.customFields; + } + + static getCustomFields(): CustomFields { + return data.customFields; + } + static async setRundown(newData: OntimeRundown) { data.rundown = [...newData]; await this.persist(); @@ -110,6 +121,7 @@ export class DataProvider { data.http = mergedData.http; data.aliases = mergedData.aliases; data.userFields = mergedData.userFields; + data.customFields = mergedData.customFields; data.rundown = mergedData.rundown; await this.persist(); } diff --git a/apps/server/src/classes/data-provider/DataProvider.utils.ts b/apps/server/src/classes/data-provider/DataProvider.utils.ts index f49e39221..9ddfeeaab 100644 --- a/apps/server/src/classes/data-provider/DataProvider.utils.ts +++ b/apps/server/src/classes/data-provider/DataProvider.utils.ts @@ -6,7 +6,7 @@ import { DatabaseModel } from 'ontime-types'; * @param {object} newData */ export function safeMerge(existing: DatabaseModel, newData: Partial) { - const { rundown, project, settings, viewSettings, aliases, userFields, osc, http } = newData || {}; + const { rundown, project, settings, viewSettings, aliases, customFields, userFields, osc, http } = newData || {}; return { ...existing, @@ -15,6 +15,7 @@ export function safeMerge(existing: DatabaseModel, newData: Partial value !== null))), diff --git a/apps/server/src/classes/data-provider/__tests__/DataProvider.test.ts b/apps/server/src/classes/data-provider/__tests__/DataProvider.test.ts index 2e125ea7d..88ab76aa3 100644 --- a/apps/server/src/classes/data-provider/__tests__/DataProvider.test.ts +++ b/apps/server/src/classes/data-provider/__tests__/DataProvider.test.ts @@ -24,11 +24,26 @@ describe('safeMerge', () => { viewSettings: { overrideStyles: false, endMessage: 'existing endMessage', + normalColor: '#ffffffcc', + warningColor: '#FFAB33', + dangerColor: '#ED3333', }, aliases: [], userFields: { user0: 'existing user0', user1: 'existing user1', + user2: 'existing user2', + user3: 'existing user3', + user4: 'existing user4', + user5: 'existing user5', + user6: 'existing user6', + user7: 'existing user7', + user8: 'existing user8', + user9: 'existing user9', + }, + customFields: { + lighting: { type: 'string', label: 'lighting' }, + vfx: { type: 'string', label: 'vfx' }, }, osc: { portIn: 8888, @@ -212,4 +227,29 @@ describe('safeMerge', () => { const result = safeMerge(existing, newData); expect(result.userFields).toEqual(expected); }); + + it('merges customFields into existing object', () => { + const existing = { + customFields: { + lighting: { type: 'string', label: 'lighting' }, + sound: { type: 'string', label: 'sound' }, + }, + }; + + const newData = { + customFields: { + switcher: { type: 'string', label: 'switcher' }, + vfx: { type: 'string', label: 'vfx' }, + }, + }; + + const expected = { + switcher: { type: 'string', label: 'switcher' }, + vfx: { type: 'string', label: 'vfx' }, + }; + + //@ts-expect-error -- testing partial merge + const result = safeMerge(existing, newData); + expect(result.customFields).toEqual(expected); + }); }); diff --git a/apps/server/src/controllers/projectController.ts b/apps/server/src/controllers/projectController.ts index 095cc2702..2996a1568 100644 --- a/apps/server/src/controllers/projectController.ts +++ b/apps/server/src/controllers/projectController.ts @@ -1,10 +1,11 @@ -import { RequestHandler } from 'express'; +import type { Request, Response, RequestHandler } from 'express'; -import { ProjectData } from 'ontime-types'; +import { CustomField, CustomFields, ProjectData } from 'ontime-types'; import { removeUndefined } from '../utils/parserUtils.js'; import { failEmptyObjects } from '../utils/routerUtils.js'; import { DataProvider } from '../classes/data-provider/DataProvider.js'; +import { createCustomField, editCustomField, removeCustomField } from '../utils/customFields.js'; // Create controller for GET request to 'project' export const getProject: RequestHandler = async (req, res) => { @@ -33,3 +34,47 @@ export const postProject: RequestHandler = async (req, res) => { res.status(400).send({ message: error.toString() }); } }; + +export const getCustomFields: RequestHandler = async (_req: Request, res: Response) => { + res.json(DataProvider.getCustomFields()); +}; + +// Expects { label: type: 'string | ..' } +export const postCustomField: RequestHandler = async (req: Request, res: Response) => { + if (failEmptyObjects(req.body, res)) { + return; + } + + try { + const newField = req.body as CustomField; + const allFields = await createCustomField(newField); + res.status(201).send(allFields); + } catch (error) { + res.status(400).send({ message: error.toString() }); + } +}; + +// Expects { label: , field: { label: type: 'string | ..' } } +export const putCustomField: RequestHandler = async (req: Request, res: Response) => { + if (failEmptyObjects(req.body, res)) { + return; + } + + try { + const newFields = await editCustomField(req.body.label, req.body.field); + res.status(200).send(newFields); + } catch (error) { + res.status(400).send({ message: error.toString() }); + } +}; + +// Expects { label: } +export const deleteCustomField: RequestHandler = async (req: Request, res: Response) => { + try { + const fieldToDelete = req.params.label; + await removeCustomField(fieldToDelete); + res.sendStatus(204); + } catch (error) { + res.status(400).send({ message: error.toString() }); + } +}; diff --git a/apps/server/src/controllers/projectController.validate.ts b/apps/server/src/controllers/projectController.validate.ts index ceae196ef..e42ad66a7 100644 --- a/apps/server/src/controllers/projectController.validate.ts +++ b/apps/server/src/controllers/projectController.validate.ts @@ -16,3 +16,37 @@ export const projectSanitiser = [ next(); }, ]; + +export const validateCustomField = [ + body('label').isString().trim(), + body('type').isString().trim(), + + (req: Request, res: Response, next: NextFunction) => { + const errors = validationResult(req); + if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() }); + next(); + }, +]; + +export const validateEditCustomField = [ + body('label').isString().trim(), + body('field.label').optional().isString().trim(), + body('field.type').optional().isString().trim(), + + (req: Request, res: Response, next: NextFunction) => { + const errors = validationResult(req); + if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() }); + next(); + }, +]; + + +export const valdiateDeleteCustomField = [ + body('label').isString(), + + (req: Request, res: Response, next: NextFunction) => { + const errors = validationResult(req); + if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() }); + next(); + }, +]; diff --git a/apps/server/src/models/dataModel.ts b/apps/server/src/models/dataModel.ts index 6ade1cb2a..73ead6c0d 100644 --- a/apps/server/src/models/dataModel.ts +++ b/apps/server/src/models/dataModel.ts @@ -28,6 +28,7 @@ export const dbModel: DatabaseModel = { endMessage: '', }, aliases: [], + customFields: {}, userFields: { user0: 'user0', user1: 'user1', diff --git a/apps/server/src/routes/projectRouter.ts b/apps/server/src/routes/projectRouter.ts index ae1907881..c498422cc 100644 --- a/apps/server/src/routes/projectRouter.ts +++ b/apps/server/src/routes/projectRouter.ts @@ -1,6 +1,17 @@ import express from 'express'; -import { getProject, postProject } from '../controllers/projectController.js'; -import { projectSanitiser } from '../controllers/projectController.validate.js'; +import { + deleteCustomField, + getCustomFields, + getProject, + postCustomField, + postProject, + putCustomField, +} from '../controllers/projectController.js'; +import { + projectSanitiser, + validateCustomField, + validateEditCustomField, +} from '../controllers/projectController.validate.js'; export const router = express.Router(); @@ -9,3 +20,11 @@ router.get('/', getProject); // create route between controller and 'POST /project' endpoint router.post('/', projectSanitiser, postProject); + +router.get('/custom-field', getCustomFields); + +router.post('/custom-field', validateCustomField, postCustomField); + +router.put('/custom-field', validateEditCustomField, putCustomField); + +router.delete('/custom-field/:label', deleteCustomField); diff --git a/apps/server/src/utils/__tests__/customFields.test.ts b/apps/server/src/utils/__tests__/customFields.test.ts new file mode 100644 index 000000000..921d3ef0d --- /dev/null +++ b/apps/server/src/utils/__tests__/customFields.test.ts @@ -0,0 +1,67 @@ +import { createCustomField, editCustomField, removeCustomField } from '../customFields.js'; + +describe('createCustomField()', () => { + beforeEach(() => { + vi.mock('../../classes/data-provider/DataProvider.js', () => { + return { + DataProvider: { + ...vi.fn().mockImplementation(() => { + return {}; + }), + getCustomFields: vi.fn().mockReturnValue({}), + setCustomFields: vi.fn().mockImplementation((newData) => { + return newData; + }), + }, + }; + }); + }); + + it('creates a field from given parameters', async () => { + const expected = { + lighting: { + label: 'lighting', + type: 'text', + }, + }; + + const customField = await createCustomField({ label: 'lighting', type: 'text' }); + expect(customField).toStrictEqual(expected); + }); +}); + +describe('editCustomField()', () => { + it('edits a field with a given label', async () => { + await createCustomField({ label: 'sound', type: 'text' }); + + const expected = { + lighting: { + label: 'lighting', + type: 'text', + }, + sound: { + label: 'sound', + type: 'number', + }, + }; + + const customField = await editCustomField('sound', { label: 'sound', type: 'number' }); + + expect(customField).toStrictEqual(expected); + }); +}); + +describe('removeCustomField()', () => { + it('deletes a field with a given label', async () => { + const expected = { + lighting: { + label: 'lighting', + type: 'text', + }, + }; + + const customField = await removeCustomField('sound'); + + expect(customField).toStrictEqual(expected); + }); +}); diff --git a/apps/server/src/utils/customFields.ts b/apps/server/src/utils/customFields.ts new file mode 100644 index 000000000..2e7dcec95 --- /dev/null +++ b/apps/server/src/utils/customFields.ts @@ -0,0 +1,63 @@ +import { isAlphanumeric } from 'ontime-utils'; +import { DataProvider } from '../classes/data-provider/DataProvider.js'; +import { CustomField } from 'ontime-types'; + +/** + * Sanitises and creates a custom field in the database + * @param field + * @returns + */ +export const createCustomField = async (field: CustomField) => { + if (!isAlphanumeric(field.label)) { + throw new Error('Label must be Alphanumeric'); + } + + const customFields = DataProvider.getCustomFields(); + if (Object.keys(customFields).find((f) => f === field.label) !== undefined) { + throw new Error('Label already exists'); + } + + Object.assign(customFields, { [field.label]: field }); + const newCustomFields = await DataProvider.setCustomFields(customFields); + + return newCustomFields; +}; + +/** + * Edits an existing custom field in the database + * @param label + * @param field + * @returns + */ +export const editCustomField = async (label: string, field: Partial) => { + const existingFields = DataProvider.getCustomFields(); + if (!(label in existingFields)) { + throw new Error('Could not find label'); + } + + const existingField = existingFields[label]; + if (!existingField) { + throw new Error('Could not find label'); + } + + existingFields[label] = { ...existingField, ...field }; + + const newCustomFields = await DataProvider.setCustomFields(existingFields); + return newCustomFields; +}; + +/** + * Deletes a custom field from the database + * @param label + */ +export const removeCustomField = async (label: string) => { + const existingFields = DataProvider.getCustomFields(); + if (!(label in existingFields)) { + throw new Error('Could not find label'); + } + + delete existingFields[label]; + + const newCustomFields = await DataProvider.setCustomFields(existingFields); + return newCustomFields; +}; diff --git a/apps/server/src/utils/parser.ts b/apps/server/src/utils/parser.ts index 20ba3f86d..0d75a1c76 100644 --- a/apps/server/src/utils/parser.ts +++ b/apps/server/src/utils/parser.ts @@ -37,6 +37,7 @@ import { parseSettings, parseUserFields, parseViewSettings, + parseCustomFields, } from './parserFunctions.js'; import { parseExcelDate } from './time.js'; import { configService } from '../services/ConfigService.js'; @@ -330,6 +331,7 @@ export const parseJson = async (jsonData: Partial): Promise { } return { ...newUserFields }; }; + +/** + * Parse customFields entry + * @param {object} data - data object + * @returns {object} - event object data + */ +export const parseCustomFields = (data): CustomFields => { + let newCustomFields: CustomFields = { ...dbModel.customFields }; + + if ('customFields' in data) { + console.log('Found Custom Fields definition, importing...'); + try { + //TODO: validate + newCustomFields = { ...dbModel.customFields, ...data.customFields }; + } catch (error) { + console.log(`Error: ${error}`); + } + } + return { ...newCustomFields }; +}; diff --git a/packages/types/src/definitions/DataModel.type.ts b/packages/types/src/definitions/DataModel.type.ts index b72509771..b63879d75 100644 --- a/packages/types/src/definitions/DataModel.type.ts +++ b/packages/types/src/definitions/DataModel.type.ts @@ -5,7 +5,7 @@ import { OSCSettings } from './core/OscSettings.type.js'; import { Settings } from './core/Settings.type.js'; import { UserFields } from './core/UserFields.type.js'; import { ViewSettings } from './core/Views.type.js'; -import { HttpSettings } from '../index.js'; +import { CustomFields, HttpSettings } from '../index.js'; export type DatabaseModel = { rundown: OntimeRundown; @@ -14,6 +14,7 @@ export type DatabaseModel = { viewSettings: ViewSettings; aliases: Alias[]; userFields: UserFields; + customFields: CustomFields; osc: OSCSettings; http: HttpSettings; }; diff --git a/packages/types/src/definitions/core/CustomFields.type.ts b/packages/types/src/definitions/core/CustomFields.type.ts new file mode 100644 index 000000000..ae28c8a9a --- /dev/null +++ b/packages/types/src/definitions/core/CustomFields.type.ts @@ -0,0 +1,6 @@ +export type CustomField = { + type: string; + label: string; +}; + +export type CustomFields = Record; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 60731fddf..cd06f8ead 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -30,6 +30,9 @@ export type { Alias } from './definitions/core/Alias.type.js'; // ---> User Fields export type { UserFields } from './definitions/core/UserFields.type.js'; +// ---> Custom Fields +export type { CustomFields, CustomField } from './definitions/core/CustomFields.type.js'; + // ---> Integration, Subscription export type { OSCSettings, OscSubscription } from './definitions/core/OscSettings.type.js'; export type { HttpSettings, HttpSubscription } from './definitions/core/HttpSettings.type.js'; diff --git a/packages/utils/index.ts b/packages/utils/index.ts index 58752549d..fbbc5b039 100644 --- a/packages/utils/index.ts +++ b/packages/utils/index.ts @@ -42,6 +42,7 @@ export { removeSeconds, removeTrailingZero, } from './src/date-utils/timeFormatting.js'; +export { isAlphanumeric } from './src/regex-utils/isAlphanumeric.js'; export { isColourHex } from './src/regex-utils/isColourHex.js'; // time utils diff --git a/packages/utils/src/regex-utils/isAlphanumeric.test.ts b/packages/utils/src/regex-utils/isAlphanumeric.test.ts new file mode 100644 index 000000000..2d7f47deb --- /dev/null +++ b/packages/utils/src/regex-utils/isAlphanumeric.test.ts @@ -0,0 +1,17 @@ +import { isAlphanumeric } from './isAlphanumeric'; + +describe('test isAlphanumeric() function', () => { + it('it OK strings', () => { + const ts = ['abcdefghijklmnopqrstuvwxyz', '0123456798', '123asd']; + for (const s of ts) { + expect(isAlphanumeric(s)).toBe(true); + } + }); + + it('it bad strings', () => { + const ts = ['!abcd1234', 'åøæ', '*']; + for (const s of ts) { + expect(isAlphanumeric(s)).toBe(false); + } + }); +}); diff --git a/packages/utils/src/regex-utils/isAlphanumeric.ts b/packages/utils/src/regex-utils/isAlphanumeric.ts new file mode 100644 index 000000000..c4e514faa --- /dev/null +++ b/packages/utils/src/regex-utils/isAlphanumeric.ts @@ -0,0 +1,8 @@ +/** + * @description Validates a alphanumeric string + * @returns {boolean} + */ +export const isAlphanumeric = (text: string): boolean => { + const regex = /^[a-z0-9]+$/i; + return regex.test(text); +};