mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-21 15:09:10 +00:00
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:
@@ -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,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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'];
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user