mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-09 09:23:51 +00:00
custom fields (#744)
--------- Co-authored-by: Carlos Valente <carlosvalente@pm.me>
This commit is contained in:
committed by
GitHub
parent
1a420a1ddd
commit
c1377544a0
@@ -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();
|
||||
},
|
||||
];
|
||||
|
||||
@@ -28,6 +28,7 @@ export const dbModel: DatabaseModel = {
|
||||
endMessage: '',
|
||||
},
|
||||
aliases: [],
|
||||
customFields: {},
|
||||
userFields: {
|
||||
user0: 'user0',
|
||||
user1: 'user1',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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>;
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
Reference in New Issue
Block a user