custom fields (#744)

---------

Co-authored-by: Carlos Valente <carlosvalente@pm.me>
This commit is contained in:
Alex Christoffer Rasmussen
2024-02-17 21:55:46 +01:00
committed by GitHub
parent 1a420a1ddd
commit c1377544a0
17 changed files with 347 additions and 6 deletions
@@ -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<CustomFields> {
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();
}
@@ -6,7 +6,7 @@ import { DatabaseModel } from 'ontime-types';
* @param {object} newData
*/
export function safeMerge(existing: DatabaseModel, newData: Partial<DatabaseModel>) {
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<DatabaseMode
settings: { ...existing.settings, ...settings },
viewSettings: { ...existing.viewSettings, ...viewSettings },
aliases: aliases ?? existing.aliases,
customFields: customFields ?? existing.customFields,
userFields: {
...existing.userFields,
...(userFields && Object.fromEntries(Object.entries(userFields).filter(([_, value]) => value !== null))),
@@ -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);
});
});
@@ -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<CustomFields>) => {
res.json(DataProvider.getCustomFields());
};
// Expects { label: <lable> 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: <oldLable>, field: { label: <newlable> 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: <lable> }
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() });
}
};
@@ -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();
},
];
+1
View File
@@ -28,6 +28,7 @@ export const dbModel: DatabaseModel = {
endMessage: '',
},
aliases: [],
customFields: {},
userFields: {
user0: 'user0',
user1: 'user1',
+21 -2
View File
@@ -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);
@@ -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);
});
});
+63
View File
@@ -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<CustomField>) => {
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;
};
+2
View File
@@ -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<DatabaseModel>): Promise<Datab
viewSettings: parseViewSettings(jsonData) ?? dbModel.viewSettings,
aliases: parseAliases(jsonData),
userFields: parseUserFields(jsonData),
customFields: parseCustomFields(jsonData),
osc: parseOsc(jsonData) ?? dbModel.osc,
http: parseHttp(jsonData) ?? dbModel.http,
};
+21
View File
@@ -13,6 +13,7 @@ import {
isOntimeEvent,
isOntimeDelay,
isOntimeBlock,
CustomFields,
isOntimeCycle,
HttpSubscription,
} from 'ontime-types';
@@ -276,3 +277,23 @@ export const parseUserFields = (data): UserFields => {
}
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 };
};
@@ -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;
};
@@ -0,0 +1,6 @@
export type CustomField = {
type: string;
label: string;
};
export type CustomFields = Record<string, CustomField>;
+3
View File
@@ -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';
+1
View File
@@ -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
@@ -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);
}
});
});
@@ -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);
};