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';
@@ -404,7 +404,11 @@ export const getStudioClockOptions = (timeFormat: string): ParamField[] => [
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 [
getTimeOption(timeFormat),
{
@@ -439,25 +443,14 @@ export const getOperatorOptions = (userFields: UserFields, timeFormat: string):
{
id: 'subscribe',
title: 'Highlight Field',
description: 'Choose a field to highlight',
description: 'Choose a custom field to highlight',
type: 'option',
values: {
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',
},
values: customFieldSelect,
},
{
id: 'shouldEdit',
title: 'Edit user field',
description: 'Allows editing an events user field by long pressing on it. Needs a selected highlighted field',
title: 'Edit custom field',
description: 'Allows editing an events selected custom field by long pressing.',
type: 'boolean',
defaultValue: false,
},
@@ -150,6 +150,13 @@ export const useEventAction = () => {
[_updateEventMutation],
);
const updateCustomField = useCallback(
async (eventId: string, field: string, value: string) => {
updateEvent({ id: eventId, custom: { [field]: { value } } });
},
[updateEvent],
);
type TimeField = 'timeStart' | 'timeEnd' | 'duration';
/**
* Updates time of existing event
@@ -553,5 +560,6 @@ export const useEventAction = () => {
swapEvents,
updateEvent,
updateTimer,
updateCustomField,
};
};
+9 -2
View File
@@ -11,6 +11,13 @@ type BlobOptions = {
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) {
const response = await axios({
url: `${url}/db`,
@@ -20,7 +27,7 @@ export default async function fileDownload(url: string, fileOptions: FileOptions
const headerLine = response.headers['Content-Disposition'];
let { name: fileName } = 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
if (headerLine != null) {
@@ -37,7 +44,7 @@ export default async function fileDownload(url: string, fileOptions: FileOptions
}
if (fileType === 'csv') {
const sheetData = makeTable(project, rundown, userFields);
const sheetData = makeTable(project, rundown, customFields);
fileContent = makeCSV(sheetData);
fileName += '.csv';
}
@@ -1,11 +1,11 @@
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 { useEventAction } from '../../common/hooks/useEventAction';
import { useCuesheet } from '../../common/hooks/useSocket';
import useCustomFields from '../../common/hooks-query/useCustomFields';
import { useFlatRundown } from '../../common/hooks-query/useRundown';
import useUserFields from '../../common/hooks-query/useUserFields';
import CuesheetProgress from './cuesheet-progress/CuesheetProgress';
import CuesheetTableHeader from './cuesheet-table-header/CuesheetTableHeader';
@@ -18,18 +18,23 @@ import styles from './CuesheetWrapper.module.scss';
export default function CuesheetWrapper() {
// TODO: can we use the normalised rundown for the table?
const { data: flatRundown, status: rundownStatus } = useFlatRundown();
const { data: userFields } = useUserFields();
const { updateEvent } = useEventAction();
const { data: customFields } = useCustomFields();
const { updateCustomField } = useEventAction();
const featureData = useCuesheet();
const columns = useMemo(() => makeCuesheetColumns(userFields), [userFields]);
const columns = useMemo(() => makeCuesheetColumns(customFields), [customFields]);
// Set window title
useEffect(() => {
document.title = 'ontime - Cuesheet';
}, []);
/**
* Handles updating a field
* Currently, only custom fields can be updated from the cuesheet
*/
const handleUpdate = useCallback(
async (rowIndex: number, accessor: keyof OntimeRundownEntry, payload: unknown) => {
async (rowIndex: number, accessor: CustomFieldLabel, payload: unknown) => {
if (!flatRundown || rundownStatus !== 'success') {
return;
}
@@ -40,42 +45,41 @@ export default function CuesheetWrapper() {
// check if value is the same
const event = flatRundown[rowIndex];
if (!event) {
if (!event || !isOntimeEvent(event)) {
return;
}
if (event[accessor] === payload) {
const previousValue = event.custom[accessor]?.value;
if (previousValue === payload) {
return;
}
// 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') {
return;
}
// cleanup
const cleanVal = payload.trim();
const mutationObject = {
id: event.id,
[accessor]: cleanVal,
};
// submit
try {
await updateEvent(mutationObject);
await updateCustomField(event.id, accessor, cleanVal);
} catch (error) {
console.error(error);
}
},
[flatRundown, rundownStatus, updateEvent],
[flatRundown, rundownStatus, updateCustomField],
);
const exportHandler = useCallback(
(headerData: ProjectData) => {
if (!userFields || !flatRundown || rundownStatus !== 'success') {
if (!flatRundown || rundownStatus !== 'success') {
return;
}
const sheetData = makeTable(headerData, flatRundown, userFields);
const sheetData = makeTable(headerData, flatRundown, customFields);
const csvContent = makeCSV(sheetData);
const fileName = 'ontime rundown.csv';
@@ -92,10 +96,10 @@ export default function CuesheetWrapper() {
URL.revokeObjectURL(url);
return;
},
[flatRundown, rundownStatus, userFields],
[flatRundown, rundownStatus, customFields],
);
if (!userFields || !flatRundown || rundownStatus !== 'success') {
if (!customFields || !flatRundown || rundownStatus !== 'success') {
return <Empty text='Loading...' />;
}
@@ -14,39 +14,32 @@ exports[`makeTable() > returns array of arrays with given fields 1`] = `
[
"Time Start",
"Time End",
"Event Title",
"Presenter Name",
"Event Subtitle",
"Is Public? (x)",
"Note",
"Duration",
"ID",
"Colour",
"End Action",
"Timer Type",
"Cue",
"Title",
"Subtitle",
"Presenter",
"Note",
"Is Public? (x)",
"Skip?",
"user0:test",
"lighting",
],
[
"00:00:00",
"00:00:00",
"...",
"",
"",
"",
"test title 1",
"",
"",
"",
"x",
"",
"",
"",
"",
"",
"test",
"test",
"",
"",
"",
"",
"",
"",
"",
"",
],
]
`;
@@ -1,12 +1,13 @@
import { makeCSV, makeTable, parseField } from '../cuesheetUtils';
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 testData2 = 60000;
const testData3 = 600000;
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('duration', testData3)).toBe('00:10:00');
});
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', () => {
const testFields = [
{ field: 'nothing', value: 123 },
{ field: 'nothing', value: '123' },
{ field: 'title', value: 'test' },
{ field: 'presenter', value: 'test' },
{ field: 'subtitle', value: 'test' },
{ field: 'note', 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) => {
@@ -70,15 +61,15 @@ describe('makeTable()', () => {
timeStart: 0,
timeEnd: 0,
isPublic: 'x',
user0: 'test',
user1: 'test',
lighting: { value: 'test lighting' },
sound: { value: 'test sound' },
},
];
const userFields = {
user0: 'test',
const customFields = {
lighting: { label: 'test' },
};
const table = makeTable(headerData, tableData, userFields);
const table = makeTable(headerData, tableData, customFields);
expect(table).toMatchSnapshot();
});
});
@@ -15,6 +15,7 @@ import { flexRender, HeaderGroup } from '@tanstack/react-table';
import { OntimeRundownEntry } from 'ontime-types';
import { useLocalStorage } from '../../../common/hooks/useLocalStorage';
import { getAccessibleColour } from '../../../common/utils/styleUtils';
import { tooltipDelayFast } from '../../../ontimeConfig';
import { initialColumnOrder } from '../cuesheetCols';
@@ -89,9 +90,17 @@ function CuesheetHeader(props: CuesheetHeaderProps) {
<SortableContext key={key} items={headerGroup.headers} strategy={horizontalListSortingStrategy}>
{headerGroup.headers.map((header) => {
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 (
<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())}
</SortableCell>
);
@@ -1,7 +1,7 @@
import { useCallback } from 'react';
import { IoCheckmark } from '@react-icons/all-files/io5/IoCheckmark';
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 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(
(newValue: string) => {
// @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
[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} />;
}
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 [
{
accessorKey: 'cue',
@@ -109,67 +123,8 @@ export function makeCuesheetColumns(userFields?: UserFields): ColumnDef<OntimeRu
header: 'Note',
cell: (row) => row.getValue(),
},
{
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,
},
...dynamicCustomFields,
];
}
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 { OntimeEntryCommonKeys, OntimeRundown, ProjectData, UserFields } from 'ontime-types';
import {
CustomFields,
isOntimeDelay,
isOntimeEvent,
MaybeNumber,
OntimeEntryCommonKeys,
OntimeRundown,
ProjectData,
} from 'ontime-types';
import { millisToString } from 'ontime-utils';
type CsvHeaderKey = OntimeEntryCommonKeys | keyof CustomFields;
/**
* @description parses a field for export
* @param {string} field
@@ -9,90 +19,89 @@ import { millisToString } from 'ontime-utils';
* @return {string}
*/
export const parseField = <T extends OntimeEntryCommonKeys>(field: T, data: unknown): string => {
let val;
switch (field) {
case 'timeStart':
case 'timeEnd':
val = millisToString(data as number | null);
break;
case 'isPublic':
case 'skip':
val = data ? 'x' : '';
break;
default:
val = data;
break;
export const parseField = (field: CsvHeaderKey, data: unknown): string => {
if (field === 'timeStart' || field === 'timeEnd' || field === 'duration') {
return millisToString(data as MaybeNumber);
}
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
* @param {object} headerData
* @param {array} rundown
* @param {object} userFields
* @param {ProjectData} headerData
* @param {OntimeRundown} rundown
* @param {CustomFields} customFields
* @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']];
if (headerData.title) data.push([`Project title: ${headerData.title}`]);
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',
'timeEnd',
'title',
'presenter',
'subtitle',
'isPublic',
'note',
'duration',
'id',
'colour',
'endAction',
'timerType',
'cue',
'title',
'subtitle',
'presenter',
'note',
'isPublic',
'skip',
'user0',
'user1',
'user2',
'user3',
'user4',
'user5',
'user6',
'user7',
'user8',
'user9',
...customFieldKeys,
];
const fieldTitles = [
'Time Start',
'Time End',
'Event Title',
'Presenter Name',
'Event Subtitle',
'Is Public? (x)',
'Note',
'Duration',
'ID',
'Colour',
'End Action',
'Timer Type',
'Cue',
'Title',
'Subtitle',
'Presenter',
'Note',
'Is Public? (x)',
'Skip?',
...customFieldLabels,
];
for (const field in userFields) {
const fieldValue = userFields[field as keyof UserFields];
const displayName = `${field}${fieldValue !== field && fieldValue !== '' ? `:${fieldValue}` : ''}`;
fieldTitles.push(displayName);
}
// add header row to data
data.push(fieldTitles);
rundown.forEach((entry) => {
if (isOntimeDelay(entry)) return;
const row: string[] = [];
// @ts-expect-error -- not sure how to type this
fieldOrder.forEach((field) => row.push(parseField(field, entry[field])));
fieldOrder.forEach((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);
});
@@ -101,10 +110,10 @@ export const makeTable = (headerData: ProjectData, rundown: OntimeRundown, userF
/**
* @description Converts an array of arrays to a csv file
* @param {array[]} arrayOfArrays
* @param {string[][]} arrayOfArrays
* @return {string}
*/
export const makeCSV = (arrayOfArrays: string[][]) => {
export const makeCSV = (arrayOfArrays: string[][]): string => {
const stringifiedData = stringify(arrayOfArrays);
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 AppSettingsModal from './AppSettings';
import CuesheetSettingsForm from './CuesheetSettingsForm';
import EditorSettings from './EditorSettings';
import ProjectDataForm from './ProjectDataForm';
import ViewSettingsForm from './ViewSettingsForm';
@@ -24,7 +23,6 @@ export default function SettingsModal(props: ModalManagerProps) {
<Tab>App</Tab>
<Tab>Project Data</Tab>
<Tab>Editor</Tab>
<Tab>Cuesheet</Tab>
<Tab>Views</Tab>
<Tab>URL Aliases</Tab>
</TabList>
@@ -38,9 +36,6 @@ export default function SettingsModal(props: ModalManagerProps) {
<TabPanel>
<EditorSettings />
</TabPanel>
<TabPanel>
<CuesheetSettingsForm />
</TabPanel>
<TabPanel>
<ViewSettingsForm />
</TabPanel>
+10 -11
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react';
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 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 useFollowComponent from '../../common/hooks/useFollowComponent';
import { useOperator } from '../../common/hooks/useSocket';
import useCustomFields from '../../common/hooks-query/useCustomFields';
import useProjectData from '../../common/hooks-query/useProjectData';
import useRundown from '../../common/hooks-query/useRundown';
import useSettings from '../../common/hooks-query/useSettings';
import useUserFields from '../../common/hooks-query/useUserFields';
import { debounce } from '../../common/utils/debounce';
import { getDefaultFormat } from '../../common/utils/time';
import { isStringBoolean } from '../../common/utils/viewUtils';
@@ -30,12 +30,12 @@ const selectedOffset = 50;
type TitleFields = Pick<OntimeEvent, 'title' | 'subtitle' | 'presenter'>;
export type EditEvent = Pick<OntimeEvent, 'id' | 'cue'> & { fieldLabel?: string; fieldValue: string };
export type PartialEdit = EditEvent & {
field: keyof UserFields;
field: keyof CustomFields;
};
export default function Operator() {
const { data, status } = useRundown();
const { data: userFields, status: userFieldsStatus } = useUserFields();
const { data: customFields, status: customFieldStatus } = useCustomFields();
const { data: projectData, status: projectDataStatus } = useProjectData();
const timeoutId = useRef<NodeJS.Timeout | null>(null);
@@ -116,8 +116,8 @@ export default function Operator() {
[searchParams],
);
const missingData = !data || !userFields || !projectData;
const isLoading = status === 'pending' || userFieldsStatus === 'pending' || projectDataStatus === 'pending';
const missingData = !data || !customFields || !projectData;
const isLoading = status === 'pending' || customFieldStatus === 'pending' || projectDataStatus === 'pending';
if (missingData || isLoading) {
return <Empty text='Loading...' />;
@@ -125,15 +125,14 @@ export default function Operator() {
// get fields which the user subscribed to
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 main = searchParams.get('main') as keyof TitleFields | null;
const secondary = searchParams.get('secondary') as keyof TitleFields | null;
const subscribedAlias = subscribe ? userFields[subscribe] : '';
const defaultFormat = getDefaultFormat(settings?.timeFormat);
const operatorOptions = getOperatorOptions(userFields, defaultFormat);
const operatorOptions = getOperatorOptions(customFields, defaultFormat);
let isPast = Boolean(featureData.selectedEventId);
const hidePast = isStringBoolean(searchParams.get('hidepast'));
@@ -178,7 +177,7 @@ export default function Operator() {
const mainField = main ? entry?.[main] || entry.title : entry.title;
const secondaryField = secondary ? entry?.[secondary] || entry.subtitle : entry.subtitle;
const subscribedData = (subscribe ? entry?.[subscribe] : undefined) || '';
const subscribedData = entry.custom[subscribe]?.value;
return (
<OperatorEvent
@@ -194,7 +193,7 @@ export default function Operator() {
delay={entry.delay}
isSelected={isSelected}
subscribed={subscribedData}
subscribedAlias={subscribedAlias}
subscribeLabel={subscribe}
isPast={isPast}
selectedRef={isSelected ? selectedRef : undefined}
onLongPress={canEdit ? handleEdit : () => undefined}
@@ -1,6 +1,5 @@
import { useRef, useState } from 'react';
import { Button, Textarea } from '@chakra-ui/react';
import { OntimeEvent } from 'ontime-types';
import { useEventAction } from '../../../common/hooks/useEventAction';
import type { PartialEdit } from '../Operator';
@@ -15,19 +14,18 @@ interface EditModalProps {
export default function EditModal(props: EditModalProps) {
const { event, onClose } = props;
const { updateEvent } = useEventAction();
const { updateCustomField } = useEventAction();
const [loading, setLoading] = useState(false);
const inputRef = useRef<HTMLTextAreaElement | null>(null);
const handleSave = async () => {
setLoading(true);
const newValue = inputRef.current?.value;
if (newValue === undefined) {
return;
}
const partialEvent: Partial<OntimeEvent> = {
id: event.id,
[event.field]: newValue,
};
await updateEvent(partialEvent);
await updateCustomField(event.id, event.field, newValue);
setLoading(false);
onClose();
};
@@ -43,6 +41,7 @@ export default function EditModal(props: EditModalProps) {
placeholder={`Add value for ${fieldLabel} field`}
defaultValue={event.fieldValue}
isDisabled={loading}
resize='none'
/>
<div className={style.buttonRow}>
<Button variant='ontime-subtle' onClick={onClose} isDisabled={loading}>
@@ -22,7 +22,7 @@ interface OperatorEventProps {
delay?: number;
isSelected: boolean;
subscribed?: string;
subscribedAlias: string;
subscribeLabel: string;
isPast: boolean;
selectedRef?: RefObject<HTMLDivElement>;
onLongPress: (event: EditEvent) => void;
@@ -47,7 +47,7 @@ function OperatorEvent(props: OperatorEventProps) {
delay,
isSelected,
subscribed,
subscribedAlias,
subscribeLabel: subscribedAlias,
isPast,
selectedRef,
onLongPress,
@@ -77,3 +77,17 @@
column-gap: 1.5rem;
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 { CustomFieldLabel, isOntimeEvent, OntimeEvent } from 'ontime-types';
@@ -10,7 +10,6 @@ import { useEventSelection } from '../useEventSelection';
import EventEditorTimes from './composite/EventEditorTimes';
import EventEditorTitles from './composite/EventEditorTitles';
import EventEditorUser from './composite/EventEditorUser';
import EventTextArea from './composite/EventTextArea';
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 ?? {});
return (
@@ -142,16 +126,17 @@ export default function EventEditor() {
{customKeys.map((label) => {
return (
<EventTextArea
key={label}
key={`${event.id}-${label}`}
field={`custom-${label}`}
label={label}
initialValue={event.custom[label]?.value ?? ''}
submitHandler={handleSubmit}
className={style.decorated}
style={{ '--decorator-color': customFields[label].colour } as CSSProperties}
/>
);
})}
</div>
<EventEditorUser key={`${event.id}-user`} userFields={userFields} handleSubmit={handleSubmit} />
</div>
<div className={style.footer}>
<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 useReactiveTextInput from '../../../../common/components/input/text-input/useReactiveTextInput';
import { cx } from '../../../../common/utils/styleUtils';
import { EditorUpdateFields } from '../EventEditor';
import style from '../EventEditor.module.scss';
interface CountedTextAreaProps {
className?: string;
field: EditorUpdateFields;
label: string;
initialValue: string;
style?: CSSProperties;
submitHandler: (field: EditorUpdateFields, value: string) => void;
}
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 { value, onChange, onBlur, onKeyDown } = useReactiveTextInput(initialValue, submitCallback);
const classes = cx([style.inputLabel, className]);
return (
<div>
<label className={style.inputLabel} htmlFor={field}>
<label className={classes} htmlFor={field} style={givenStyles}>
{label}
</label>
<AutoTextArea
@@ -17,7 +17,7 @@ import {
import { data, db } from '../../modules/loadDb.js';
import { safeMerge } from './DataProvider.utils.js';
import { isProduction } from '../../setup.js';
import { isTest } from '../../setup.js';
export class DataProvider {
static getData() {
@@ -110,7 +110,7 @@ export class DataProvider {
}
static async persist() {
if (!isProduction) {
if (isTest) {
return;
}
await db.write();
@@ -220,6 +220,7 @@ export function mutateCache<T extends object>(mutation: MutatingFn<T>) {
// TODO: should we trottle this?
// defer writing to the database
setImmediate(() => {
console.log('writing to database', persistedRundown.length)
DataProvider.setRundown(persistedRundown);
});
@@ -283,7 +284,6 @@ export function edit({ persistedRundown, eventId, patch }: EditArgs): Required<M
const eventInMemory = persistedRundown[indexAt];
const newEvent = makeEvent(eventInMemory, patch);
console.log('got', patch, 'will make', newEvent);
const newRundown = [...persistedRundown];
newRundown[indexAt] = newEvent;
+1 -1
View File
@@ -402,7 +402,7 @@ export function createPatch(originalEvent: OntimeEvent, patchEvent: Partial<Onti
revision: originalEvent.revision,
timeWarning: patchEvent.timeWarning ?? originalEvent.timeWarning,
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 = [
'Time Start',
'Time End',
'Event Title',
'Presenter Name',
'Event Subtitle',
'Public',
'Note',
'Duration',
'ID',
'Colour',
'End Action',
'Timer Type',
'Skip',
'user0',
'user1',
'user2',
'user3',
'user4',
'user5',
'user6',
'user7',
'user8',
'user9',
'Cue',
'Title',
'Subtitle',
'Presenter',
'Note',
'Is Public? (x)',
'Skip?',
];
const expectedValues = ['Albania', 'Latvia', 'Lithuania', 'Lunch break'];