Custom fields views (#789)

* style: show field colour in editor

* feat: custom fields in cuesheet

* feat: custom fields in operator

* refactor: update CSV export
This commit is contained in:
Carlos Valente
2024-02-24 22:04:20 +01:00
committed by GitHub
parent 474f1e2177
commit da7ef59216
22 changed files with 229 additions and 539 deletions
@@ -1,4 +1,4 @@
import { UserFields } from 'ontime-types'; import { CustomFields } from 'ontime-types';
import { ParamField } from './types'; import { ParamField } from './types';
@@ -404,7 +404,11 @@ export const getStudioClockOptions = (timeFormat: string): ParamField[] => [
hideTimerSeconds, hideTimerSeconds,
]; ];
export const getOperatorOptions = (userFields: UserFields, timeFormat: string): ParamField[] => { export const getOperatorOptions = (customFields: CustomFields, timeFormat: string): ParamField[] => {
const customFieldSelect = Object.keys(customFields).reduce((acc, key) => {
return { ...acc, [key]: key };
}, {});
return [ return [
getTimeOption(timeFormat), getTimeOption(timeFormat),
{ {
@@ -439,25 +443,14 @@ export const getOperatorOptions = (userFields: UserFields, timeFormat: string):
{ {
id: 'subscribe', id: 'subscribe',
title: 'Highlight Field', title: 'Highlight Field',
description: 'Choose a field to highlight', description: 'Choose a custom field to highlight',
type: 'option', type: 'option',
values: { values: customFieldSelect,
user0: userFields.user0 || 'user0',
user1: userFields.user1 || 'user1',
user2: userFields.user2 || 'user2',
user3: userFields.user3 || 'user3',
user4: userFields.user4 || 'user4',
user5: userFields.user5 || 'user5',
user6: userFields.user6 || 'user6',
user7: userFields.user7 || 'user7',
user8: userFields.user8 || 'user8',
user9: userFields.user9 || 'user9',
},
}, },
{ {
id: 'shouldEdit', id: 'shouldEdit',
title: 'Edit user field', title: 'Edit custom field',
description: 'Allows editing an events user field by long pressing on it. Needs a selected highlighted field', description: 'Allows editing an events selected custom field by long pressing.',
type: 'boolean', type: 'boolean',
defaultValue: false, defaultValue: false,
}, },
@@ -150,6 +150,13 @@ export const useEventAction = () => {
[_updateEventMutation], [_updateEventMutation],
); );
const updateCustomField = useCallback(
async (eventId: string, field: string, value: string) => {
updateEvent({ id: eventId, custom: { [field]: { value } } });
},
[updateEvent],
);
type TimeField = 'timeStart' | 'timeEnd' | 'duration'; type TimeField = 'timeStart' | 'timeEnd' | 'duration';
/** /**
* Updates time of existing event * Updates time of existing event
@@ -553,5 +560,6 @@ export const useEventAction = () => {
swapEvents, swapEvents,
updateEvent, updateEvent,
updateTimer, updateTimer,
updateCustomField,
}; };
}; };
+9 -2
View File
@@ -11,6 +11,13 @@ type BlobOptions = {
type: string; type: string;
}; };
/**
* Gets DB from backend and prepares a file to be downloaded
* @param url
* @param fileOptions
* @param blobOptions
* @returns
*/
export default async function fileDownload(url: string, fileOptions: FileOptions, blobOptions: BlobOptions) { export default async function fileDownload(url: string, fileOptions: FileOptions, blobOptions: BlobOptions) {
const response = await axios({ const response = await axios({
url: `${url}/db`, url: `${url}/db`,
@@ -20,7 +27,7 @@ export default async function fileDownload(url: string, fileOptions: FileOptions
const headerLine = response.headers['Content-Disposition']; const headerLine = response.headers['Content-Disposition'];
let { name: fileName } = fileOptions; let { name: fileName } = fileOptions;
const { type: fileType } = fileOptions; const { type: fileType } = fileOptions;
const { project, rundown, userFields } = response.data; const { project, rundown, customFields } = response.data;
// try and get the filename from the response // try and get the filename from the response
if (headerLine != null) { if (headerLine != null) {
@@ -37,7 +44,7 @@ export default async function fileDownload(url: string, fileOptions: FileOptions
} }
if (fileType === 'csv') { if (fileType === 'csv') {
const sheetData = makeTable(project, rundown, userFields); const sheetData = makeTable(project, rundown, customFields);
fileContent = makeCSV(sheetData); fileContent = makeCSV(sheetData);
fileName += '.csv'; fileName += '.csv';
} }
@@ -1,11 +1,11 @@
import { useCallback, useEffect, useMemo } from 'react'; import { useCallback, useEffect, useMemo } from 'react';
import { OntimeRundownEntry, ProjectData } from 'ontime-types'; import { CustomFieldLabel, isOntimeEvent, ProjectData } from 'ontime-types';
import Empty from '../../common/components/state/Empty'; import Empty from '../../common/components/state/Empty';
import { useEventAction } from '../../common/hooks/useEventAction'; import { useEventAction } from '../../common/hooks/useEventAction';
import { useCuesheet } from '../../common/hooks/useSocket'; import { useCuesheet } from '../../common/hooks/useSocket';
import useCustomFields from '../../common/hooks-query/useCustomFields';
import { useFlatRundown } from '../../common/hooks-query/useRundown'; import { useFlatRundown } from '../../common/hooks-query/useRundown';
import useUserFields from '../../common/hooks-query/useUserFields';
import CuesheetProgress from './cuesheet-progress/CuesheetProgress'; import CuesheetProgress from './cuesheet-progress/CuesheetProgress';
import CuesheetTableHeader from './cuesheet-table-header/CuesheetTableHeader'; import CuesheetTableHeader from './cuesheet-table-header/CuesheetTableHeader';
@@ -18,18 +18,23 @@ import styles from './CuesheetWrapper.module.scss';
export default function CuesheetWrapper() { export default function CuesheetWrapper() {
// TODO: can we use the normalised rundown for the table? // TODO: can we use the normalised rundown for the table?
const { data: flatRundown, status: rundownStatus } = useFlatRundown(); const { data: flatRundown, status: rundownStatus } = useFlatRundown();
const { data: userFields } = useUserFields(); const { data: customFields } = useCustomFields();
const { updateEvent } = useEventAction();
const { updateCustomField } = useEventAction();
const featureData = useCuesheet(); const featureData = useCuesheet();
const columns = useMemo(() => makeCuesheetColumns(userFields), [userFields]); const columns = useMemo(() => makeCuesheetColumns(customFields), [customFields]);
// Set window title // Set window title
useEffect(() => { useEffect(() => {
document.title = 'ontime - Cuesheet'; document.title = 'ontime - Cuesheet';
}, []); }, []);
/**
* Handles updating a field
* Currently, only custom fields can be updated from the cuesheet
*/
const handleUpdate = useCallback( const handleUpdate = useCallback(
async (rowIndex: number, accessor: keyof OntimeRundownEntry, payload: unknown) => { async (rowIndex: number, accessor: CustomFieldLabel, payload: unknown) => {
if (!flatRundown || rundownStatus !== 'success') { if (!flatRundown || rundownStatus !== 'success') {
return; return;
} }
@@ -40,42 +45,41 @@ export default function CuesheetWrapper() {
// check if value is the same // check if value is the same
const event = flatRundown[rowIndex]; const event = flatRundown[rowIndex];
if (!event) { if (!event || !isOntimeEvent(event)) {
return; return;
} }
if (event[accessor] === payload) { const previousValue = event.custom[accessor]?.value;
if (previousValue === payload) {
return; return;
} }
// check if value is valid // check if value is valid
// as of now, the fields do not have any validation // in anticipation to different types of event here
if (typeof payload !== 'string') { if (typeof payload !== 'string') {
return; return;
} }
// cleanup // cleanup
const cleanVal = payload.trim(); const cleanVal = payload.trim();
const mutationObject = {
id: event.id,
[accessor]: cleanVal,
};
// submit // submit
try { try {
await updateEvent(mutationObject); await updateCustomField(event.id, accessor, cleanVal);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
} }
}, },
[flatRundown, rundownStatus, updateEvent], [flatRundown, rundownStatus, updateCustomField],
); );
const exportHandler = useCallback( const exportHandler = useCallback(
(headerData: ProjectData) => { (headerData: ProjectData) => {
if (!userFields || !flatRundown || rundownStatus !== 'success') { if (!flatRundown || rundownStatus !== 'success') {
return; return;
} }
const sheetData = makeTable(headerData, flatRundown, userFields); const sheetData = makeTable(headerData, flatRundown, customFields);
const csvContent = makeCSV(sheetData); const csvContent = makeCSV(sheetData);
const fileName = 'ontime rundown.csv'; const fileName = 'ontime rundown.csv';
@@ -92,10 +96,10 @@ export default function CuesheetWrapper() {
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
return; return;
}, },
[flatRundown, rundownStatus, userFields], [flatRundown, rundownStatus, customFields],
); );
if (!userFields || !flatRundown || rundownStatus !== 'success') { if (!customFields || !flatRundown || rundownStatus !== 'success') {
return <Empty text='Loading...' />; return <Empty text='Loading...' />;
} }
@@ -14,39 +14,32 @@ exports[`makeTable() > returns array of arrays with given fields 1`] = `
[ [
"Time Start", "Time Start",
"Time End", "Time End",
"Event Title", "Duration",
"Presenter Name", "ID",
"Event Subtitle",
"Is Public? (x)",
"Note",
"Colour", "Colour",
"End Action", "Cue",
"Timer Type", "Title",
"Subtitle",
"Presenter",
"Note",
"Is Public? (x)",
"Skip?", "Skip?",
"user0:test", "lighting",
], ],
[ [
"00:00:00", "00:00:00",
"00:00:00", "00:00:00",
"...",
"",
"",
"",
"test title 1", "test title 1",
"", "",
"", "",
"",
"x", "x",
"", "",
"", "",
"",
"",
"",
"test",
"test",
"",
"",
"",
"",
"",
"",
"",
"",
], ],
] ]
`; `;
@@ -1,12 +1,13 @@
import { makeCSV, makeTable, parseField } from '../cuesheetUtils'; import { makeCSV, makeTable, parseField } from '../cuesheetUtils';
describe('parseField()', () => { describe('parseField()', () => {
it('returns a string from given millis on timeStart and TimeEnd', () => { it('returns a string from given millis on timeStart, TimeEnd and duration', () => {
const testData1 = 1000; const testData1 = 1000;
const testData2 = 60000; const testData2 = 60000;
const testData3 = 600000;
expect(parseField('timeStart', testData1)).toBe('00:00:01'); expect(parseField('timeStart', testData1)).toBe('00:00:01');
expect(parseField('timeEnd', testData2)).toBe('00:01:00'); expect(parseField('timeEnd', testData2)).toBe('00:01:00');
expect(parseField('timeEnd', testData2)).toBe('00:01:00'); expect(parseField('duration', testData3)).toBe('00:10:00');
}); });
describe('returns an x when isPublic is truthy, empty string otherwise', () => { describe('returns an x when isPublic is truthy, empty string otherwise', () => {
@@ -31,22 +32,12 @@ describe('parseField()', () => {
describe('simply returns any other value in any other field', () => { describe('simply returns any other value in any other field', () => {
const testFields = [ const testFields = [
{ field: 'nothing', value: 123 }, { field: 'nothing', value: '123' },
{ field: 'title', value: 'test' }, { field: 'title', value: 'test' },
{ field: 'presenter', value: 'test' }, { field: 'presenter', value: 'test' },
{ field: 'subtitle', value: 'test' }, { field: 'subtitle', value: 'test' },
{ field: 'note', value: 'test' }, { field: 'note', value: 'test' },
{ field: 'colour', value: 'test' }, { field: 'colour', value: 'test' },
{ field: 'user0', value: 'test' },
{ field: 'user1', value: 'test' },
{ field: 'user2', value: 'test' },
{ field: 'user3', value: 'test' },
{ field: 'user4', value: 'test' },
{ field: 'user5', value: 'test' },
{ field: 'user6', value: 'test' },
{ field: 'user7', value: 'test' },
{ field: 'user8', value: 'test' },
{ field: 'user9', value: 'test' },
]; ];
testFields.forEach((testCase) => { testFields.forEach((testCase) => {
@@ -70,15 +61,15 @@ describe('makeTable()', () => {
timeStart: 0, timeStart: 0,
timeEnd: 0, timeEnd: 0,
isPublic: 'x', isPublic: 'x',
user0: 'test', lighting: { value: 'test lighting' },
user1: 'test', sound: { value: 'test sound' },
}, },
]; ];
const userFields = { const customFields = {
user0: 'test', lighting: { label: 'test' },
}; };
const table = makeTable(headerData, tableData, userFields); const table = makeTable(headerData, tableData, customFields);
expect(table).toMatchSnapshot(); expect(table).toMatchSnapshot();
}); });
}); });
@@ -15,6 +15,7 @@ import { flexRender, HeaderGroup } from '@tanstack/react-table';
import { OntimeRundownEntry } from 'ontime-types'; import { OntimeRundownEntry } from 'ontime-types';
import { useLocalStorage } from '../../../common/hooks/useLocalStorage'; import { useLocalStorage } from '../../../common/hooks/useLocalStorage';
import { getAccessibleColour } from '../../../common/utils/styleUtils';
import { tooltipDelayFast } from '../../../ontimeConfig'; import { tooltipDelayFast } from '../../../ontimeConfig';
import { initialColumnOrder } from '../cuesheetCols'; import { initialColumnOrder } from '../cuesheetCols';
@@ -89,9 +90,17 @@ function CuesheetHeader(props: CuesheetHeaderProps) {
<SortableContext key={key} items={headerGroup.headers} strategy={horizontalListSortingStrategy}> <SortableContext key={key} items={headerGroup.headers} strategy={horizontalListSortingStrategy}>
{headerGroup.headers.map((header) => { {headerGroup.headers.map((header) => {
const width = header.getSize(); const width = header.getSize();
// @ts-expect-error -- we inject this into react-table
const customBackground = header.column.columnDef?.meta?.colour;
let customStyles = {};
if (customBackground) {
const customColour = getAccessibleColour(customBackground);
customStyles = { backgroundColor: customColour.backgroundColor, color: customColour.color };
}
return ( return (
<SortableCell key={header.column.columnDef.id} header={header} style={{ width }}> <SortableCell key={header.column.columnDef.id} header={header} style={{ width, ...customStyles }}>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</SortableCell> </SortableCell>
); );
@@ -1,7 +1,7 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { IoCheckmark } from '@react-icons/all-files/io5/IoCheckmark'; import { IoCheckmark } from '@react-icons/all-files/io5/IoCheckmark';
import { CellContext, ColumnDef } from '@tanstack/react-table'; import { CellContext, ColumnDef } from '@tanstack/react-table';
import { OntimeEvent, OntimeRundownEntry, UserFields } from 'ontime-types'; import { CustomFields, isOntimeEvent, OntimeEvent, OntimeRundownEntry } from 'ontime-types';
import { millisToString } from 'ontime-utils'; import { millisToString } from 'ontime-utils';
import DelayIndicator from '../../common/components/delay-indicator/DelayIndicator'; import DelayIndicator from '../../common/components/delay-indicator/DelayIndicator';
@@ -33,22 +33,36 @@ function MakeTimer({ getValue, row: { original } }: CellContext<OntimeRundownEnt
); );
} }
function MakeUserField({ getValue, row: { index }, column: { id }, table }: CellContext<OntimeRundownEntry, unknown>) { function MakeCustomField({ row, column, table }: CellContext<OntimeRundownEntry, unknown>) {
const update = useCallback( const update = useCallback(
(newValue: string) => { (newValue: string) => {
// @ts-expect-error -- we inject this into react-table // @ts-expect-error -- we inject this into react-table
table.options.meta?.handleUpdate(index, id, newValue); table.options.meta?.handleUpdate(row.index, column.id, newValue);
}, },
// eslint-disable-next-line react-hooks/exhaustive-deps -- we skip table.options.meta since the reference seems unstable // eslint-disable-next-line react-hooks/exhaustive-deps -- we skip table.options.meta since the reference seems unstable
[id, index], [column.id, row.index],
); );
const initialValue = getValue() as string; const event = row.original;
if (!isOntimeEvent(event)) {
return null;
}
// events dont necessarily contain all custom fields
const initialValue = event.custom[column.id]?.value ?? '';
return <EditableCell value={initialValue} handleUpdate={update} />; return <EditableCell value={initialValue} handleUpdate={update} />;
} }
export function makeCuesheetColumns(userFields?: UserFields): ColumnDef<OntimeRundownEntry>[] { export function makeCuesheetColumns(customFields: CustomFields): ColumnDef<OntimeRundownEntry>[] {
const dynamicCustomFields = Object.keys(customFields).map((key) => ({
accessorKey: key,
id: key,
header: customFields[key].label,
meta: { colour: customFields[key].colour },
cell: MakeCustomField,
}));
return [ return [
{ {
accessorKey: 'cue', accessorKey: 'cue',
@@ -109,67 +123,8 @@ export function makeCuesheetColumns(userFields?: UserFields): ColumnDef<OntimeRu
header: 'Note', header: 'Note',
cell: (row) => row.getValue(), cell: (row) => row.getValue(),
}, },
{ ...dynamicCustomFields,
accessorKey: 'user0',
id: 'user0',
header: userFields?.user0 || 'User 0',
cell: MakeUserField,
},
{
accessorKey: 'user1',
id: 'user1',
header: userFields?.user1 || 'User 1',
cell: MakeUserField,
},
{
accessorKey: 'user2',
id: 'user2',
header: userFields?.user2 || 'User 2',
cell: MakeUserField,
},
{
accessorKey: 'user3',
id: 'user3',
header: userFields?.user3 || 'User 3',
cell: MakeUserField,
},
{
accessorKey: 'user4',
id: 'user4',
header: userFields?.user4 || 'User 4',
cell: MakeUserField,
},
{
accessorKey: 'user5',
id: 'user5',
header: userFields?.user5 || 'User 5',
cell: MakeUserField,
},
{
accessorKey: 'user6',
id: 'user6',
header: userFields?.user6 || 'User 6',
cell: MakeUserField,
},
{
accessorKey: 'user7',
id: 'user7',
header: userFields?.user7 || 'User 7',
cell: MakeUserField,
},
{
accessorKey: 'user8',
id: 'user8',
header: userFields?.user8 || 'User 8',
cell: MakeUserField,
},
{
accessorKey: 'user9',
id: 'user9',
header: userFields?.user9 || 'User 9',
cell: MakeUserField,
},
]; ];
} }
export const initialColumnOrder: string[] = makeCuesheetColumns().map((column) => column.id as string); export const initialColumnOrder: string[] = makeCuesheetColumns({}).map((column) => column.id as string);
@@ -1,7 +1,17 @@
import { stringify } from 'csv-stringify/browser/esm/sync'; import { stringify } from 'csv-stringify/browser/esm/sync';
import { OntimeEntryCommonKeys, OntimeRundown, ProjectData, UserFields } from 'ontime-types'; import {
CustomFields,
isOntimeDelay,
isOntimeEvent,
MaybeNumber,
OntimeEntryCommonKeys,
OntimeRundown,
ProjectData,
} from 'ontime-types';
import { millisToString } from 'ontime-utils'; import { millisToString } from 'ontime-utils';
type CsvHeaderKey = OntimeEntryCommonKeys | keyof CustomFields;
/** /**
* @description parses a field for export * @description parses a field for export
* @param {string} field * @param {string} field
@@ -9,90 +19,89 @@ import { millisToString } from 'ontime-utils';
* @return {string} * @return {string}
*/ */
export const parseField = <T extends OntimeEntryCommonKeys>(field: T, data: unknown): string => { export const parseField = (field: CsvHeaderKey, data: unknown): string => {
let val; if (field === 'timeStart' || field === 'timeEnd' || field === 'duration') {
switch (field) { return millisToString(data as MaybeNumber);
case 'timeStart':
case 'timeEnd':
val = millisToString(data as number | null);
break;
case 'isPublic':
case 'skip':
val = data ? 'x' : '';
break;
default:
val = data;
break;
} }
if (typeof data === 'undefined') {
return ''; if (field === 'isPublic' || field === 'skip') {
return data ? 'x' : '';
} }
// all other values are strings
return val as string; return String(data ?? '');
}; };
/** /**
* @description Creates an array of arrays usable by xlsx for export * @description Creates an array of arrays usable by xlsx for export
* @param {object} headerData * @param {ProjectData} headerData
* @param {array} rundown * @param {OntimeRundown} rundown
* @param {object} userFields * @param {CustomFields} customFields
* @return {(string[])[]} * @return {(string[])[]}
*/ */
export const makeTable = (headerData: ProjectData, rundown: OntimeRundown, userFields: UserFields): string[][] => { export const makeTable = (headerData: ProjectData, rundown: OntimeRundown, customFields: CustomFields): string[][] => {
// create metadata header row
const data = [['Ontime · Rundown export']]; const data = [['Ontime · Rundown export']];
if (headerData.title) data.push([`Project title: ${headerData.title}`]); if (headerData.title) data.push([`Project title: ${headerData.title}`]);
if (headerData.description) data.push([`Project description: ${headerData.description}`]); if (headerData.description) data.push([`Project description: ${headerData.description}`]);
const fieldOrder: OntimeEntryCommonKeys[] = [ const customFieldKeys = Object.keys(customFields).map((key) => `custom-${key}`);
const customFieldLabels = Object.keys(customFields);
// we chose not to expose internals of the application
const fieldOrder: CsvHeaderKey[] = [
'timeStart', 'timeStart',
'timeEnd', 'timeEnd',
'title', 'duration',
'presenter', 'id',
'subtitle',
'isPublic',
'note',
'colour', 'colour',
'endAction', 'cue',
'timerType', 'title',
'subtitle',
'presenter',
'note',
'isPublic',
'skip', 'skip',
'user0', ...customFieldKeys,
'user1',
'user2',
'user3',
'user4',
'user5',
'user6',
'user7',
'user8',
'user9',
]; ];
const fieldTitles = [ const fieldTitles = [
'Time Start', 'Time Start',
'Time End', 'Time End',
'Event Title', 'Duration',
'Presenter Name', 'ID',
'Event Subtitle',
'Is Public? (x)',
'Note',
'Colour', 'Colour',
'End Action', 'Cue',
'Timer Type', 'Title',
'Subtitle',
'Presenter',
'Note',
'Is Public? (x)',
'Skip?', 'Skip?',
...customFieldLabels,
]; ];
for (const field in userFields) { // add header row to data
const fieldValue = userFields[field as keyof UserFields];
const displayName = `${field}${fieldValue !== field && fieldValue !== '' ? `:${fieldValue}` : ''}`;
fieldTitles.push(displayName);
}
data.push(fieldTitles); data.push(fieldTitles);
rundown.forEach((entry) => { rundown.forEach((entry) => {
if (isOntimeDelay(entry)) return;
const row: string[] = []; const row: string[] = [];
// @ts-expect-error -- not sure how to type this fieldOrder.forEach((field) => {
fieldOrder.forEach((field) => row.push(parseField(field, entry[field]))); if (isOntimeEvent(entry)) {
// for custom fields, we need to extract the value from the custom object
if (field.startsWith('custom-')) {
const fieldLabel = field.split('custom-')[1];
const value = entry.custom[fieldLabel]?.value;
row.push(parseField(fieldLabel, value));
} else {
// @ts-expect-error -- it is ok, we will just not have the data for other fields
row.push(parseField(field, entry[field]));
}
return;
}
// @ts-expect-error -- it is ok, we will just not have the data for other fields
row.push(parseField(field, entry[field]));
});
data.push(row); data.push(row);
}); });
@@ -101,10 +110,10 @@ export const makeTable = (headerData: ProjectData, rundown: OntimeRundown, userF
/** /**
* @description Converts an array of arrays to a csv file * @description Converts an array of arrays to a csv file
* @param {array[]} arrayOfArrays * @param {string[][]} arrayOfArrays
* @return {string} * @return {string}
*/ */
export const makeCSV = (arrayOfArrays: string[][]) => { export const makeCSV = (arrayOfArrays: string[][]): string => {
const stringifiedData = stringify(arrayOfArrays); const stringifiedData = stringify(arrayOfArrays);
return stringifiedData; return stringifiedData;
}; };
@@ -1,183 +0,0 @@
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { Alert, AlertDescription, AlertIcon, AlertTitle, Input } from '@chakra-ui/react';
import { UserFields } from 'ontime-types';
import { logAxiosError } from '../../../common/api/apiUtils';
import { postUserFields } from '../../../common/api/ontimeApi';
import useUserFields from '../../../common/hooks-query/useUserFields';
import ModalLoader from '../modal-loader/ModalLoader';
import { inputProps } from '../modalHelper';
import ModalLink from '../ModalLink';
import ModalSplitInput from '../ModalSplitInput';
import OntimeModalFooter from '../OntimeModalFooter';
import style from './SettingsModal.module.scss';
const userFieldsDocsUrl = 'https://ontime.gitbook.io/v2/features/user-fields';
export default function CuesheetSettingsForm() {
const { data, status, isFetching, refetch } = useUserFields();
const {
handleSubmit,
register,
reset,
formState: { errors, isSubmitting, isDirty, isValid },
} = useForm<UserFields>({
defaultValues: data,
values: data,
resetOptions: {
keepDirtyValues: true,
},
});
useEffect(() => {
if (data) {
reset(data);
}
}, [data, reset]);
const onSubmit = async (formData: UserFields) => {
try {
await postUserFields(formData);
} catch (error) {
logAxiosError('Error saving cuesheet settings', error);
} finally {
await refetch();
}
};
const onReset = () => {
reset(data);
};
const disableInputs = status === 'pending';
if (isFetching) {
return <ModalLoader />;
}
return (
<form onSubmit={handleSubmit(onSubmit)} id='cuesheet-settings' className={style.sectionContainer}>
<div style={{ height: '16px' }} />
<Alert status='info' variant='ontime-on-light-info'>
<AlertIcon />
<div className={style.column}>
<AlertTitle>User Fields</AlertTitle>
<AlertDescription>
Allow for custom naming of additional data fields on each event (eg. light, sound, camera). <br />
<ModalLink href={userFieldsDocsUrl}>See the docs</ModalLink>
</AlertDescription>
</div>
</Alert>
<div style={{ height: '16px' }} />
<ModalSplitInput field='user0' title='User0' description='' error={errors.user0?.message}>
<Input
{...inputProps}
width='300px'
variant='ontime-filled-on-light'
isDisabled={disableInputs}
placeholder='Display name for user field'
{...register('user0')}
/>
</ModalSplitInput>
<ModalSplitInput field='user1' title='User1' description='' error={errors.user1?.message}>
<Input
{...inputProps}
width='300px'
variant='ontime-filled-on-light'
isDisabled={disableInputs}
placeholder='Display name for user field'
{...register('user1')}
/>
</ModalSplitInput>
<ModalSplitInput field='user2' title='User2' description='' error={errors.user2?.message}>
<Input
{...inputProps}
width='300px'
variant='ontime-filled-on-light'
isDisabled={disableInputs}
placeholder='Display name for user field'
{...register('user2')}
/>
</ModalSplitInput>
<ModalSplitInput field='user3' title='User3' description='' error={errors.user3?.message}>
<Input
{...inputProps}
width='300px'
variant='ontime-filled-on-light'
isDisabled={disableInputs}
placeholder='Display name for user field'
{...register('user3')}
/>
</ModalSplitInput>
<ModalSplitInput field='user4' title='User4' description='' error={errors.user4?.message}>
<Input
{...inputProps}
width='300px'
variant='ontime-filled-on-light'
isDisabled={disableInputs}
placeholder='Display name for user field'
{...register('user4')}
/>
</ModalSplitInput>
<ModalSplitInput field='user5' title='User5' description='' error={errors.user5?.message}>
<Input
{...inputProps}
width='300px'
variant='ontime-filled-on-light'
isDisabled={disableInputs}
placeholder='Display name for user field'
{...register('user5')}
/>
</ModalSplitInput>
<ModalSplitInput field='user6' title='User6' description='' error={errors.user6?.message}>
<Input
{...inputProps}
width='300px'
variant='ontime-filled-on-light'
isDisabled={disableInputs}
placeholder='Display name for user field'
{...register('user6')}
/>
</ModalSplitInput>
<ModalSplitInput field='user7' title='User7' description='' error={errors.user7?.message}>
<Input
{...inputProps}
width='300px'
variant='ontime-filled-on-light'
isDisabled={disableInputs}
placeholder='Display name for user field'
{...register('user7')}
/>
</ModalSplitInput>
<ModalSplitInput field='user8' title='User8' description='' error={errors.user8?.message}>
<Input
{...inputProps}
width='300px'
variant='ontime-filled-on-light'
isDisabled={disableInputs}
placeholder='Display name for user field'
{...register('user8')}
/>
</ModalSplitInput>
<ModalSplitInput field='user9' title='User9' description='' error={errors.user9?.message}>
<Input
{...inputProps}
width='300px'
variant='ontime-filled-on-light'
isDisabled={disableInputs}
placeholder='Display name for user field'
{...register('user9')}
/>
</ModalSplitInput>
<OntimeModalFooter
formId='cuesheet-settings'
handleRevert={onReset}
isDirty={isDirty}
isValid={isValid}
isSubmitting={isSubmitting}
/>
</form>
);
}
@@ -4,7 +4,6 @@ import ModalWrapper from '../ModalWrapper';
import AliasesForm from './AliasesForm'; import AliasesForm from './AliasesForm';
import AppSettingsModal from './AppSettings'; import AppSettingsModal from './AppSettings';
import CuesheetSettingsForm from './CuesheetSettingsForm';
import EditorSettings from './EditorSettings'; import EditorSettings from './EditorSettings';
import ProjectDataForm from './ProjectDataForm'; import ProjectDataForm from './ProjectDataForm';
import ViewSettingsForm from './ViewSettingsForm'; import ViewSettingsForm from './ViewSettingsForm';
@@ -24,7 +23,6 @@ export default function SettingsModal(props: ModalManagerProps) {
<Tab>App</Tab> <Tab>App</Tab>
<Tab>Project Data</Tab> <Tab>Project Data</Tab>
<Tab>Editor</Tab> <Tab>Editor</Tab>
<Tab>Cuesheet</Tab>
<Tab>Views</Tab> <Tab>Views</Tab>
<Tab>URL Aliases</Tab> <Tab>URL Aliases</Tab>
</TabList> </TabList>
@@ -38,9 +36,6 @@ export default function SettingsModal(props: ModalManagerProps) {
<TabPanel> <TabPanel>
<EditorSettings /> <EditorSettings />
</TabPanel> </TabPanel>
<TabPanel>
<CuesheetSettingsForm />
</TabPanel>
<TabPanel> <TabPanel>
<ViewSettingsForm /> <ViewSettingsForm />
</TabPanel> </TabPanel>
+10 -11
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { isOntimeEvent, OntimeEvent, SupportedEvent, UserFields } from 'ontime-types'; import { CustomFields, isOntimeEvent, OntimeEvent, SupportedEvent, UserFields } from 'ontime-types';
import { getFirstEventNormal, getLastEventNormal } from 'ontime-utils'; import { getFirstEventNormal, getLastEventNormal } from 'ontime-utils';
import NavigationMenu from '../../common/components/navigation-menu/NavigationMenu'; import NavigationMenu from '../../common/components/navigation-menu/NavigationMenu';
@@ -9,10 +9,10 @@ import { getOperatorOptions } from '../../common/components/view-params-editor/c
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor'; import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
import useFollowComponent from '../../common/hooks/useFollowComponent'; import useFollowComponent from '../../common/hooks/useFollowComponent';
import { useOperator } from '../../common/hooks/useSocket'; import { useOperator } from '../../common/hooks/useSocket';
import useCustomFields from '../../common/hooks-query/useCustomFields';
import useProjectData from '../../common/hooks-query/useProjectData'; import useProjectData from '../../common/hooks-query/useProjectData';
import useRundown from '../../common/hooks-query/useRundown'; import useRundown from '../../common/hooks-query/useRundown';
import useSettings from '../../common/hooks-query/useSettings'; import useSettings from '../../common/hooks-query/useSettings';
import useUserFields from '../../common/hooks-query/useUserFields';
import { debounce } from '../../common/utils/debounce'; import { debounce } from '../../common/utils/debounce';
import { getDefaultFormat } from '../../common/utils/time'; import { getDefaultFormat } from '../../common/utils/time';
import { isStringBoolean } from '../../common/utils/viewUtils'; import { isStringBoolean } from '../../common/utils/viewUtils';
@@ -30,12 +30,12 @@ const selectedOffset = 50;
type TitleFields = Pick<OntimeEvent, 'title' | 'subtitle' | 'presenter'>; type TitleFields = Pick<OntimeEvent, 'title' | 'subtitle' | 'presenter'>;
export type EditEvent = Pick<OntimeEvent, 'id' | 'cue'> & { fieldLabel?: string; fieldValue: string }; export type EditEvent = Pick<OntimeEvent, 'id' | 'cue'> & { fieldLabel?: string; fieldValue: string };
export type PartialEdit = EditEvent & { export type PartialEdit = EditEvent & {
field: keyof UserFields; field: keyof CustomFields;
}; };
export default function Operator() { export default function Operator() {
const { data, status } = useRundown(); const { data, status } = useRundown();
const { data: userFields, status: userFieldsStatus } = useUserFields(); const { data: customFields, status: customFieldStatus } = useCustomFields();
const { data: projectData, status: projectDataStatus } = useProjectData(); const { data: projectData, status: projectDataStatus } = useProjectData();
const timeoutId = useRef<NodeJS.Timeout | null>(null); const timeoutId = useRef<NodeJS.Timeout | null>(null);
@@ -116,8 +116,8 @@ export default function Operator() {
[searchParams], [searchParams],
); );
const missingData = !data || !userFields || !projectData; const missingData = !data || !customFields || !projectData;
const isLoading = status === 'pending' || userFieldsStatus === 'pending' || projectDataStatus === 'pending'; const isLoading = status === 'pending' || customFieldStatus === 'pending' || projectDataStatus === 'pending';
if (missingData || isLoading) { if (missingData || isLoading) {
return <Empty text='Loading...' />; return <Empty text='Loading...' />;
@@ -125,15 +125,14 @@ export default function Operator() {
// get fields which the user subscribed to // get fields which the user subscribed to
const shouldEdit = searchParams.get('shouldEdit'); const shouldEdit = searchParams.get('shouldEdit');
const subscribe = searchParams.get('subscribe') as keyof UserFields | null; const subscribe = searchParams.get('subscribe') as keyof CustomFields;
const canEdit = shouldEdit && subscribe; const canEdit = shouldEdit && subscribe;
const main = searchParams.get('main') as keyof TitleFields | null; const main = searchParams.get('main') as keyof TitleFields | null;
const secondary = searchParams.get('secondary') as keyof TitleFields | null; const secondary = searchParams.get('secondary') as keyof TitleFields | null;
const subscribedAlias = subscribe ? userFields[subscribe] : '';
const defaultFormat = getDefaultFormat(settings?.timeFormat); const defaultFormat = getDefaultFormat(settings?.timeFormat);
const operatorOptions = getOperatorOptions(userFields, defaultFormat); const operatorOptions = getOperatorOptions(customFields, defaultFormat);
let isPast = Boolean(featureData.selectedEventId); let isPast = Boolean(featureData.selectedEventId);
const hidePast = isStringBoolean(searchParams.get('hidepast')); const hidePast = isStringBoolean(searchParams.get('hidepast'));
@@ -178,7 +177,7 @@ export default function Operator() {
const mainField = main ? entry?.[main] || entry.title : entry.title; const mainField = main ? entry?.[main] || entry.title : entry.title;
const secondaryField = secondary ? entry?.[secondary] || entry.subtitle : entry.subtitle; const secondaryField = secondary ? entry?.[secondary] || entry.subtitle : entry.subtitle;
const subscribedData = (subscribe ? entry?.[subscribe] : undefined) || ''; const subscribedData = entry.custom[subscribe]?.value;
return ( return (
<OperatorEvent <OperatorEvent
@@ -194,7 +193,7 @@ export default function Operator() {
delay={entry.delay} delay={entry.delay}
isSelected={isSelected} isSelected={isSelected}
subscribed={subscribedData} subscribed={subscribedData}
subscribedAlias={subscribedAlias} subscribeLabel={subscribe}
isPast={isPast} isPast={isPast}
selectedRef={isSelected ? selectedRef : undefined} selectedRef={isSelected ? selectedRef : undefined}
onLongPress={canEdit ? handleEdit : () => undefined} onLongPress={canEdit ? handleEdit : () => undefined}
@@ -1,6 +1,5 @@
import { useRef, useState } from 'react'; import { useRef, useState } from 'react';
import { Button, Textarea } from '@chakra-ui/react'; import { Button, Textarea } from '@chakra-ui/react';
import { OntimeEvent } from 'ontime-types';
import { useEventAction } from '../../../common/hooks/useEventAction'; import { useEventAction } from '../../../common/hooks/useEventAction';
import type { PartialEdit } from '../Operator'; import type { PartialEdit } from '../Operator';
@@ -15,19 +14,18 @@ interface EditModalProps {
export default function EditModal(props: EditModalProps) { export default function EditModal(props: EditModalProps) {
const { event, onClose } = props; const { event, onClose } = props;
const { updateEvent } = useEventAction(); const { updateCustomField } = useEventAction();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const inputRef = useRef<HTMLTextAreaElement | null>(null); const inputRef = useRef<HTMLTextAreaElement | null>(null);
const handleSave = async () => { const handleSave = async () => {
setLoading(true); setLoading(true);
const newValue = inputRef.current?.value; const newValue = inputRef.current?.value;
if (newValue === undefined) {
return;
}
const partialEvent: Partial<OntimeEvent> = { await updateCustomField(event.id, event.field, newValue);
id: event.id,
[event.field]: newValue,
};
await updateEvent(partialEvent);
setLoading(false); setLoading(false);
onClose(); onClose();
}; };
@@ -43,6 +41,7 @@ export default function EditModal(props: EditModalProps) {
placeholder={`Add value for ${fieldLabel} field`} placeholder={`Add value for ${fieldLabel} field`}
defaultValue={event.fieldValue} defaultValue={event.fieldValue}
isDisabled={loading} isDisabled={loading}
resize='none'
/> />
<div className={style.buttonRow}> <div className={style.buttonRow}>
<Button variant='ontime-subtle' onClick={onClose} isDisabled={loading}> <Button variant='ontime-subtle' onClick={onClose} isDisabled={loading}>
@@ -22,7 +22,7 @@ interface OperatorEventProps {
delay?: number; delay?: number;
isSelected: boolean; isSelected: boolean;
subscribed?: string; subscribed?: string;
subscribedAlias: string; subscribeLabel: string;
isPast: boolean; isPast: boolean;
selectedRef?: RefObject<HTMLDivElement>; selectedRef?: RefObject<HTMLDivElement>;
onLongPress: (event: EditEvent) => void; onLongPress: (event: EditEvent) => void;
@@ -47,7 +47,7 @@ function OperatorEvent(props: OperatorEventProps) {
delay, delay,
isSelected, isSelected,
subscribed, subscribed,
subscribedAlias, subscribeLabel: subscribedAlias,
isPast, isPast,
selectedRef, selectedRef,
onLongPress, onLongPress,
@@ -77,3 +77,17 @@
column-gap: 1.5rem; column-gap: 1.5rem;
row-gap: 1rem; row-gap: 1rem;
} }
.decorated {
&::before{
content: '';
display: inline-block;
vertical-align: baseline;
margin-right: 0.25rem;
width: 1em;
height: 1em;
aspect-ratio: 1;
border-radius: 99px;
background-color: var(--decorator-color, $gray-1100);
}
}
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react'; import { CSSProperties, useCallback, useEffect, useState } from 'react';
import { Button } from '@chakra-ui/react'; import { Button } from '@chakra-ui/react';
import { CustomFieldLabel, isOntimeEvent, OntimeEvent } from 'ontime-types'; import { CustomFieldLabel, isOntimeEvent, OntimeEvent } from 'ontime-types';
@@ -10,7 +10,6 @@ import { useEventSelection } from '../useEventSelection';
import EventEditorTimes from './composite/EventEditorTimes'; import EventEditorTimes from './composite/EventEditorTimes';
import EventEditorTitles from './composite/EventEditorTitles'; import EventEditorTitles from './composite/EventEditorTitles';
import EventEditorUser from './composite/EventEditorUser';
import EventTextArea from './composite/EventTextArea'; import EventTextArea from './composite/EventTextArea';
import style from './EventEditor.module.scss'; import style from './EventEditor.module.scss';
@@ -86,21 +85,6 @@ export default function EventEditor() {
); );
} }
// Compositing user fields by hand
// this will be replaced by dynamic logic
const userFields = {
user0: event.user0,
user1: event.user1,
user2: event.user2,
user3: event.user3,
user4: event.user4,
user5: event.user5,
user6: event.user6,
user7: event.user7,
user8: event.user8,
user9: event.user9,
};
const customKeys = Object.keys(customFields ?? {}); const customKeys = Object.keys(customFields ?? {});
return ( return (
@@ -142,16 +126,17 @@ export default function EventEditor() {
{customKeys.map((label) => { {customKeys.map((label) => {
return ( return (
<EventTextArea <EventTextArea
key={label} key={`${event.id}-${label}`}
field={`custom-${label}`} field={`custom-${label}`}
label={label} label={label}
initialValue={event.custom[label]?.value ?? ''} initialValue={event.custom[label]?.value ?? ''}
submitHandler={handleSubmit} submitHandler={handleSubmit}
className={style.decorated}
style={{ '--decorator-color': customFields[label].colour } as CSSProperties}
/> />
); );
})} })}
</div> </div>
<EventEditorUser key={`${event.id}-user`} userFields={userFields} handleSubmit={handleSubmit} />
</div> </div>
<div className={style.footer}> <div className={style.footer}>
<CopyTag label='OSC trigger by id'>{`/ontime/load/id "${event.id}"`}</CopyTag> <CopyTag label='OSC trigger by id'>{`/ontime/load/id "${event.id}"`}</CopyTag>
@@ -1,83 +0,0 @@
import { UserFields } from 'ontime-types';
import useUserFields from '../../../../common/hooks-query/useUserFields';
import { EditorUpdateFields } from '../EventEditor';
import EventTextArea from './EventTextArea';
import style from '../EventEditor.module.scss';
interface EventEditorUserProps {
userFields: UserFields;
handleSubmit: (field: EditorUpdateFields, value: string) => void;
}
export default function EventEditorUser(props: EventEditorUserProps) {
const { userFields, handleSubmit } = props;
const { data } = useUserFields();
return (
<div className={style.column}>
<EventTextArea
field='user0'
label={data?.user0 ?? 'user0'}
initialValue={userFields.user0}
submitHandler={handleSubmit}
/>
<EventTextArea
field='user1'
label={data?.user1 ?? 'user1'}
initialValue={userFields.user1}
submitHandler={handleSubmit}
/>
<EventTextArea
field='user2'
label={data?.user2 ?? 'user2'}
initialValue={userFields.user2}
submitHandler={handleSubmit}
/>
<EventTextArea
field='user3'
label={data?.user3 ?? 'user3'}
initialValue={userFields.user3}
submitHandler={handleSubmit}
/>
<EventTextArea
field='user4'
label={data?.user4 ?? 'user4'}
initialValue={userFields.user4}
submitHandler={handleSubmit}
/>
<EventTextArea
field='user5'
label={data?.user5 ?? 'user5'}
initialValue={userFields.user5}
submitHandler={handleSubmit}
/>
<EventTextArea
field='user6'
label={data?.user6 ?? 'user6'}
initialValue={userFields.user6}
submitHandler={handleSubmit}
/>
<EventTextArea
field='user7'
label={data?.user7 ?? 'user7'}
initialValue={userFields.user7}
submitHandler={handleSubmit}
/>
<EventTextArea
field='user8'
label={data?.user8 ?? 'user8'}
initialValue={userFields.user8}
submitHandler={handleSubmit}
/>
<EventTextArea
field='user9'
label={data?.user9 ?? 'user9'}
initialValue={userFields.user9}
submitHandler={handleSubmit}
/>
</div>
);
}
@@ -1,28 +1,32 @@
import { useCallback } from 'react'; import { CSSProperties, useCallback } from 'react';
import { AutoTextArea } from '../../../../common/components/input/auto-text-area/AutoTextArea'; import { AutoTextArea } from '../../../../common/components/input/auto-text-area/AutoTextArea';
import useReactiveTextInput from '../../../../common/components/input/text-input/useReactiveTextInput'; import useReactiveTextInput from '../../../../common/components/input/text-input/useReactiveTextInput';
import { cx } from '../../../../common/utils/styleUtils';
import { EditorUpdateFields } from '../EventEditor'; import { EditorUpdateFields } from '../EventEditor';
import style from '../EventEditor.module.scss'; import style from '../EventEditor.module.scss';
interface CountedTextAreaProps { interface CountedTextAreaProps {
className?: string;
field: EditorUpdateFields; field: EditorUpdateFields;
label: string; label: string;
initialValue: string; initialValue: string;
style?: CSSProperties;
submitHandler: (field: EditorUpdateFields, value: string) => void; submitHandler: (field: EditorUpdateFields, value: string) => void;
} }
export default function EventTextArea(props: CountedTextAreaProps) { export default function EventTextArea(props: CountedTextAreaProps) {
const { field, label, initialValue, submitHandler } = props; const { className, field, label, initialValue, style: givenStyles, submitHandler } = props;
const submitCallback = useCallback((newValue: string) => submitHandler(field, newValue), [field, submitHandler]); const submitCallback = useCallback((newValue: string) => submitHandler(field, newValue), [field, submitHandler]);
const { value, onChange, onBlur, onKeyDown } = useReactiveTextInput(initialValue, submitCallback); const { value, onChange, onBlur, onKeyDown } = useReactiveTextInput(initialValue, submitCallback);
const classes = cx([style.inputLabel, className]);
return ( return (
<div> <div>
<label className={style.inputLabel} htmlFor={field}> <label className={classes} htmlFor={field} style={givenStyles}>
{label} {label}
</label> </label>
<AutoTextArea <AutoTextArea
@@ -17,7 +17,7 @@ import {
import { data, db } from '../../modules/loadDb.js'; import { data, db } from '../../modules/loadDb.js';
import { safeMerge } from './DataProvider.utils.js'; import { safeMerge } from './DataProvider.utils.js';
import { isProduction } from '../../setup.js'; import { isTest } from '../../setup.js';
export class DataProvider { export class DataProvider {
static getData() { static getData() {
@@ -110,7 +110,7 @@ export class DataProvider {
} }
static async persist() { static async persist() {
if (!isProduction) { if (isTest) {
return; return;
} }
await db.write(); await db.write();
@@ -220,6 +220,7 @@ export function mutateCache<T extends object>(mutation: MutatingFn<T>) {
// TODO: should we trottle this? // TODO: should we trottle this?
// defer writing to the database // defer writing to the database
setImmediate(() => { setImmediate(() => {
console.log('writing to database', persistedRundown.length)
DataProvider.setRundown(persistedRundown); DataProvider.setRundown(persistedRundown);
}); });
@@ -283,7 +284,6 @@ export function edit({ persistedRundown, eventId, patch }: EditArgs): Required<M
const eventInMemory = persistedRundown[indexAt]; const eventInMemory = persistedRundown[indexAt];
const newEvent = makeEvent(eventInMemory, patch); const newEvent = makeEvent(eventInMemory, patch);
console.log('got', patch, 'will make', newEvent);
const newRundown = [...persistedRundown]; const newRundown = [...persistedRundown];
newRundown[indexAt] = newEvent; newRundown[indexAt] = newEvent;
+1 -1
View File
@@ -402,7 +402,7 @@ export function createPatch(originalEvent: OntimeEvent, patchEvent: Partial<Onti
revision: originalEvent.revision, revision: originalEvent.revision,
timeWarning: patchEvent.timeWarning ?? originalEvent.timeWarning, timeWarning: patchEvent.timeWarning ?? originalEvent.timeWarning,
timeDanger: patchEvent.timeDanger ?? originalEvent.timeDanger, timeDanger: patchEvent.timeDanger ?? originalEvent.timeDanger,
custom: patchEvent.custom ?? originalEvent.custom, custom: { ...originalEvent.custom, ...patchEvent.custom },
}; };
} }
+9 -18
View File
@@ -20,25 +20,16 @@ test('cuesheet displays events and exports csv', async ({ page }) => {
const expectedColumns = [ const expectedColumns = [
'Time Start', 'Time Start',
'Time End', 'Time End',
'Event Title', 'Duration',
'Presenter Name', 'ID',
'Event Subtitle',
'Public',
'Note',
'Colour', 'Colour',
'End Action', 'Cue',
'Timer Type', 'Title',
'Skip', 'Subtitle',
'user0', 'Presenter',
'user1', 'Note',
'user2', 'Is Public? (x)',
'user3', 'Skip?',
'user4',
'user5',
'user6',
'user7',
'user8',
'user9',
]; ];
const expectedValues = ['Albania', 'Latvia', 'Lithuania', 'Lunch break']; const expectedValues = ['Albania', 'Latvia', 'Lithuania', 'Lunch break'];