Compare commits

..

38 Commits

Author SHA1 Message Date
cv b0090a2fba refactor: memo event line 2023-09-02 13:22:44 +02:00
cv f842d5baf4 feat: allow configuring fields 2023-09-02 13:15:37 +02:00
cv ca9e49a26f style: user feedback tweaks 2023-09-02 12:56:53 +02:00
cv b869fa5c2d style: user feedback tweaks 2023-09-02 12:51:48 +02:00
cv 5ac95b7f80 style: user feedback tweaks 2023-09-02 12:50:11 +02:00
cv 6417a99281 chore: smoke test operator 2023-09-02 11:11:51 +02:00
cv 65ba6ae2db Merge remote-tracking branch 'origin/master' into feat/operator 2023-09-02 11:07:35 +02:00
cv 8505566d22 chore: smoke test operator 2023-09-01 23:06:09 +02:00
cv f5304668b9 refactor: allow 12hour format with seconds 2023-09-01 22:13:04 +02:00
cv cf20562c9c refactor: cleanup logs 2023-09-01 22:12:47 +02:00
cv b46a22c964 style: improve readability of list 2023-09-01 22:07:39 +02:00
cv c9976c7fe8 refactor: distinguish automated scrolling 2023-09-01 21:46:17 +02:00
cv 5245da7ce7 refactor: code review cleanup 2023-09-01 20:09:09 +02:00
cv 124eb9a6ea style: recompose layout 2023-08-31 21:28:50 +02:00
cv e822c712ed feat: operator view 2023-08-29 14:32:28 +02:00
cv ef8f82113b style: hover indicator on actions 2023-08-29 14:20:53 +02:00
cv c75d8502b0 feat: operator view 2023-08-29 14:19:46 +02:00
cv e667755fd6 feat: operator view 2023-08-29 12:37:03 +02:00
cv 5202f8669d feat: operator view 2023-08-28 22:31:32 +02:00
cv c33887386e feat: operator view 2023-08-28 22:31:25 +02:00
cv e299ca2cd6 feat: operator view 2023-08-28 22:16:09 +02:00
cv 3cdee24d39 fix: correct casing in custom data attribute 2023-08-28 21:58:42 +02:00
cv 62979877ae feat: operator view 2023-08-28 21:43:03 +02:00
cv 32f18d8d23 feat: operator view 2023-08-28 21:42:27 +02:00
cv 9886e986d3 chore: register operator in menu 2023-08-27 22:16:39 +02:00
cv 4920392cb5 Merge remote-tracking branch 'origin/master' into feat/operator 2023-08-27 22:14:02 +02:00
cv c49c8fbd27 wip: operator data and structure 2023-07-24 22:26:35 +02:00
cv 75b69055a0 fix: prevent issue with appending multiple keys 2023-07-24 21:53:41 +02:00
cv 300bd7e568 refactor: extract reusable components 2023-07-24 21:51:20 +02:00
cv 473b258c03 Merge branch 'feat/operator' of https://github.com/cpvalente/ontime into feat/operator 2023-07-23 22:24:07 +02:00
cv 38c4a7ffd1 Merge remote-tracking branch 'origin/master' into feat/operator 2023-07-23 22:18:35 +02:00
arihanv bf7ca81e02 style: Styling Operator Block (#468)
* Operator Layout
2023-07-23 22:17:25 +02:00
arihanv 465550130b Make Operator Layout (#442)
* Operator Layout

* style: Add OnTime colors and adjust playback block

* clean: Format Code

* style: Operator

* fix: Revert Pnpm Lock

* fix: Fix Imports
2023-07-14 15:41:09 -05:00
arihanv 50f7bd1227 feat: Add Operator List 2023-07-03 00:22:10 -05:00
arihanv 7a6158584a refactor: make operator block component 2023-07-02 18:12:15 -05:00
cv 9e9d9eedaf Revert "wip: initial setup"
This reverts commit a23c10dd5a.
2023-07-02 23:13:56 +02:00
cv a23c10dd5a wip: initial setup 2023-07-02 23:11:12 +02:00
cv 9f226ba001 initial setup 2023-07-02 21:35:02 +02:00
128 changed files with 1487 additions and 2640 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-ui",
"version": "2.9.0",
"version": "2.7.2",
"private": true,
"dependencies": {
"@chakra-ui/react": "^2.7.0",
+2 -2
View File
@@ -1,5 +1,5 @@
// REST stuff
export const PROJECT_DATA = ['project'];
export const EVENT_DATA = ['eventdata'];
export const ALIASES = ['aliases'];
export const USERFIELDS = ['userFields'];
export const RUNDOWN_TABLE_KEY = 'rundown';
@@ -19,7 +19,7 @@ export const serverPort = isProduction ? location.port : STATIC_PORT;
export const serverURL = `${location.protocol}//${location.hostname}:${serverPort}`;
export const websocketUrl = `${socketProtocol}://${location.hostname}:${serverPort}/ws`;
export const projectDataURL = `${serverURL}/project`;
export const eventURL = `${serverURL}/eventdata`;
export const rundownURL = `${serverURL}/events`;
export const ontimeURL = `${serverURL}/ontime`;
+5 -30
View File
@@ -2,30 +2,18 @@ import axios, { AxiosError } from 'axios';
import { LogLevel } from 'ontime-types';
import { generateId, millisToString } from 'ontime-utils';
import { ontimeQueryClient } from '../queryClient';
import { addLog } from '../stores/logger';
import { nowInMillis } from '../utils/time';
export function maybeAxiosError(error: unknown) {
export function logAxiosError(prepend: string, error: unknown) {
let message;
if (axios.isAxiosError(error)) {
const statusText = (error as AxiosError).response?.statusText ?? '';
let data = (error as AxiosError).response?.data ?? '';
if (typeof data === 'object') {
// TODO: use error instead, when migrated
if ('message' in data) {
data = JSON.stringify(data.message);
} else {
data = JSON.stringify(data);
}
}
return `${statusText}: ${data}`;
const data = (error as AxiosError).response?.data ?? '';
message = `${prepend} ${statusText}: ${data}`;
} else {
return error as string;
message = `${prepend}: ${error}`;
}
}
export function logAxiosError(prepend: string, error: unknown) {
const message = `${prepend}: ${maybeAxiosError(error)}`;
addLog({
id: generateId(),
@@ -35,16 +23,3 @@ export function logAxiosError(prepend: string, error: unknown) {
text: message,
});
}
export async function invalidateAllCaches() {
await ontimeQueryClient.invalidateQueries([
'project',
'aliases',
'userFields',
'rundown',
'appinfo',
'oscSettings',
'appSettings',
'viewSettings',
]);
}
@@ -0,0 +1,21 @@
import axios from 'axios';
import { EventData } from 'ontime-types';
import { eventURL } from './apiConstants';
/**
* @description HTTP request to fetch event data
* @return {Promise}
*/
export async function fetchEventData(): Promise<EventData> {
const res = await axios.get(eventURL);
return res.data;
}
/**
* @description HTTP request to mutate event data
* @return {Promise}
*/
export async function postEventData(data: EventData) {
return axios.post(eventURL, data);
}
+8 -69
View File
@@ -1,16 +1,5 @@
import axios, { AxiosResponse } from 'axios';
import {
Alias,
DatabaseModel,
OntimeRundown,
OSCSettings,
OscSubscription,
ProjectData,
Settings,
UserFields,
ViewSettings,
} from 'ontime-types';
import { ExcelImportMap } from 'ontime-utils';
import axios from 'axios';
import { Alias, EventData, OSCSettings, OscSubscription, Settings, UserFields, ViewSettings } from 'ontime-types';
import { apiRepoLatest } from '../../externals';
import { InfoType } from '../models/Info';
@@ -148,26 +137,17 @@ export const downloadRundown = async () => {
});
};
// TODO: should this be extracted to shared code?
export type ProjectFileImportOptions = {
onlyRundown: boolean;
};
/**
* @description HTTP request to upload events db
* @return {Promise}
*/
export const uploadProjectFile = async (
file: File,
setProgress: (value: number) => void,
options?: Partial<ProjectFileImportOptions>,
) => {
type UploadDataOptions = {
onlyRundown?: boolean;
};
export const uploadData = async (file: File, setProgress: (value: number) => void, options?: UploadDataOptions) => {
const formData = new FormData();
formData.append('userFile', file);
const onlyRundown = Boolean(options?.onlyRundown);
console.log('debug here', onlyRundown, options);
const onlyRundown = options?.onlyRundown || 'false';
await axios
.post(`${ontimeURL}/db?onlyRundown=${onlyRundown}`, formData, {
headers: {
@@ -181,47 +161,6 @@ export const uploadProjectFile = async (
.then((response) => response.data.id);
};
/**
* @description Make patch changes to the objects in the db
* @return {Promise}
*/
export async function patchData(patchDb: Partial<DatabaseModel>) {
const response = await axios.patch(`${ontimeURL}/db`, patchDb);
return response;
}
type PostPreviewExcelResponse = {
rundown: OntimeRundown;
project: ProjectData;
userFields: UserFields;
};
/**
* @description Make patch changes to the objects in the db
* @return {Promise} - returns parsed rundown and userfields
*/
export async function postPreviewExcel(file: File, setProgress: (value: number) => void, options?: ExcelImportMap) {
const formData = new FormData();
formData.append('userFile', file);
formData.append('options', JSON.stringify(options));
const response: AxiosResponse<PostPreviewExcelResponse> = await axios.post(
`${ontimeURL}/preview-spreadsheet`,
formData,
{
headers: {
'Content-Type': 'multipart/form-data',
},
onUploadProgress: (progressEvent) => {
const complete = progressEvent?.total ? Math.round((progressEvent.loaded * 100) / progressEvent.total) : 0;
setProgress(complete);
},
},
);
return response;
}
export type HasUpdate = {
url: string;
version: string;
@@ -239,6 +178,6 @@ export async function getLatestVersion(): Promise<HasUpdate> {
};
}
export async function postNew(initialData: Partial<ProjectData>) {
export async function postNew(initialData: Partial<EventData>) {
return axios.post(`${ontimeURL}/new`, initialData);
}
@@ -1,21 +0,0 @@
import axios from 'axios';
import { ProjectData } from 'ontime-types';
import { projectDataURL } from './apiConstants';
/**
* @description HTTP request to fetch project data
* @return {Promise}
*/
export async function getProjectData(): Promise<ProjectData> {
const res = await axios.get(projectDataURL);
return res.data;
}
/**
* @description HTTP request to mutate project data
* @return {Promise}
*/
export async function postProjectData(data: ProjectData) {
return axios.post(projectDataURL, data);
}
@@ -1,6 +1,6 @@
import { useCallback } from 'react';
import { EditorUpdateFields } from '../../../../features/event-editor/EventEditor';
import { TitleActions } from '../../../../features/event-editor/composite/EventEditorDataLeft';
import Swatch from './Swatch';
@@ -8,8 +8,8 @@ import style from './SwatchSelect.module.scss';
interface ColourInputProps {
value: string;
name: EditorUpdateFields;
handleChange: (newValue: EditorUpdateFields, name: string) => void;
name: TitleActions;
handleChange: (newValue: TitleActions, name: string) => void;
}
const colours = [
@@ -38,12 +38,6 @@
gap: $element-inner-spacing;
}
.noHover {
&:hover {
background-color: inherit;
}
}
.title {
font-size: $inner-section-text-size;
display: block;
@@ -1,15 +1,15 @@
import { useQuery } from '@tanstack/react-query';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { PROJECT_DATA } from '../api/apiConstants';
import { getProjectData } from '../api/projectDataApi';
import { projectDataPlaceholder } from '../models/ProjectData';
import { EVENT_DATA } from '../api/apiConstants';
import { fetchEventData } from '../api/eventDataApi';
import { eventDataPlaceholder } from '../models/EventData';
export default function useProjectData() {
export default function useEventData() {
const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: PROJECT_DATA,
queryFn: getProjectData,
placeholderData: projectDataPlaceholder,
queryKey: EVENT_DATA,
queryFn: fetchEventData,
placeholderData: eventDataPlaceholder,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
@@ -42,7 +42,7 @@ export default function useFullscreen() {
});
} else if (element.webkitRequestFullscreen) {
// iOS Safari fullscreen API is supported
element.webkitRequestFullscreen?.().catch(() => {
element.webkitRequestFullscreen().catch(() => {
/* nothing to do */
});
}
@@ -55,7 +55,7 @@ export default function useFullscreen() {
});
} else if ((document as WebkitDocument).webkitExitFullscreen) {
// iOS Safari fullscreen API is supported
(document as WebkitDocument).webkitExitFullscreen?.().catch(() => {
(document as WebkitDocument).webkitExitFullscreen().catch(() => {
/* nothing to do */
});
}
+2 -5
View File
@@ -5,9 +5,7 @@ import { socketSendJson } from '../utils/socket';
export const useRundownEditor = () => {
const featureSelector = (state: RuntimeStore) => ({
playback: state.playback,
selectedEventId: state.loaded.selectedEventId,
nextEventId: state.loaded.nextEventId,
});
return useRuntimeStore(featureSelector, deepCompare);
@@ -79,8 +77,7 @@ export const setPlayback = {
export const useInfoPanel = () => {
const featureSelector = (state: RuntimeStore) => ({
eventNow: state.eventNow,
eventNext: state.eventNext,
titles: state.titles,
playback: state.playback,
selectedEventIndex: state.loaded.selectedEventIndex,
numEvents: state.loaded.numEvents,
@@ -95,7 +92,7 @@ export const useCuesheet = () => {
selectedEventId: state.loaded.selectedEventId,
selectedEventIndex: state.loaded.selectedEventIndex,
numEvents: state.loaded.numEvents,
titleNow: state.eventNow?.title || '',
titleNow: state.titles.titleNow,
});
return useRuntimeStore(featureSelector, deepCompare);
@@ -1,6 +1,6 @@
import { ProjectData } from 'ontime-types';
import { EventData } from 'ontime-types';
export const projectDataPlaceholder: ProjectData = {
export const eventDataPlaceholder: EventData = {
title: '',
description: '',
publicUrl: '',
+20 -4
View File
@@ -42,10 +42,26 @@ export const runtimeStorePlaceholder = {
nextEventId: null,
nextPublicEventId: null,
},
eventNow: null,
eventNext: null,
publicEventNow: null,
publicEventNext: null,
titles: {
titleNow: null,
subtitleNow: null,
presenterNow: null,
noteNow: null,
titleNext: null,
subtitleNext: null,
presenterNext: null,
noteNext: null,
},
titlesPublic: {
titleNow: null,
subtitleNow: null,
presenterNow: null,
noteNow: null,
titleNext: null,
subtitleNext: null,
presenterNext: null,
noteNext: null,
},
};
export const runtime = createStore<RuntimeStore>(() => ({
@@ -1,6 +0,0 @@
export function isMacOS() {
const userAgent = navigator.userAgent.toLowerCase();
return userAgent.includes('macintosh') || userAgent.includes('mac os');
}
export const deviceAlt = isMacOS() ? '⌥' : 'Alt';
+12
View File
@@ -87,6 +87,18 @@ export const connectSocket = (preferredClientName?: string) => {
runtime.setState(state);
break;
}
case 'ontime-titles': {
const state = runtime.getState();
state.titles = payload;
runtime.setState(state);
break;
}
case 'ontime-titlesPublic': {
const state = runtime.getState();
state.titlesPublic = payload;
runtime.setState(state);
break;
}
case 'ontime-timerMessage': {
const state = runtime.getState();
state.timerMessage = payload;
@@ -61,11 +61,6 @@ $table-header-font-size: calc(1rem - 3px);
.eventRow {
vertical-align: top;
&:hover {
outline: 1px solid $blue-700;
outline-offset: -1px;
}
td {
background-color: $gray-1250;
border-radius: 2px;
+121 -20
View File
@@ -1,20 +1,34 @@
import { useRef } from 'react';
import { Tooltip } from '@chakra-ui/react';
import {
closestCenter,
DndContext,
DragEndEvent,
KeyboardSensor,
PointerSensor,
TouchSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import { horizontalListSortingStrategy, SortableContext, sortableKeyboardCoordinates } from '@dnd-kit/sortable';
import { ColumnDef, flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table';
import { isOntimeBlock, isOntimeDelay, isOntimeEvent, OntimeRundown, OntimeRundownEntry } from 'ontime-types';
import useFollowComponent from '../../common/hooks/useFollowComponent';
import { useLocalStorage } from '../../common/hooks/useLocalStorage';
import { millisToDelayString } from '../../common/utils/dateConfig';
import { getAccessibleColour } from '../../common/utils/styleUtils';
import { tooltipDelayFast } from '../../ontimeConfig';
import BlockRow from './cuesheet-table-elements/BlockRow';
import CuesheetHeader from './cuesheet-table-elements/CuesheetHeader';
import DelayRow from './cuesheet-table-elements/DelayRow';
import EventRow from './cuesheet-table-elements/EventRow';
import CuesheetTableSettings from './cuesheet-table-settings/CuesheetTableSettings';
import { useCuesheetSettings } from './store/CuesheetSettings';
import { SortableCell } from './tableElements/SortableCell';
import { initialColumnOrder } from './cuesheetCols';
import style from './Cuesheet.module.scss';
const pastOpacity = '0.2';
interface CuesheetProps {
data: OntimeRundown;
columns: ColumnDef<OntimeRundownEntry>[];
@@ -53,6 +67,49 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
getCoreRowModel: getCoreRowModel(),
});
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
delay: 100,
tolerance: 50,
},
}),
useSensor(TouchSensor, {
activationConstraint: {
delay: 100,
tolerance: 50,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
const handleOnDragEnd = (event: DragEndEvent) => {
const { delta, active, over } = event;
// cancel if delta y is greater than 200
if (delta.y > 200) return;
// cancel if we do not have an over id
if (over?.id == null) return;
// get index of from
const fromIndex = columnOrder.indexOf(active.id as string);
// get index of to
const toIndex = columnOrder.indexOf(over.id as string);
if (toIndex === -1) {
return;
}
const reorderedCols = [...columnOrder];
const reorderedItem = reorderedCols.splice(fromIndex, 1);
reorderedCols.splice(toIndex, 0, reorderedItem[0]);
saveColumnOrder(reorderedCols);
};
const resetColumnOrder = () => {
saveColumnOrder(initialColumnOrder);
};
@@ -65,8 +122,6 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
setColumnSizing({});
};
const headerGroups = table.getHeaderGroups;
let eventIndex = 0;
let isPast = Boolean(selectedId);
@@ -82,7 +137,37 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
)}
<div ref={tableContainerRef} className={style.cuesheetContainer}>
<table className={style.cuesheet}>
<CuesheetHeader headerGroups={headerGroups} />
<thead className={style.tableHeader}>
{table.getHeaderGroups().map((headerGroup) => {
const key = headerGroup.id;
return (
<DndContext key={key} sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleOnDragEnd}>
<tr key={headerGroup.id}>
<th className={style.indexColumn}>
<Tooltip label='Event Order' openDelay={tooltipDelayFast}>
#
</Tooltip>
</th>
<SortableContext key={key} items={headerGroup.headers} strategy={horizontalListSortingStrategy}>
{headerGroup.headers.map((header) => {
const width = header.getSize();
return (
<SortableCell key={header.column.columnDef.id} header={header} style={{ width }}>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</SortableCell>
);
})}
</SortableContext>
</tr>
</DndContext>
);
})}
</thead>
<tbody>
{table.getRowModel().rows.map((row) => {
const key = row.original.id;
@@ -92,7 +177,13 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
}
if (isOntimeBlock(row.original)) {
return <BlockRow key={key} title={row.original.title} />;
const title = row.original.title;
return (
<tr key={key} className={style.blockRow}>
<td>{title}</td>
</tr>
);
}
if (isOntimeDelay(row.original)) {
const delayVal = row.original.duration;
@@ -101,7 +192,12 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
return null;
}
return <DelayRow key={key} duration={delayVal} />;
const delayTime = millisToDelayString(delayVal);
return (
<tr key={key} className={style.delayRow}>
<td>{delayTime}</td>
</tr>
);
}
if (isOntimeEvent(row.original)) {
eventIndex++;
@@ -114,20 +210,25 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
return null;
}
let rowBgColour: string | undefined;
if (isSelected) {
rowBgColour = 'var(--cuesheet-running-bg-override, #D20300)'; // $red-700
}
const bgFallback = 'transparent';
const bgColour = row.original.colour || bgFallback;
const textColour = bgColour === bgFallback ? undefined : getAccessibleColour(bgColour);
const isSkipped = row.original.skip;
let rowBgColour: string | undefined;
if (row.original.id === selectedId) {
rowBgColour = '#D20300'; // $red-700
}
return (
<EventRow
<tr
key={key}
eventIndex={eventIndex}
isPast={isPast}
selectedRef={isSelected ? selectedRef : undefined}
skip={row.original.skip}
colour={row.original.colour}
className={`${style.eventRow} ${isSkipped ? style.skip : ''}`}
style={{ opacity: `${isPast ? pastOpacity : '1'}` }}
ref={isSelected ? selectedRef : undefined}
>
<td className={style.indexColumn} style={{ backgroundColor: bgColour, color: textColour?.color }}>
{eventIndex}
</td>
{row.getVisibleCells().map((cell) => {
return (
<td key={cell.id} style={{ width: cell.column.getSize(), backgroundColor: rowBgColour }}>
@@ -135,7 +236,7 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
</td>
);
})}
</EventRow>
</tr>
);
}
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo } from 'react';
import { OntimeRundownEntry, ProjectData } from 'ontime-types';
import { EventData, OntimeRundownEntry } from 'ontime-types';
import Empty from '../../common/components/state/Empty';
import { useEventAction } from '../../common/hooks/useEventAction';
@@ -69,7 +69,7 @@ export default function CuesheetWrapper() {
);
const exportHandler = useCallback(
(headerData: ProjectData) => {
(headerData: EventData) => {
if (!headerData || !rundown || !userFields) {
return;
}
@@ -6,11 +6,7 @@ exports[`makeTable() > returns array of arrays with given fields 1`] = `
"Ontime · Schedule Template",
],
[
"Project Title",
"",
],
[
"Project Description",
"Event Name",
"",
],
[
@@ -26,7 +26,7 @@ describe('parseField()', () => {
});
it('returns an empty string on undefined fields', () => {
expect(parseField('presenter')).toBe('');
expect(parseField('presenter', undefined)).toBe('');
});
describe('simply returns any other value in any other field', () => {
@@ -1,18 +0,0 @@
import { memo } from 'react';
import style from '../Cuesheet.module.scss';
interface BlockRowProps {
title: string;
}
function BlockRow(props: BlockRowProps) {
const { title } = props;
return (
<tr className={style.blockRow}>
<td>{title}</td>
</tr>
);
}
export default memo(BlockRow);
@@ -1,108 +0,0 @@
import { memo } from 'react';
import { Tooltip } from '@chakra-ui/react';
import {
closestCenter,
DndContext,
DragEndEvent,
KeyboardSensor,
PointerSensor,
TouchSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import { horizontalListSortingStrategy, SortableContext, sortableKeyboardCoordinates } from '@dnd-kit/sortable';
import { flexRender, HeaderGroup } from '@tanstack/react-table';
import { OntimeRundownEntry } from 'ontime-types';
import { useLocalStorage } from '../../../common/hooks/useLocalStorage';
import { tooltipDelayFast } from '../../../ontimeConfig';
import { initialColumnOrder } from '../cuesheetCols';
import { SortableCell } from './SortableCell';
import style from '../Cuesheet.module.scss';
interface CuesheetHeaderProps {
headerGroups: () => HeaderGroup<OntimeRundownEntry>[];
}
function CuesheetHeader(props: CuesheetHeaderProps) {
const { headerGroups } = props;
const [columnOrder, saveColumnOrder] = useLocalStorage<string[]>('table-order', initialColumnOrder);
const handleOnDragEnd = (event: DragEndEvent) => {
const { delta, active, over } = event;
// cancel if delta y is greater than 200
if (delta.y > 200) return;
// cancel if we do not have an over id
if (over?.id == null) return;
// get index of from
const fromIndex = columnOrder.indexOf(active.id as string);
// get index of to
const toIndex = columnOrder.indexOf(over.id as string);
if (toIndex === -1) {
return;
}
const reorderedCols = [...columnOrder];
const reorderedItem = reorderedCols.splice(fromIndex, 1);
reorderedCols.splice(toIndex, 0, reorderedItem[0]);
saveColumnOrder(reorderedCols);
};
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
delay: 100,
tolerance: 50,
},
}),
useSensor(TouchSensor, {
activationConstraint: {
delay: 100,
tolerance: 50,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
return (
<thead className={style.tableHeader}>
{headerGroups().map((headerGroup) => {
const key = headerGroup.id;
return (
<DndContext key={key} sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleOnDragEnd}>
<tr key={headerGroup.id}>
<th className={style.indexColumn}>
<Tooltip label='Event Order' openDelay={tooltipDelayFast}>
#
</Tooltip>
</th>
<SortableContext key={key} items={headerGroup.headers} strategy={horizontalListSortingStrategy}>
{headerGroup.headers.map((header) => {
const width = header.getSize();
return (
<SortableCell key={header.column.columnDef.id} header={header} style={{ width }}>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</SortableCell>
);
})}
</SortableContext>
</tr>
</DndContext>
);
})}
</thead>
);
}
export default memo(CuesheetHeader);
@@ -1,6 +0,0 @@
interface CuesheetRowProps {
row: OntimeRundownEntry;
isSelected: boolean;
}
function CuesheetRow() {}
@@ -1,22 +0,0 @@
import { memo } from 'react';
import { millisToDelayString } from '../../../common/utils/dateConfig';
import style from '../Cuesheet.module.scss';
interface DelayRowProps {
duration: number;
}
function DelayRow(props: DelayRowProps) {
const { duration } = props;
const delayTime = millisToDelayString(duration);
return (
<tr className={style.delayRow}>
<td>{delayTime}</td>
</tr>
);
}
export default memo(DelayRow);
@@ -1,67 +0,0 @@
import { memo, MutableRefObject, PropsWithChildren, useLayoutEffect, useRef, useState } from 'react';
import { getAccessibleColour } from '../../../common/utils/styleUtils';
import style from '../Cuesheet.module.scss';
const pastOpacity = '0.2';
interface EventRowProps {
eventIndex: number;
isPast?: boolean;
selectedRef?: MutableRefObject<HTMLTableRowElement | null>;
skip?: boolean;
colour?: string;
}
function EventRow(props: PropsWithChildren<EventRowProps>) {
const { children, eventIndex, isPast, selectedRef, skip, colour } = props;
const ownRef = useRef<HTMLTableRowElement>(null);
const [isVisible, setIsVisible] = useState(false);
const bgFallback = 'transparent';
const bgColour = colour || bgFallback;
const textColour = bgColour === bgFallback ? undefined : getAccessibleColour(bgColour);
useLayoutEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsVisible(true);
}
},
{
root: null,
threshold: 0.01,
},
);
const handleRefCurrent = ownRef.current;
if (selectedRef) {
setIsVisible(true);
} else if (handleRefCurrent) {
observer.observe(handleRefCurrent);
}
return () => {
if (handleRefCurrent) {
observer.unobserve(handleRefCurrent);
}
};
}, [ownRef, selectedRef]);
return (
<tr
className={`${style.eventRow} ${skip ? style.skip : ''}`}
style={{ opacity: `${isPast ? pastOpacity : '1'}` }}
ref={selectedRef ?? ownRef}
>
<td className={style.indexColumn} style={{ backgroundColor: bgColour, color: textColour?.color }}>
{eventIndex}
</td>
{isVisible ? children : null}
</tr>
);
}
export default memo(EventRow);
@@ -3,20 +3,21 @@ import { IoContract } from '@react-icons/all-files/io5/IoContract';
import { IoExpand } from '@react-icons/all-files/io5/IoExpand';
import { IoLocate } from '@react-icons/all-files/io5/IoLocate';
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
import { Playback, ProjectData } from 'ontime-types';
import { EventData, Playback } from 'ontime-types';
import { formatDisplay } from 'ontime-utils';
import PlaybackIcon from '../../../common/components/playback-icon/PlaybackIcon';
import useFullscreen from '../../../common/hooks/useFullscreen';
import useProjectData from '../../../common/hooks-query/useProjectData';
import { useTimer } from '../../../common/hooks/useSocket';
import useEventData from '../../../common/hooks-query/useEventData';
import { formatTime } from '../../../common/utils/time';
import { tooltipDelayFast } from '../../../ontimeConfig';
import { useCuesheetSettings } from '../store/CuesheetSettings';
import CuesheetTableHeaderTimers from './CuesheetTableHeaderTimers';
import style from './CuesheetTableHeader.module.scss';
interface CuesheetTableHeaderProps {
handleCSVExport: (headerData: ProjectData) => void;
handleCSVExport: (headerData: EventData) => void;
featureData: {
playback: Playback;
selectedEventIndex: number | null;
@@ -30,12 +31,13 @@ export default function CuesheetTableHeader({ handleCSVExport, featureData }: Cu
const showSettings = useCuesheetSettings((state) => state.showSettings);
const toggleSettings = useCuesheetSettings((state) => state.toggleSettings);
const toggleFollow = useCuesheetSettings((state) => state.toggleFollow);
const timer = useTimer();
const { isFullScreen, toggleFullScreen } = useFullscreen();
const { data: project } = useProjectData();
const { data: event } = useEventData();
const exportCsv = () => {
if (project) {
handleCSVExport(project);
if (event) {
handleCSVExport(event);
}
};
@@ -45,17 +47,32 @@ export default function CuesheetTableHeader({ handleCSVExport, featureData }: Cu
featureData.numEvents ? featureData.numEvents : '-'
}`;
// prepare presentation variables
const isOvertime = (timer.current ?? 0) < 0;
const timerNow = timer.current == null ? '-' : `${isOvertime ? '-' : ''}${formatDisplay(timer.current)}`;
const timeNow = formatTime(timer.clock, {
showSeconds: true,
format: 'hh:mm:ss a',
});
return (
<div className={style.header}>
<div className={style.event}>
<div className={style.title}>{project?.title || '-'}</div>
<div className={style.title}>{event?.title || '-'}</div>
<div className={style.eventNow}>{featureData?.titleNow || '-'}</div>
</div>
<div className={style.playback}>
<div className={style.playbackLabel}>{selected}</div>
<PlaybackIcon state={featureData.playback} />
</div>
<CuesheetTableHeaderTimers />
<div className={style.timer}>
<div className={style.timerLabel}>Running Timer</div>
<div className={style.value}>{timerNow}</div>
</div>
<div className={style.clock}>
<div className={style.clockLabel}>Time Now</div>
<div className={style.value}>{timeNow}</div>
</div>
<div className={style.headerActions}>
<Tooltip openDelay={tooltipDelayFast} label='Toggle follow'>
<span onClick={() => toggleFollow()} className={`${style.actionIcon} ${followSelected ? style.enabled : ''}`}>
@@ -1,31 +0,0 @@
import { formatDisplay } from 'ontime-utils';
import { useTimer } from '../../../common/hooks/useSocket';
import { formatTime } from '../../../common/utils/time';
import style from './CuesheetTableHeader.module.scss';
export default function CuesheetTableHeaderTimers() {
const timer = useTimer();
// prepare presentation variables
const isOvertime = (timer.current ?? 0) < 0;
const timerNow = timer.current == null ? '-' : `${isOvertime ? '-' : ''}${formatDisplay(timer.current)}`;
const timeNow = formatTime(timer.clock, {
showSeconds: true,
format: 'hh:mm:ss a',
});
return (
<>
<div className={style.timer}>
<div className={style.timerLabel}>Running Timer</div>
<div className={style.value}>{timerNow}</div>
</div>
<div className={style.clock}>
<div className={style.clockLabel}>Time Now</div>
<div className={style.value}>{timeNow}</div>
</div>
</>
);
}
@@ -1,4 +1,3 @@
import { memo } from 'react';
import { Button, Checkbox, Switch } from '@chakra-ui/react';
import { Column } from '@tanstack/react-table';
import { OntimeRundownEntry } from 'ontime-types';
@@ -20,7 +19,7 @@ interface CuesheetTableSettingsProps {
handleClearToggles: () => void;
}
function CuesheetTableSettings(props: CuesheetTableSettingsProps) {
export default function CuesheetTableSettings(props: CuesheetTableSettingsProps) {
const { columns, handleResetResizing, handleResetReordering, handleClearToggles } = props;
const showPrevious = useCuesheetSettings((state) => state.showPrevious);
const togglePreviousVisibility = useCuesheetSettings((state) => state.togglePreviousVisibility);
@@ -82,5 +81,3 @@ function CuesheetTableSettings(props: CuesheetTableSettingsProps) {
</div>
);
}
export default memo(CuesheetTableSettings);
@@ -6,8 +6,8 @@ import { millisToString } from 'ontime-utils';
import DelayIndicator from '../../common/components/delay-indicator/DelayIndicator';
import EditableCell from './cuesheet-table-elements/EditableCell';
import { useCuesheetSettings } from './store/CuesheetSettings';
import EditableCell from './tableElements/EditableCell';
import style from './Cuesheet.module.scss';
@@ -1,5 +1,5 @@
import { stringify } from 'csv-stringify/browser/esm/sync';
import { OntimeEntryCommonKeys, OntimeRundown, ProjectData, UserFields } from 'ontime-types';
import { EventData, OntimeEntryCommonKeys, OntimeRundown, UserFields } from 'ontime-types';
import { millisToString } from 'ontime-utils';
/**
@@ -38,11 +38,10 @@ export const parseField = (field: keyof OntimeRundown, data: unknown): string =>
* @param {object} userFields
* @return {(string[])[]}
*/
export const makeTable = (headerData: ProjectData, rundown: OntimeRundown, userFields: UserFields): string[][] => {
export const makeTable = (headerData: EventData, rundown: OntimeRundown, userFields: UserFields): string[][] => {
const data = [
['Ontime · Schedule Template'],
['Project Title', headerData?.title || ''],
['Project Description', headerData?.description || ''],
['Event Name', headerData?.title || ''],
['Public URL', headerData?.publicUrl || ''],
['Backstage URL', headerData?.backstageUrl || ''],
[],
@@ -2,15 +2,16 @@ import { useCallback } from 'react';
import { Textarea } from '@chakra-ui/react';
import useReactiveTextInput from '../../../common/components/input/text-input/useReactiveTextInput';
import { EditorUpdateFields } from '../EventEditor';
import { TitleActions } from './EventEditorDataLeft';
import style from '../EventEditor.module.scss';
interface CountedTextAreaProps {
field: EditorUpdateFields;
field: TitleActions;
label: string;
initialValue: string;
submitHandler: (field: EditorUpdateFields, value: string) => void;
submitHandler: (field: TitleActions, value: string) => void;
}
export default function CountedTextArea(props: CountedTextAreaProps) {
+8 -8
View File
@@ -12,17 +12,17 @@ export default function Info() {
const showNif = useEditorSettings((state) => state.eventSettings.showNif);
const titlesNow = {
title: data.eventNow?.title || '',
subtitle: data.eventNow?.subtitle || '',
presenter: data.eventNow?.presenter || '',
note: data.eventNow?.note || '',
title: data.titles.titleNow || '',
subtitle: data.titles.subtitleNow || '',
presenter: data.titles.presenterNow || '',
note: data.titles.noteNow || '',
};
const titlesNext = {
title: data.eventNext?.title || '',
subtitle: data.eventNext?.subtitle || '',
presenter: data.eventNext?.presenter || '',
note: data.eventNext?.note || '',
title: data.titles.titleNext || '',
subtitle: data.titles.subtitleNext || '',
presenter: data.titles.presenterNext || '',
note: data.titles.noteNext || '',
};
const selected = !data.numEvents
@@ -1,9 +1,9 @@
import useProjectData from '../../../common/hooks-query/useProjectData';
import useEventData from '../../../common/hooks-query/useEventData';
import style from '../Info.module.scss';
export default function InfoHeader({ selected }: { selected: string }) {
const { data } = useProjectData();
const { data } = useEventData();
return (
<>
@@ -41,8 +41,8 @@ $el-padding-with-compensation: 24px; // 16 + 8
.title {
font-size: $inner-section-text-size;
color: $gray-500;
padding-left: 0.5rem;
margin: 0.5rem 0;
padding-left: 8px;
margin: 8px 0;
text-transform: uppercase;
}
@@ -107,16 +107,6 @@ $el-padding-with-compensation: 24px; // 16 + 8
color: $error-red;
}
.success {
@include subsection;
color: $action-blue;
}
.feedbackSection {
justify-content: flex-start;
}
.buttonSection {
margin-top: $section-spacing;
display: flex;
@@ -127,10 +117,6 @@ $el-padding-with-compensation: 24px; // 16 + 8
flex-grow: 1;
}
.vSpacer {
height: 2rem;
}
.shiftRight {
align-self: flex-end;
}
@@ -149,12 +135,6 @@ $el-padding-with-compensation: 24px; // 16 + 8
grid-template-columns: auto 1fr;
}
.twoEqualColumn {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.padBottom {
padding-bottom: $element-spacing;
}
@@ -16,12 +16,12 @@ import {
ModalOverlay,
Textarea,
} from '@chakra-ui/react';
import type { ProjectData } from 'ontime-types';
import type { EventData } from 'ontime-types';
import { PROJECT_DATA, RUNDOWN_TABLE } from '../../../common/api/apiConstants';
import { EVENT_DATA, RUNDOWN_TABLE } from '../../../common/api/apiConstants';
import { postNew } from '../../../common/api/ontimeApi';
import useProjectData from '../../../common/hooks-query/useProjectData';
import { projectDataPlaceholder } from '../../../common/models/ProjectData';
import useEventData from '../../../common/hooks-query/useEventData';
import { eventDataPlaceholder } from '../../../common/models/EventData';
import { ontimeQueryClient } from '../../../common/queryClient';
import styles from '../Modal.module.scss';
@@ -32,7 +32,7 @@ interface QuickStartProps {
}
export default function QuickStart({ onClose, isOpen }: QuickStartProps) {
const { data, status } = useProjectData();
const { data, status } = useEventData();
const {
handleSubmit,
register,
@@ -49,10 +49,10 @@ export default function QuickStart({ onClose, isOpen }: QuickStartProps) {
if (data) reset(data);
}, [data, reset]);
const onSubmit = async (data: Partial<ProjectData>) => {
const onSubmit = async (data: Partial<EventData>) => {
try {
await postNew(data);
await ontimeQueryClient.invalidateQueries(PROJECT_DATA);
await ontimeQueryClient.invalidateQueries(EVENT_DATA);
await ontimeQueryClient.invalidateQueries(RUNDOWN_TABLE);
onClose();
@@ -61,7 +61,7 @@ export default function QuickStart({ onClose, isOpen }: QuickStartProps) {
}
};
const onReset = () => reset(projectDataPlaceholder);
const onReset = () => reset(eventDataPlaceholder);
const disableButtons = status !== 'success' || isSubmitting;
return (
@@ -86,13 +86,13 @@ export default function QuickStart({ onClose, isOpen }: QuickStartProps) {
<div className={styles.column}>
<AlertTitle>Note</AlertTitle>
<AlertDescription>
On submit, application options will be kept but rundown and project data will be reset
On submit, application options will be kept but rundown and event data will be reset
</AlertDescription>
</div>
</Alert>
<div className={styles.entryRow}>
<label className={styles.sectionTitle}>
Project title
Event title
<Input
variant='ontime-filled-on-light'
size='sm'
@@ -104,7 +104,7 @@ export default function QuickStart({ onClose, isOpen }: QuickStartProps) {
</div>
<div className={styles.entryRow}>
<label className={styles.sectionTitle}>
Project description
Event description
<Input
variant='ontime-filled-on-light'
size='sm'
@@ -116,7 +116,7 @@ export default function QuickStart({ onClose, isOpen }: QuickStartProps) {
</div>
<div className={styles.entryRow}>
<label className={styles.sectionTitle}>
Public info
Public Info
<Textarea
variant='ontime-filled-on-light'
size='sm'
@@ -128,7 +128,7 @@ export default function QuickStart({ onClose, isOpen }: QuickStartProps) {
</div>
<div className={styles.entryRow}>
<label className={styles.sectionTitle}>
Public QR code Url
Public QR Code Url
<Input
variant='ontime-filled-on-light'
size='sm'
@@ -139,7 +139,7 @@ export default function QuickStart({ onClose, isOpen }: QuickStartProps) {
</div>
<div className={styles.entryRow}>
<label className={styles.sectionTitle}>
Backstage info
Backstage Info
<Textarea
variant='ontime-filled-on-light'
size='sm'
@@ -151,7 +151,7 @@ export default function QuickStart({ onClose, isOpen }: QuickStartProps) {
</div>
<div className={styles.entryRow}>
<label className={styles.sectionTitle}>
Backstage QR code Url
Backstage QR Code Url
<Input
variant='ontime-filled-on-light'
size='sm'
@@ -1,11 +1,11 @@
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { Input, Textarea } from '@chakra-ui/react';
import { ProjectData } from 'ontime-types';
import { EventData } from 'ontime-types';
import { logAxiosError } from '../../../common/api/apiUtils';
import { postProjectData } from '../../../common/api/projectDataApi';
import useProjectData from '../../../common/hooks-query/useProjectData';
import { postEventData } from '../../../common/api/eventDataApi';
import useEventData from '../../../common/hooks-query/useEventData';
import ModalLoader from '../modal-loader/ModalLoader';
import { inputProps } from '../modalHelper';
import ModalInput from '../ModalInput';
@@ -13,14 +13,14 @@ import OntimeModalFooter from '../OntimeModalFooter';
import style from './SettingsModal.module.scss';
export default function ProjectDataForm() {
const { data, status, isFetching, refetch } = useProjectData();
export default function EventDataForm() {
const { data, status, isFetching, refetch } = useEventData();
const {
handleSubmit,
register,
reset,
formState: { errors, isSubmitting, isDirty, isValid },
} = useForm<ProjectData>({
} = useForm<EventData>({
defaultValues: data,
values: data,
resetOptions: {
@@ -34,11 +34,11 @@ export default function ProjectDataForm() {
}
}, [data, reset]);
const onSubmit = async (formData: ProjectData) => {
const onSubmit = async (formData: EventData) => {
try {
await postProjectData(formData);
await postEventData(formData);
} catch (error) {
logAxiosError('Error saving project data', error);
logAxiosError('Error saving event settings', error);
} finally {
await refetch();
}
@@ -55,10 +55,10 @@ export default function ProjectDataForm() {
}
return (
<form onSubmit={handleSubmit(onSubmit)} id='project-data' className={style.sectionContainer}>
<form onSubmit={handleSubmit(onSubmit)} id='event-data' className={style.sectionContainer}>
<ModalInput
field='title'
title='Project title'
title='Event title'
description='Shown in overview screens'
error={errors.title?.message}
>
@@ -73,7 +73,7 @@ export default function ProjectDataForm() {
</ModalInput>
<ModalInput
field='description'
title='Project description'
title='Event description'
description='Free field, shown in editor'
error={errors.description?.message}
>
@@ -87,7 +87,7 @@ export default function ProjectDataForm() {
/>
</ModalInput>
<div style={{ height: '16px' }} />
<ModalInput field='publicInfo' title='Public info' description='Information shown in public screens'>
<ModalInput field='publicInfo' title='Public Info' description='Information shown in public screens'>
<Textarea
{...inputProps}
variant='ontime-filled-on-light'
@@ -107,7 +107,7 @@ export default function ProjectDataForm() {
/>
</ModalInput>
<div style={{ height: '16px' }} />
<ModalInput field='backstageInfo' title='Backstage info' description='Information shown in public screens'>
<ModalInput field='backstageInfo' title='Backstage Info' description='Information shown in public screens'>
<Textarea
{...inputProps}
variant='ontime-filled-on-light'
@@ -128,7 +128,7 @@ export default function ProjectDataForm() {
/>
</ModalInput>
<OntimeModalFooter
formId='project-data'
formId='event-data'
handleRevert={onReset}
isDirty={isDirty}
isValid={isValid}
@@ -6,7 +6,7 @@ import AliasesForm from './AliasesForm';
import AppSettingsModal from './AppSettings';
import CuesheetSettingsForm from './CuesheetSettingsForm';
import EditorSettings from './EditorSettings';
import ProjectDataForm from './ProjectDataForm';
import EventDataForm from './EventDataForm';
import ViewSettingsForm from './ViewSettingsForm';
interface ModalManagerProps {
@@ -22,7 +22,7 @@ export default function SettingsModal(props: ModalManagerProps) {
<Tabs variant='ontime' size='sm' isLazy>
<TabList>
<Tab>App</Tab>
<Tab>Project Data</Tab>
<Tab>Event Data</Tab>
<Tab>Editor</Tab>
<Tab>Cuesheet</Tab>
<Tab>Views</Tab>
@@ -33,7 +33,7 @@ export default function SettingsModal(props: ModalManagerProps) {
<AppSettingsModal />
</TabPanel>
<TabPanel>
<ProjectDataForm />
<EventDataForm />
</TabPanel>
<TabPanel>
<EditorSettings />
@@ -1,67 +0,0 @@
import { ChangeEvent, useRef, useState } from 'react';
import { Input } from '@chakra-ui/react';
import UploadEntry from './upload-entry/UploadEntry';
import { useUploadModalContextStore } from './uploadModalContext';
import { validateFile } from './uploadUtils';
import style from './UploadModal.module.scss';
export default function UploadFile() {
const fileInputRef = useRef<HTMLInputElement>(null);
const { file, setFile, progress } = useUploadModalContextStore();
const [errors, setErrors] = useState<string | undefined>();
const success = false;
const clearFile = () => {
setFile(null);
setErrors('');
};
const handleFile = (event: ChangeEvent<HTMLInputElement>) => {
setErrors('');
const selectedFile = event?.target?.files?.[0];
if (!selectedFile) {
setFile(null);
return;
}
try {
validateFile(selectedFile);
setFile(selectedFile);
} catch (error) {
if (error instanceof Error) {
setErrors(error.message);
}
setFile(null);
}
};
const handleClick = () => {
fileInputRef.current?.click();
};
return (
<>
<Input
ref={fileInputRef}
style={{ display: 'none' }}
type='file'
onChange={handleFile}
accept='.json, .xlsx'
data-testid='file-input'
/>
{!file && (
<div className={style.uploadArea} onClick={handleClick}>
Click to upload Ontime project or xlsx file
</div>
)}
{(file || errors) && (
<UploadEntry file={file} errors={errors} progress={progress} success={success} handleClear={clearFile} />
)}
</>
);
}
@@ -5,31 +5,79 @@
.uploadBody {
display: flex;
flex-direction: column;
gap: 1rem;
gap: 16px;
}
.uploadArea {
margin: 0 auto;
width: 100%;
max-width: 550px;
min-height: 150px;
border: 2px dashed $gray-200;
min-height: 200px;
border: 2px dashed $gray-50;
border-radius: 3px;
display: grid;
place-content: center;
transition-property: background-color;
transition-duration: $transition-time-action;
font-size: calc(1rem - 1px);
&:hover {
border: 2px solid $blue-500;
background-color: $blue-50;
cursor: pointer;
}
&.comment {
color: $modal-note-color;
color: gray;
}
}
.uploadedItem {
background-color: $gray-50;
padding: 8px;
display: grid;
grid-template-areas:
"icon title close"
"icon info ."
"progress progress progress";
grid-template-columns: auto 1fr auto;
column-gap: 16px;
border-radius: 3px;
.icon {
align-self: center;
grid-area: icon;
font-size: 32px;
color: $gray-700;
}
.fileTitle {
grid-area: title;
font-size: 14px;
color: $gray-1350;
}
.fileInfo {
grid-area: info;
font-size: 12px;
color: $gray-1100;
}
.fileProgress {
grid-area: progress;
}
.cancelUpload {
grid-area: close;
cursor: pointer;
}
&.error {
.icon {
color: $error-red;
}
}
&.success {
.icon {
color: $green-500;
}
}
}
@@ -41,9 +89,3 @@
.pad {
margin: 8px;
}
.twoColumn {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
}
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from 'react';
import { ChangeEvent, useCallback, useRef, useState } from 'react';
import {
Button,
Input,
Modal,
ModalBody,
ModalCloseButton,
@@ -8,33 +9,22 @@ import {
ModalFooter,
ModalHeader,
ModalOverlay,
Progress,
Switch,
} from '@chakra-ui/react';
import { IoClose } from '@react-icons/all-files/io5/IoClose';
import { IoDocumentTextOutline } from '@react-icons/all-files/io5/IoDocumentTextOutline';
import { IoWarningOutline } from '@react-icons/all-files/io5/IoWarningOutline';
import { useQueryClient } from '@tanstack/react-query';
import { OntimeRundown, ProjectData, UserFields } from 'ontime-types';
import { defaultExcelImportMap, ExcelImportMap } from 'ontime-utils';
import { invalidateAllCaches, maybeAxiosError } from '../../../common/api/apiUtils';
import {
patchData,
postPreviewExcel,
ProjectFileImportOptions,
uploadProjectFile,
} from '../../../common/api/ontimeApi';
import { projectDataPlaceholder } from '../../../common/models/ProjectData';
import { userFieldsPlaceholder } from '../../../common/models/UserFields';
import { RUNDOWN_TABLE } from '../../../common/api/apiConstants';
import { uploadData } from '../../../common/api/ontimeApi';
import { useEmitLog } from '../../../common/stores/logger';
import ModalSplitInput from '../ModalSplitInput';
import PreviewExcel from './preview/PreviewExcel';
import ExcelFileOptions from './upload-options/ExcelFileOptions';
import OntimeFileOptions from './upload-options/OntimeFileOptions';
import UploadStepTracker from './upload-step/UploadStep';
import UploadFile from './UploadFile';
import { useUploadModalContextStore } from './uploadModalContext';
import { isExcelFile, isOntimeFile } from './uploadUtils';
import { validateFile } from './utils';
import style from './UploadModal.module.scss';
import { PROJECT_DATA, RUNDOWN_TABLE, USERFIELDS } from '../../../common/api/apiConstants';
export type UploadStep = 'upload' | 'review';
interface UploadModalProps {
onClose: () => void;
@@ -43,130 +33,64 @@ interface UploadModalProps {
export default function UploadModal({ onClose, isOpen }: UploadModalProps) {
const queryClient = useQueryClient();
const { emitError } = useEmitLog();
const [errors, setErrors] = useState<string | undefined>();
const [isSubmitting, setSubmitting] = useState(false);
const [file, setFile] = useState<File | null>(null);
const [progress, setProgress] = useState(0);
const overrideOptionRef = useRef<HTMLInputElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const [success, setSuccess] = useState(false);
const { file, setProgress, clear } = useUploadModalContextStore();
const handleFile = useCallback((event: ChangeEvent<HTMLInputElement>) => {
const fileUploaded = event?.target?.files?.[0];
if (!fileUploaded) return;
const [uploadStep, setUploadStep] = useState<UploadStep>('upload');
const [submitting, setSubmitting] = useState(false);
const [rundown, setRundown] = useState<OntimeRundown | null>(null);
const [userFields, setUserFields] = useState<UserFields | null>(null);
const [project, setProject] = useState<ProjectData | null>(null);
const validate = validateFile(fileUploaded);
setErrors(validate.errors?.[0]);
const [errors, setErrors] = useState('');
if (validate.isValid) {
setFile(fileUploaded);
} else {
setFile(null);
}
}, []);
const ontimeFileOptions = useRef<Partial<ProjectFileImportOptions>>({});
const excelFileOptions = useRef<ExcelImportMap>(defaultExcelImportMap);
/* if the modal re-opens, we want to restart all states */
useEffect(() => {
clear();
setUploadStep('upload');
setSubmitting(false);
setRundown(null);
setUserFields(null);
setProject(null);
setErrors('');
}, [clear, isOpen]);
/* uploads file to backend
* - in the case of excel, we get the preview
* - in the case of project file, this is end of line
**/
const handleUpload = async () => {
let doClose = false;
const handleSubmit = useCallback(async () => {
setSubmitting(true);
if (file) {
setSubmitting(true);
setErrors('');
try {
if (isOntimeFile(file)) {
// TODO: we would also like to have preview for ontime project files
const options = ontimeFileOptions.current;
await handleOntimeFile(file, options);
doClose = true;
} else if (isExcelFile(file)) {
const options = excelFileOptions.current;
await handleExcelFile(file, options);
await invalidateAllCaches();
}
const options = {
onlyRundown: overrideOptionRef.current?.checked || false,
};
await uploadData(file, setProgress, options);
} catch (error) {
const message = maybeAxiosError(error);
setErrors(`Failed uploading file ${message}`);
emitError(`Failed uploading file: ${error}`);
} finally {
setSubmitting(false);
if (doClose) {
handleClose();
}
await queryClient.invalidateQueries(RUNDOWN_TABLE);
setSuccess(true);
}
}
setSubmitting(false);
}, [emitError, file, queryClient]);
// when we upload excel, we populate state with preview data
async function handleExcelFile(file: File, options: ExcelImportMap) {
const response = await postPreviewExcel(file, setProgress, options);
if (response.status === 200) {
setRundown(response.data.rundown);
setUserFields(response.data.userFields);
setProject(response.data.project);
// in excel imports we have an extra review step
setUploadStep('review');
}
}
// when we upload project files, no extra operations are done
async function handleOntimeFile(file: File, options: Partial<ProjectFileImportOptions>) {
await uploadProjectFile(file, setProgress, options);
}
const handleClick = () => {
fileInputRef.current?.click();
};
const clearFile = () => {
setFile(null);
};
// before closing the modal, we clear data from mutations
const handleClose = () => {
clear();
setRundown([]);
setUserFields(userFieldsPlaceholder);
setProject(projectDataPlaceholder);
clearFile();
setSuccess(false);
setErrors(undefined);
setProgress(0);
onClose();
};
const handleFinalise = async () => {
// this step is currently only used for excel files, after preview
if (isExcel && rundown && userFields && project) {
let doClose = false;
setSubmitting(true);
try {
await patchData({ rundown, userFields, project });
queryClient.setQueryData(RUNDOWN_TABLE, rundown);
queryClient.setQueryData(USERFIELDS, userFields);
queryClient.setQueryData(PROJECT_DATA, project);
await queryClient.invalidateQueries({
queryKey: [...RUNDOWN_TABLE, ...USERFIELDS, ...PROJECT_DATA],
});
doClose = true;
} catch (error) {
const message = maybeAxiosError(error);
setErrors(`Failed applying changes ${message}`);
} finally {
setSubmitting(false);
if (doClose) {
handleClose();
}
}
}
};
const undoReview = () => {
setUploadStep('upload');
setErrors('');
};
const isUpload = uploadStep === 'upload';
const isReview = uploadStep === 'review';
const isExcel = isExcelFile(file);
const isOntime = isOntimeFile(file);
const handleGoBack = isUpload ? undefined : undoReview;
const handleSubmit = isUpload ? handleUpload : handleFinalise;
const disableSubmit = (isUpload && !file) || (isReview && rundown === null);
const disableGoBack = isUpload;
const submitText = isUpload ? 'Upload' : 'Finish';
const disableSubmit = !file || isSubmitting;
return (
<Modal
@@ -177,51 +101,66 @@ export default function UploadModal({ onClose, isOpen }: UploadModalProps) {
size='xl'
scrollBehavior='inside'
preserveScrollBarGap
variant='ontime-upload'
variant='ontime-small'
>
<ModalOverlay />
<ModalContent>
<ModalHeader>File import</ModalHeader>
<ModalCloseButton />
<ModalBody className={style.uploadBody}>
{isExcel && <UploadStepTracker uploadStep={uploadStep} />}
{uploadStep === 'upload' ? (
<>
<UploadFile />
{isOntime && <OntimeFileOptions optionsRef={ontimeFileOptions} />}
{isExcel && <ExcelFileOptions optionsRef={excelFileOptions} />}
</>
) : (
<PreviewExcel
rundown={rundown ?? []}
project={project ?? projectDataPlaceholder}
userFields={userFields ?? userFieldsPlaceholder}
/>
)}
</ModalBody>
<ModalFooter>
<div className={style.feedbackSection}>{errors && <div className={style.error}>{errors}</div>}</div>
<div className={`${style.buttonSection} ${style.pad}`}>
<Button
onClick={handleGoBack}
isDisabled={disableGoBack || submitting}
variant='ontime-ghost-on-light'
size='sm'
>
Go Back
</Button>
<Button
onClick={handleSubmit}
isLoading={submitting}
isDisabled={disableSubmit}
variant='ontime-filled'
padding='0 2em'
size='sm'
>
{submitText}
</Button>
<Input
ref={fileInputRef}
style={{ display: 'none' }}
type='file'
onChange={handleFile}
accept='.json, .xlsx'
data-testid='file-input'
/>
<div className={style.uploadArea} onClick={handleClick}>
Click to upload Ontime project file
</div>
{file && (
<div className={`${style.uploadedItem} ${success ? style.success : ''}`}>
<IoClose className={style.cancelUpload} onClick={clearFile} />
<IoDocumentTextOutline className={style.icon} />
<span className={style.fileTitle}>{file.name}</span>
<span className={style.fileInfo}>{`${(file.size / 1024).toFixed(2)}kb - ${file.type}`}</span>
<Progress variant='ontime-on-light' className={style.fileProgress} value={progress} />
</div>
)}
{errors && (
<div className={`${style.uploadedItem} ${style.error}`}>
<IoWarningOutline className={style.icon} />
<span className={style.fileTitle}>{errors}</span>
<span className={style.fileInfo}>Please try again</span>
<Progress className={style.fileProgress} value={progress} />
</div>
)}
<div className={style.uploadOptions}>
<span className={style.title}>Import options</span>
<ModalSplitInput
field=''
title='Only import rundown'
description='All other options, including application settings will be discarded'
>
<Switch variant='ontime-on-light' ref={overrideOptionRef} />
</ModalSplitInput>
</div>
</ModalBody>
<ModalFooter className={`${style.buttonSection} ${style.pad}`}>
<Button onClick={handleClose} isDisabled={isSubmitting} variant='ontime-ghost-on-light' size='sm'>
Cancel
</Button>
<Button
onClick={handleSubmit}
isLoading={isSubmitting}
isDisabled={disableSubmit}
variant='ontime-filled'
padding='0 2em'
size='sm'
>
Import
</Button>
</ModalFooter>
</ModalContent>
</Modal>
@@ -1,22 +0,0 @@
@use "../../../../theme/_ontimeColours" as *;
@mixin pad-item {
padding-left: 0.5rem;
padding-right: 1rem;
}
.previewTable {
display: grid;
grid-template-columns: auto 1fr;
grid-template-rows: repeat(6, auto);
font-size: calc(1rem - 2px);
}
.field {
font-weight: 200;
@include pad-item;
}
.value {
@include pad-item;
}
@@ -1,26 +0,0 @@
import { OntimeRundown, ProjectData, UserFields } from 'ontime-types';
import PreviewProjectData from './PreviewProjectData';
import PreviewRundown from './PreviewRundown';
import style from '../../Modal.module.scss';
interface PreviewExcelProps {
rundown: OntimeRundown;
project: ProjectData;
userFields: UserFields;
}
export default function PreviewExcel(props: PreviewExcelProps) {
const { rundown, project, userFields } = props;
return (
<div className={`${style.column} ${style.noHover}`}>
<div className={style.title}>Review Project Data</div>
<PreviewProjectData project={project} />
<div className={style.vSpacer} />
<div className={style.title}>Review Rundown</div>
<PreviewRundown rundown={rundown} userFields={userFields} />
</div>
);
}
@@ -1,26 +0,0 @@
import { ProjectData } from 'ontime-types';
import style from './PreviewColumn.module.scss';
interface PreviewProjectDataProps {
project: ProjectData;
}
export default function PreviewProjectData({ project }: PreviewProjectDataProps) {
return (
<div className={style.previewTable}>
<span className={style.field}>Title</span>
<span className={style.value}>{project.title}</span>
<span className={style.field}>Description</span>
<span className={style.value}>{project.description}</span>
<span className={style.field}>Public URL</span>
<span className={style.value}>{project.publicUrl}</span>
<span className={style.field}>Public info</span>
<span className={style.value}>{project.publicInfo}</span>
<span className={style.field}>Backstage URL</span>
<span className={style.value}>{project.backstageUrl}</span>
<span className={style.field}>Backstage info</span>
<span className={style.value}>{project.backstageInfo}</span>
</div>
);
}
@@ -1,125 +0,0 @@
import { isOntimeEvent, OntimeRundown, UserFields } from 'ontime-types';
import { millisToString } from 'ontime-utils';
import { getAccessibleColour } from '../../../../common/utils/styleUtils';
import Tag from './Tag';
import style from './PreviewTable.module.scss';
interface PreviewRundownProps {
rundown: OntimeRundown;
userFields: UserFields;
}
function booleanToText(value?: boolean) {
return value ? 'Yes' : undefined;
}
export default function PreviewRundown({ rundown, userFields }: PreviewRundownProps) {
return (
<div className={style.container}>
<div className={style.scrollContainer}>
<table className={style.rundownPreview}>
<thead className={style.header}>
<tr>
<th>#</th>
<th>Type</th>
<th>Cue</th>
<th>Title</th>
<th>Subtitle</th>
<th>Presenter</th>
<th>Note</th>
<th>Time Start</th>
<th>Time End</th>
<th>Duration</th>
<th>Is Public</th>
<th>Skip</th>
<th>Colour</th>
<th>Timer Type</th>
<th>End Action</th>
<th>
user0 <Tag>{userFields.user0}</Tag>
</th>
<th>
user1 <Tag>{userFields.user1}</Tag>
</th>
<th>
user2 <Tag>{userFields.user2}</Tag>
</th>
<th>
user3 <Tag>{userFields.user3}</Tag>
</th>
<th>
user4 <Tag>{userFields.user4}</Tag>
</th>
<th>
user5 <Tag>{userFields.user5}</Tag>
</th>
<th>
user6 <Tag>{userFields.user6}</Tag>
</th>
<th>
user7 <Tag>{userFields.user7}</Tag>
</th>
<th>
user8 <Tag>{userFields.user8}</Tag>
</th>
<th>
user9 <Tag>{userFields.user9}</Tag>
</th>
</tr>
</thead>
<tbody className={style.body}>
{rundown.map((event, index) => {
const key = event.id;
if (isOntimeEvent(event)) {
const colour = event.colour ? getAccessibleColour(event.colour) : {};
const isPublic = booleanToText(event.isPublic);
const skip = booleanToText(event.skip);
return (
<tr key={key}>
<td className={style.center}>
<Tag>{index + 1}</Tag>
</td>
<td className={style.center}>
<Tag>Event</Tag>
</td>
<td className={style.nowrap}>{event.cue}</td>
<td>{event.title}</td>
<td>{event.subtitle}</td>
<td>{event.presenter}</td>
<td>{event.note}</td>
<td>{millisToString(event.timeStart)}</td>
<td>{millisToString(event.timeEnd)}</td>
<td>{millisToString(event.duration)}</td>
<td>{isPublic && <Tag>{isPublic}</Tag>}</td>
<td>{skip && <Tag>{skip}</Tag>}</td>
<td style={{ ...colour }}>{event.colour}</td>
<td>
<Tag>{event.timerType}</Tag>
</td>
<td>
<Tag>{event.endAction}</Tag>
</td>
<td>{event.user0}</td>
<td>{event.user1}</td>
<td>{event.user2}</td>
<td>{event.user3}</td>
<td>{event.user4}</td>
<td>{event.user5}</td>
<td>{event.user6}</td>
<td>{event.user7}</td>
<td>{event.user8}</td>
<td>{event.user9}</td>
</tr>
);
}
return null;
})}
</tbody>
</table>
</div>
</div>
);
}
@@ -1,60 +0,0 @@
@use "../../../../theme/_ontimeColours" as *;
.container {
max-width: 100%;
max-height: max(300px, 30vh);
overflow: scroll;
}
.scrollContainer {
overflow-x: scroll;
}
.rundownPreview {
font-size: calc(1rem - 2px);
border-collapse: separate;
}
.header,
.body {
th {
font-weight: 400;
height: unset;
line-height: calc(1rem - 2px);
white-space: nowrap;
padding-left: 0.25rem;
padding-right: 1rem;
}
}
.header {
th {
font-weight: 200;
text-align: left;
}
tr {
word-wrap: unset;
}
}
.body {
tr:nth-child(odd) {
background-color: $gray-50;
}
td {
text-align: left;
vertical-align: top;
padding: 0 0.5em;
}
.center {
text-align: center;
}
.nowrap {
white-space: nowrap;
}
}
@@ -1,10 +0,0 @@
@use "../../../../theme/_ontimeColours" as *;
.tag {
font-size: 10px;
background-color: $blue-500;
color: $pure-white;
border-radius: 2px;
padding: 0 0.25rem;
white-space: nowrap;
}
@@ -1,7 +0,0 @@
import { ReactNode } from 'react';
import style from './Tag.module.scss';
export default function Tag({ children }: { children: ReactNode }) {
return <span className={style.tag}>{children}</span>;
}
@@ -1,59 +0,0 @@
@use '../../../../theme/ontimeColours' as *;
@use '../../../../theme/v2Styles' as *;
.uploadedItem {
margin: 0 auto;
width: 100%;
max-width: 550px;
border: 1px solid $gray-200;
padding: 0.5rem;
display: grid;
grid-template-areas:
"icon title close"
"icon info ."
"progress progress progress";
grid-template-columns: auto 1fr auto;
column-gap: 1rem;
border-radius: 3px;
.icon {
align-self: center;
grid-area: icon;
font-size: 2rem;
color: $gray-700;
}
.fileTitle {
grid-area: title;
font-size: calc(1rem - 2px);
color: $ui-black;
}
.fileInfo {
grid-area: info;
font-size: calc(1rem - 4px);
color: $gray-1100;
}
.fileProgress {
grid-area: progress;
}
.cancelUpload {
grid-area: close;
cursor: pointer;
}
&.error {
.icon {
color: $error-red;
}
}
&.success {
.icon {
color: $green-500;
}
}
}
@@ -1,53 +0,0 @@
import { Progress } from '@chakra-ui/react';
import { IoClose } from '@react-icons/all-files/io5/IoClose';
import { IoDocumentTextOutline } from '@react-icons/all-files/io5/IoDocumentTextOutline';
import { IoWarningOutline } from '@react-icons/all-files/io5/IoWarningOutline';
import { isExcelFile, isOntimeFile } from '../uploadUtils';
import style from './UploadEntry.module.scss';
interface UploadEntryProps {
file: File | null;
errors?: string;
progress: number;
success: boolean;
handleClear: () => void;
}
export default function UploadEntry(props: UploadEntryProps) {
const { file, errors, progress, success, handleClear } = props;
if (errors) {
return (
<div className={`${style.uploadedItem} ${style.error}`}>
<IoClose className={style.cancelUpload} onClick={handleClear} />
<IoWarningOutline className={style.icon} />
<span className={style.fileTitle}>{errors}</span>
<span className={style.fileInfo}>Please try again</span>
</div>
);
}
if (file) {
const fileSize = `${(file.size / 1024).toFixed(2)}kb`;
let fileType = '';
if (isOntimeFile(file)) {
fileType = 'Ontime Project File';
} else if (isExcelFile(file)) {
fileType = 'Excel Rundown';
}
return (
<div className={`${style.uploadedItem} ${success ? style.success : ''}`}>
<IoClose className={style.cancelUpload} onClick={handleClear} />
<IoDocumentTextOutline className={style.icon} />
<span className={style.fileTitle}>{file.name}</span>
<span className={style.fileInfo}>{`${fileSize} - ${fileType}`}</span>
<Progress variant='ontime-on-light' className={style.fileProgress} value={progress} />
</div>
);
}
return null;
}
@@ -1,76 +0,0 @@
import { MutableRefObject } from 'react';
import { ExcelImportMap } from 'ontime-utils';
import ImportMapTable, { type TableEntry } from './ImportMapTable';
import style from '../UploadModal.module.scss';
interface ExcelFileOptionsProps {
optionsRef: MutableRefObject<ExcelImportMap>;
}
export default function ExcelFileOptions(props: ExcelFileOptionsProps) {
const { optionsRef } = props;
const updateRef = <T extends keyof ExcelImportMap>(field: T, value: ExcelImportMap[T]) => {
// avoid unnecessary changes
if (optionsRef.current[field] !== value) {
optionsRef.current = { ...optionsRef.current, [field]: value };
}
};
const worksheet: TableEntry[] = [{ label: 'Worksheet', title: 'worksheet', value: optionsRef.current.worksheet }];
const timings: TableEntry[] = [
{ label: 'Start time', title: 'timeStart', value: optionsRef.current.timeStart },
{ label: 'End Time', title: 'timeEnd', value: optionsRef.current.timeEnd },
{ label: 'Duration', title: 'duration', value: optionsRef.current.duration },
];
const titles: TableEntry[] = [
{ label: 'Cue', title: 'cue', value: optionsRef.current.cue },
{ label: 'Colour', title: 'colour', value: optionsRef.current.colour },
{ label: 'Title', title: 'title', value: optionsRef.current.title },
{ label: 'Presenter', title: 'presenter', value: optionsRef.current.presenter },
{ label: 'Subtitle', title: 'subtitle', value: optionsRef.current.subtitle },
{ label: 'Note', title: 'note', value: optionsRef.current.note },
];
const options: TableEntry[] = [
{ label: 'Is Public', title: 'isPublic', value: optionsRef.current.isPublic },
{ label: 'Skip', title: 'skip', value: optionsRef.current.skip },
{ label: 'Timer Type', title: 'timerType', value: optionsRef.current.timerType },
{ label: 'End Action', title: 'endAction', value: optionsRef.current.endAction },
];
const userFields: TableEntry[] = [
{ label: 'User 0', title: 'user0', value: optionsRef.current.user0 },
{ label: 'User 1', title: 'user1', value: optionsRef.current.user1 },
{ label: 'User 2', title: 'user2', value: optionsRef.current.user2 },
{ label: 'User 3', title: 'user3', value: optionsRef.current.user3 },
{ label: 'User 4', title: 'user4', value: optionsRef.current.user4 },
{ label: 'User 5', title: 'user5', value: optionsRef.current.user5 },
{ label: 'User 6', title: 'user6', value: optionsRef.current.user6 },
{ label: 'User 7', title: 'user7', value: optionsRef.current.user7 },
{ label: 'User 8', title: 'user8', value: optionsRef.current.user8 },
{ label: 'User 9', title: 'user9', value: optionsRef.current.user9 },
];
return (
<div className={style.uploadOptions}>
<div className={style.twoEqualColumn}>
<ImportMapTable title='Import options' fields={worksheet} handleOnChange={updateRef} />
</div>
<div className={style.twoEqualColumn}>
<ImportMapTable title='Timings' fields={timings} handleOnChange={updateRef} />
<ImportMapTable title='Options' fields={options} handleOnChange={updateRef} />
</div>
<div className={style.twoEqualColumn}>
<ImportMapTable title='Titles' fields={titles} handleOnChange={updateRef} />
<ImportMapTable title='User Fields' fields={userFields} handleOnChange={updateRef} />
</div>
</div>
);
}
@@ -1,33 +0,0 @@
@use '../../../../theme/v2Styles' as *;
@use '../../../../theme/ontimeColours' as *;
.importTable {
margin: 0.5rem;
height: fit-content;
thead {
color: $gray-500;
text-transform: uppercase;
width: 10em;
}
tr:hover {
background-color: $gray-50;
}
tbody {
td {
max-width: fit-content;
}
}
}
.label {
display: inline-block;
min-width: 6em;
font-size: $inner-section-text-size;
}
.input {
width: 100%;
}
@@ -1,50 +0,0 @@
import { Input } from '@chakra-ui/react';
import { ExcelImportMap } from 'ontime-utils';
import style from './ImportMapTable.module.scss';
export type TableEntry = { label: string; title: keyof ExcelImportMap; value: string };
interface ImportMapTableProps {
title: string;
fields: TableEntry[];
handleOnChange: (field: keyof ExcelImportMap, value: string) => void;
}
export default function ImportMapTable(props: ImportMapTableProps) {
const { title, fields, handleOnChange } = props;
return (
<table className={style.importTable}>
<thead>
<tr>
<td colSpan={2}>{title}</td>
</tr>
</thead>
<tbody>
{fields.map((field) => {
return (
<tr key={field.title}>
<td className={style.label}>
<label htmlFor={field.title}>{field.title}</label>
</td>
<td className={style.input}>
<Input
id={field.title}
size='xs'
variant='ontime-filled-on-light'
maxLength={25}
defaultValue={field.value}
placeholder='Use default column name'
onBlur={(event) => {
handleOnChange(field.title, event.target.value);
}}
/>
</td>
</tr>
);
})}
</tbody>
</table>
);
}
@@ -1,34 +0,0 @@
import { MutableRefObject } from 'react';
import { Switch } from '@chakra-ui/react';
import { ProjectFileImportOptions } from '../../../../common/api/ontimeApi';
import ModalSplitInput from '../../ModalSplitInput';
import style from '../UploadModal.module.scss';
interface OntimeFileOptionsProps {
optionsRef: MutableRefObject<Partial<ProjectFileImportOptions>>;
}
export default function OntimeFileOptions(props: OntimeFileOptionsProps) {
const { optionsRef } = props;
const updateRef = <T extends keyof ProjectFileImportOptions>(field: T, value: ProjectFileImportOptions[T]) => {
optionsRef.current = { ...optionsRef.current, [field]: value };
};
return (
<div className={style.uploadOptions}>
<span className={style.title}>Import options</span>
<ModalSplitInput field='' title='Only import rundown' description='All other project options will be kept'>
<Switch
variant='ontime-on-light'
onChange={(e) => {
updateRef('onlyRundown', e.target.checked);
}}
defaultChecked={Boolean(optionsRef.current.onlyRundown)}
/>
</ModalSplitInput>
</div>
);
}
@@ -1,40 +0,0 @@
@use '../../../../theme/ontimeColours' as *;
@mixin row {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0 0.25rem;
}
.stepRow {
display: flex;
gap: 2rem;
align-items: center;
margin: 0 auto;
font-size: 1rem;
}
.idle {
@include row;
color: $blue-500;
}
.inactive {
@include row;
color: $gray-700;
}
.active {
@include row;
color: $blue-700;
}
.inactiveIcon {
color: $gray-700;
}
.activeIcon {
color: $blue-500;
}
@@ -1,26 +0,0 @@
import { IoCheckmarkCircle } from '@react-icons/all-files/io5/IoCheckmarkCircle';
import { IoChevronForward } from '@react-icons/all-files/io5/IoChevronForward';
import { IoEllipseOutline } from '@react-icons/all-files/io5/IoEllipseOutline';
import type { UploadStep } from '../UploadModal';
import style from './UploadStep.module.scss';
export default function UploadStepTracker({ uploadStep }: { uploadStep: UploadStep }) {
const isUpload = uploadStep === 'upload';
const isReview = uploadStep === 'review';
return (
<div className={style.stepRow}>
<div className={isUpload ? style.active : style.idle}>
<IoCheckmarkCircle />
Upload
</div>
<IoChevronForward className={isReview ? style.activeIcon : style.inactiveIcon} />
<div className={isReview ? style.active : style.inactive}>
{isReview ? <IoCheckmarkCircle /> : <IoEllipseOutline />}
Review
</div>
</div>
);
}
@@ -1,21 +0,0 @@
import { create } from 'zustand';
type UploadModalContext = {
file: File | null;
setFile: (file: File | null) => void;
progress: number;
setProgress: (progress: number) => void;
clear: () => void;
};
export const useUploadModalContextStore = create<UploadModalContext>((set) => ({
file: null,
setFile: (file: File | null) => set({ file }),
progress: 0,
setProgress: (progress: number) => set({ progress }),
clear: () => set({ file: null, progress: 0 }),
}));
@@ -1,28 +0,0 @@
export function validateFile(file: File) {
if (!file) {
throw new Error('No file to upload');
}
// Limit file size of a project file to around 1MB
if (file.name.endsWith('.json') && file.size > 1_000_000) {
throw new Error('File size limit (1MB) exceeded');
}
// Limit file size of an excel file to around 10MB
if (file.name.endsWith('.xlsx') && file.size > 10_000_000) {
throw new Error('File size limit (10MB) exceeded');
}
// Check file extension
if (!file.name.endsWith('.xlsx') && !file.name.endsWith('.json')) {
throw new Error('Unhandled file type');
}
}
export function isExcelFile(file: File | null) {
return file?.name.endsWith('.xlsx');
}
export function isOntimeFile(file: File | null) {
return file?.name.endsWith('.json');
}
@@ -0,0 +1,25 @@
type ValidationStatus = {
errors: string[];
isValid: boolean;
};
export function validateFile(file: File): ValidationStatus {
const status: ValidationStatus = { errors: [], isValid: true };
if (!file) {
status.errors.push('No file to upload');
status.isValid = false;
}
// Limit file size to 1MB
if (file.size > 1000000) {
status.errors.push('File size limit (1MB) exceeded');
status.isValid = false;
}
// Check file extension
if (!file.name.endsWith('.xlsx') && !file.name.endsWith('.json')) {
status.errors.push('Unhandled file type');
status.isValid = false;
}
return status;
}
@@ -9,7 +9,7 @@ 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 useProjectData from '../../common/hooks-query/useProjectData';
import useEventData from '../../common/hooks-query/useEventData';
import useRundown from '../../common/hooks-query/useRundown';
import useUserFields from '../../common/hooks-query/useUserFields';
import { isStringBoolean } from '../../common/utils/viewUtils';
@@ -27,7 +27,7 @@ type TitleFields = Pick<OntimeEvent, 'title' | 'subtitle' | 'presenter'>;
export default function Operator() {
const { data, status } = useRundown();
const { data: userFields, status: userFieldsStatus } = useUserFields();
const { data: projectData, status: projectDataStatus } = useProjectData();
const { data: projectData, status: projectDataStatus } = useEventData();
const featureData = useOperator();
const [searchParams] = useSearchParams();
@@ -3,7 +3,7 @@
.followButton {
position: relative;
bottom: 12rem;
bottom: 10rem;
margin: 0 auto;
z-index: 1;
@@ -1,13 +1,9 @@
import { memo } from 'react';
import style from './OperatorBlock.module.scss';
interface OperatorBlockProps {
title: string;
}
function OperatorBlock({ title }: OperatorBlockProps) {
export default function OperatorBlock({ title }: OperatorBlockProps) {
return <div className={style.block}>{title}</div>;
}
export default memo(OperatorBlock);
@@ -31,8 +31,8 @@
}
&.running {
border-top: 1px solid $gray-1300;
background-color: var(--operator-running-bg-override, $red-700);
border-top: 1px solid $white-10;
background-color: $red-700;
}
&.past {
@@ -97,7 +97,7 @@
.field {
font-weight: 600;
padding: 0 0.25rem;
background-color: var(--operator-highlight-override, $orange-600);
background-color: $orange-600;
margin-right: 0.5rem;
}
@@ -128,9 +128,6 @@ $skip-opacity: 0.1;
.eventTitle {
grid-area: title;
overflow: hidden;
max-height: calc(2.5em + 2px);
line-height: 1.25em;
}
.eventActions {
@@ -5,7 +5,6 @@ import { SupportedEvent } from 'ontime-types';
import { useEventAction } from '../../../common/hooks/useEventAction';
import { useEditorSettings } from '../../../common/stores/editorSettings';
import { useEmitLog } from '../../../common/stores/logger';
import { deviceAlt } from '../../../common/utils/deviceUtils';
import { tooltipDelayMid } from '../../../ontimeConfig';
import style from './QuickAddBlock.module.scss';
@@ -83,7 +82,7 @@ const QuickAddBlock = (props: QuickAddBlockProps) => {
className={style.quickBtn}
data-testid='quick-add-event'
>
Event {showKbd && <span className={style.keyboard}>{`${deviceAlt} + E`}</span>}
Event {showKbd && <span className={style.keyboard}>Alt + E</span>}
</Button>
</Tooltip>
<Tooltip label='Add Delay' openDelay={tooltipDelayMid}>
@@ -95,7 +94,7 @@ const QuickAddBlock = (props: QuickAddBlockProps) => {
className={style.quickBtn}
data-testid='quick-add-delay'
>
Delay {showKbd && <span className={style.keyboard}>{`${deviceAlt} + D`}</span>}
Delay {showKbd && <span className={style.keyboard}>Alt + D</span>}
</Button>
</Tooltip>
<Tooltip label='Add Block' openDelay={tooltipDelayMid}>
@@ -107,7 +106,7 @@ const QuickAddBlock = (props: QuickAddBlockProps) => {
className={style.quickBtn}
data-testid='quick-add-block'
>
Block {showKbd && <span className={style.keyboard}>{`${deviceAlt} + B`}</span>}
Block {showKbd && <span className={style.keyboard}>Alt + B</span>}
</Button>
</Tooltip>
</div>
@@ -1,13 +1,16 @@
/* eslint-disable react/display-name */
import { ComponentType, useMemo } from 'react';
import { TitleBlock } from 'ontime-types';
import { useStore } from 'zustand';
import useProjectData from '../../common/hooks-query/useProjectData';
import useEventData from '../../common/hooks-query/useEventData';
import useRundown from '../../common/hooks-query/useRundown';
import useViewSettings from '../../common/hooks-query/useViewSettings';
import { runtime } from '../../common/stores/runtime';
import { useViewOptionsStore } from '../../common/stores/viewOptions';
export type TitleManager = TitleBlock & { showNow: boolean; showNext: boolean };
const withData = <P extends object>(Component: ComponentType<P>) => {
return (props: Partial<P>) => {
// persisted app state
@@ -15,7 +18,7 @@ const withData = <P extends object>(Component: ComponentType<P>) => {
// HTTP API data
const { data: rundownData } = useRundown();
const { data: project } = useProjectData();
const { data: eventData } = useEventData();
const { data: viewSettings } = useViewSettings();
const publicEvents = useMemo(() => {
@@ -27,22 +30,44 @@ const withData = <P extends object>(Component: ComponentType<P>) => {
// websocket data
const data = useStore(runtime);
const {
timer,
publicMessage,
timerMessage,
lowerMessage,
playback,
onAir,
eventNext,
publicEventNext,
publicEventNow,
eventNow,
loaded,
} = data;
const publicSelectedId = loaded.selectedPublicEventId;
const selectedId = loaded.selectedEventId;
const nextId = loaded.nextEventId;
const { timer, titles, titlesPublic, publicMessage, timerMessage, lowerMessage, playback, onAir } = data;
const publicSelectedId = data.loaded.selectedPublicEventId;
const selectedId = data.loaded.selectedEventId;
const nextId = data.loaded.nextEventId;
/********************************************/
/*** + titleManager ***/
/*** WRAP INFORMATION RELATED TO TITLES ***/
/*** ---------------------------------- ***/
/********************************************/
// is there a now field?
let showNow = true;
if (!titles.titleNow && !titles.subtitleNow && !titles.presenterNow) showNow = false;
// is there a next field?
let showNext = true;
if (!titles.titleNext && !titles.subtitleNext && !titles.presenterNext) showNext = false;
const titleManager: TitleManager = { ...titles, showNow: showNow, showNext: showNext };
/********************************************/
/*** + publicTitleManager ***/
/*** WRAP INFORMATION RELATED TO TITLES ***/
/*** ---------------------------------- ***/
/********************************************/
// is there a now field?
let showPublicNow = true;
if (!titlesPublic.titleNow && !titlesPublic.subtitleNow && !titlesPublic.presenterNow) showPublicNow = false;
// is there a next field?
let showPublicNext = true;
if (!titlesPublic.titleNext && !titlesPublic.subtitleNext && !titlesPublic.presenterNext) showPublicNext = false;
const publicTitleManager: TitleManager = {
...titlesPublic,
showNow: showPublicNow,
showNext: showPublicNext,
};
/******************************************/
/*** + TimeManagerType ***/
@@ -50,6 +75,9 @@ const withData = <P extends object>(Component: ComponentType<P>) => {
/*** -------------------------------- ***/
/******************************************/
// inject info:
// is timer finished
// get clock string
const TimeManagerType = {
...timer,
playback,
@@ -67,10 +95,8 @@ const withData = <P extends object>(Component: ComponentType<P>) => {
pres={timerMessage}
publ={publicMessage}
lower={lowerMessage}
eventNow={eventNow}
publicEventNow={publicEventNow}
eventNext={eventNext}
publicEventNext={publicEventNext}
title={titleManager}
publicTitle={publicTitleManager}
time={TimeManagerType}
events={publicEvents}
backstageEvents={rundownData}
@@ -78,7 +104,7 @@ const withData = <P extends object>(Component: ComponentType<P>) => {
publicSelectedId={publicSelectedId}
viewSettings={viewSettings}
nextId={nextId}
general={project}
general={eventData}
onAir={onAir}
/>
);
@@ -25,7 +25,7 @@
/* =================== HEADER + EXTRAS ===================*/
.project-header {
.event-header {
grid-area: header;
font-size: clamp(32px, 4.5vw, 64px);
font-weight: 600;
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react';
import QRCode from 'react-qr-code';
import { AnimatePresence, motion } from 'framer-motion';
import { Message, OntimeEvent, ProjectData, SupportedEvent, ViewSettings } from 'ontime-types';
import { EventData, Message, OntimeEvent, SupportedEvent, ViewSettings } from 'ontime-types';
import { formatDisplay } from 'ontime-utils';
import { overrideStylesURL } from '../../../common/api/apiConstants';
@@ -18,6 +18,7 @@ import { TimeManagerType } from '../../../common/models/TimeManager.type';
import { formatTime } from '../../../common/utils/time';
import { useTranslation } from '../../../translation/TranslationProvider';
import { titleVariants } from '../common/animation';
import { TitleManager } from '../ViewWrapper';
import './Backstage.scss';
@@ -29,17 +30,16 @@ const formatOptions = {
interface BackstageProps {
isMirrored: boolean;
publ: Message;
eventNow: OntimeEvent | null;
eventNext: OntimeEvent | null;
title: TitleManager;
time: TimeManagerType;
backstageEvents: OntimeEvent[];
selectedId: string | null;
general: ProjectData;
general: EventData;
viewSettings: ViewSettings;
}
export default function Backstage(props: BackstageProps) {
const { isMirrored, publ, eventNow, eventNext, time, backstageEvents, selectedId, general, viewSettings } = props;
const { isMirrored, publ, title, time, backstageEvents, selectedId, general, viewSettings } = props;
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
const { getLocalizedString } = useTranslation();
const [blinkClass, setBlinkClass] = useState(false);
@@ -93,7 +93,7 @@ export default function Backstage(props: BackstageProps) {
<div className={`backstage ${isMirrored ? 'mirror' : ''}`} data-testid='backstage-view'>
<NavigationMenu />
<ViewParamsEditor paramFields={[TIME_FORMAT_OPTION]} />
<div className='project-header'>
<div className='event-header'>
{general.title}
<div className='clock-container'>
<div className='label'>{getLocalizedString('common.time_now')}</div>
@@ -110,7 +110,7 @@ export default function Backstage(props: BackstageProps) {
<div className='now-container'>
<AnimatePresence>
{eventNow && (
{title.showNow && (
<motion.div
className={`event now ${blinkClass ? 'blink' : ''}`}
key='now'
@@ -121,9 +121,9 @@ export default function Backstage(props: BackstageProps) {
>
<TitleCard
label='now'
title={eventNow.title}
subtitle={eventNow.subtitle}
presenter={eventNow.presenter}
title={title.titleNow}
subtitle={title.subtitleNow}
presenter={title.presenterNow}
/>
<div className='timer-group'>
<div className='aux-timers'>
@@ -144,7 +144,7 @@ export default function Backstage(props: BackstageProps) {
</AnimatePresence>
<AnimatePresence>
{eventNext && (
{title.showNext && (
<motion.div
className='event next'
key='next'
@@ -155,9 +155,9 @@ export default function Backstage(props: BackstageProps) {
>
<TitleCard
label='next'
title={eventNext.title}
subtitle={eventNext.subtitle}
presenter={eventNext.presenter}
title={title.titleNext}
subtitle={title.subtitleNext}
presenter={title.presenterNext}
/>
</motion.div>
)}
@@ -5,6 +5,7 @@ import { Message } from 'ontime-types';
import NavigationMenu from '../../../common/components/navigation-menu/NavigationMenu';
import { LOWER_THIRDS_OPTIONS } from '../../../common/components/view-params-editor/constants';
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
import { TitleManager } from '../ViewWrapper';
import { LowerOptions } from './LowerWrapper';
@@ -12,14 +13,12 @@ import './LowerLines.scss';
interface LowerLinesProps {
lower: Message;
heading: string;
subheading: string;
title: TitleManager;
options: LowerOptions;
doShow: boolean;
}
export default function LowerLines(props: LowerLinesProps) {
const { lower, heading, subheading, options, doShow } = props;
const { lower, title, options } = props;
const [showLower, setShowLower] = useState(true);
// Unmount if fadeOut
@@ -37,8 +36,8 @@ export default function LowerLines(props: LowerLinesProps) {
}, [options.fadeOut, options.transitionIn]);
useEffect(() => {
setShowLower(doShow);
}, [doShow]);
setShowLower(title.showNow);
}, [title.showNow]);
// Format messages
const showLowerMessage = lower.text !== '' && lower.visible;
@@ -147,14 +146,14 @@ export default function LowerLines(props: LowerLinesProps) {
>
<motion.div className='title-container' variants={titleContainerVariants}>
<motion.div className='title' variants={titleVariants}>
{heading}
{title.titleNow}
</motion.div>
<div className='title-decor' />
</motion.div>
<motion.div className='subtitle-container' variants={subtitleContainerVariants}>
<div className='sub-decor' />
<motion.div className='subtitle' variants={subtitleVariants}>
{subheading}
{title.presenterNow}
</motion.div>
</motion.div>
</motion.div>
@@ -1,10 +1,11 @@
import { memo, useEffect, useState } from 'react';
import isEqual from 'react-fast-compare';
import { useSearchParams } from 'react-router-dom';
import { Message, OntimeEvent, ViewSettings } from 'ontime-types';
import { Message, ViewSettings } from 'ontime-types';
import { overrideStylesURL } from '../../../common/api/apiConstants';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
import { TitleManager } from '../ViewWrapper';
import LowerLines from './LowerLines';
@@ -16,26 +17,33 @@ export type LowerOptions = {
keyColour?: string;
fadeOut: number;
};
interface LowerProps {
eventNow: OntimeEvent | null;
title: TitleManager;
lower: Message;
viewSettings: ViewSettings;
}
// prevent triggering animation without a content change
const areEqual = (prevProps: LowerProps, nextProps: LowerProps) => {
return isEqual(prevProps.eventNow?.title, nextProps.eventNow?.title) && isEqual(prevProps.lower, nextProps.lower);
return isEqual(prevProps.title, nextProps.title) && isEqual(prevProps.lower, nextProps.lower);
};
const Lower = (props: LowerProps) => {
const { eventNow, lower, viewSettings } = props;
const { title, lower, viewSettings } = props;
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
const [searchParams] = useSearchParams();
const [heading, setHeading] = useState('');
const [subheading, setSubheading] = useState('');
const [showLower, setShowLower] = useState(false);
const [titles, setTitles] = useState<TitleManager>({
titleNow: '',
titleNext: '',
subtitleNow: '',
subtitleNext: '',
presenterNow: '',
presenterNext: '',
noteNow: '',
noteNext: '',
showNow: false,
showNext: false,
});
// Set window title
useEffect(() => {
@@ -46,32 +54,28 @@ const Lower = (props: LowerProps) => {
useEffect(() => {
// clear titles if necessary
// will trigger an animation out in the component
let timeout: NodeJS.Timeout;
let timeout: NodeJS.Timeout | null = null;
if (
title?.titleNow !== titles?.titleNow ||
title?.subtitleNow !== titles?.subtitleNow ||
title?.presenterNow !== titles?.presenterNow
) {
setTitles((t) => ({ ...t, showNow: false }));
const haveTitlesChanged = eventNow?.title !== heading || eventNow?.presenter !== subheading;
const areTitlesEmpty = !eventNow?.title && !eventNow?.presenter;
const transitionTime = 2000;
// we have new titles
if (haveTitlesChanged && !areTitlesEmpty) {
// show lower
setHeading(eventNow?.title ?? '');
setSubheading(eventNow?.presenter ?? '');
setShowLower(true);
// schedule transition out
const transitionTime = 5000;
timeout = setTimeout(() => {
setShowLower(false);
setTitles(title);
}, transitionTime);
}
return () => {
if (timeout) {
if (timeout != null) {
clearTimeout(timeout);
}
};
// eslint-disable-next-line -- we do this to keep animations
}, [eventNow?.title, eventNow?.presenter]);
}, [title.titleNow, title.subtitleNow, title.presenterNow]);
// defer rendering until we load stylesheets
if (!shouldRender) {
@@ -131,7 +135,7 @@ const Lower = (props: LowerProps) => {
}
}
return <LowerLines lower={lower} heading={heading} subheading={subheading} options={options} doShow={showLower} />;
return <LowerLines lower={lower} title={titles} options={options} />;
};
export default memo(Lower, areEqual);
@@ -1,6 +1,6 @@
import { useEffect } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Playback, TimerMessage, TimerType, ViewSettings } from 'ontime-types';
import { EventData, Playback, TimerMessage, TimerType, ViewSettings } from 'ontime-types';
import { overrideStylesURL } from '../../../common/api/apiConstants';
import NavigationMenu from '../../../common/components/navigation-menu/NavigationMenu';
@@ -19,6 +19,7 @@ interface MinimalTimerProps {
pres: TimerMessage;
time: TimeManagerType;
viewSettings: ViewSettings;
general: EventData;
}
export default function MinimalTimer(props: MinimalTimerProps) {
@@ -24,7 +24,7 @@
/* =================== HEADER + EXTRAS ===================*/
.project-header {
.event-header {
grid-area: header;
font-size: clamp(32px, 4.5vw, 64px);
font-weight: 600;
@@ -1,7 +1,7 @@
import { useEffect } from 'react';
import QRCode from 'react-qr-code';
import { AnimatePresence, motion } from 'framer-motion';
import { Message, OntimeEvent, ProjectData, ViewSettings } from 'ontime-types';
import { EventData, Message, OntimeEvent, ViewSettings } from 'ontime-types';
import { overrideStylesURL } from '../../../common/api/apiConstants';
import NavigationMenu from '../../../common/components/navigation-menu/NavigationMenu';
@@ -16,6 +16,7 @@ import { TimeManagerType } from '../../../common/models/TimeManager.type';
import { formatTime } from '../../../common/utils/time';
import { useTranslation } from '../../../translation/TranslationProvider';
import { titleVariants } from '../common/animation';
import { TitleManager } from '../ViewWrapper';
import './Public.scss';
@@ -27,18 +28,16 @@ const formatOptions = {
interface BackstageProps {
isMirrored: boolean;
publ: Message;
publicEventNow: OntimeEvent | null;
publicEventNext: OntimeEvent | null;
publicTitle: TitleManager;
time: TimeManagerType;
events: OntimeEvent[];
publicSelectedId: string | null;
general: ProjectData;
general: EventData;
viewSettings: ViewSettings;
}
export default function Public(props: BackstageProps) {
const { isMirrored, publ, publicEventNow, publicEventNext, time, events, publicSelectedId, general, viewSettings } =
props;
const { isMirrored, publ, publicTitle, time, events, publicSelectedId, general, viewSettings } = props;
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
const { getLocalizedString } = useTranslation();
@@ -59,7 +58,7 @@ export default function Public(props: BackstageProps) {
<div className={`public-screen ${isMirrored ? 'mirror' : ''}`} data-testid='public-view'>
<NavigationMenu />
<ViewParamsEditor paramFields={[TIME_FORMAT_OPTION]} />
<div className='project-header'>
<div className='event-header'>
{general.title}
<div className='clock-container'>
<div className='label'>{getLocalizedString('common.time_now')}</div>
@@ -69,7 +68,7 @@ export default function Public(props: BackstageProps) {
<div className='now-container'>
<AnimatePresence>
{publicEventNow && (
{publicTitle.showNow && (
<motion.div
className='event now'
key='now'
@@ -80,16 +79,16 @@ export default function Public(props: BackstageProps) {
>
<TitleCard
label='now'
title={publicEventNow.title}
subtitle={publicEventNow.subtitle}
presenter={publicEventNow.presenter}
title={publicTitle.titleNow}
subtitle={publicTitle.subtitleNow}
presenter={publicTitle.presenterNow}
/>
</motion.div>
)}
</AnimatePresence>
<AnimatePresence>
{publicEventNext && (
{publicTitle.showNext && (
<motion.div
className='event next'
key='next'
@@ -100,9 +99,9 @@ export default function Public(props: BackstageProps) {
>
<TitleCard
label='next'
title={publicEventNext.title}
subtitle={publicEventNext.subtitle}
presenter={publicEventNext.presenter}
title={publicTitle.titleNext}
subtitle={publicTitle.subtitleNext}
presenter={publicTitle.presenterNext}
/>
</motion.div>
)}
@@ -13,6 +13,7 @@ import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet
import { TimeManagerType } from '../../../common/models/TimeManager.type';
import { secondsInMillis } from '../../../common/utils/dateConfig';
import { formatTime } from '../../../common/utils/time';
import { TitleManager } from '../ViewWrapper';
import { type ScheduleEvent, formatEventList, trimRundown } from './studioClock.utils';
@@ -25,7 +26,7 @@ const formatOptions = {
interface StudioClockProps {
isMirrored: boolean;
eventNext: OntimeEvent | null;
title: TitleManager;
time: TimeManagerType;
backstageEvents: OntimeRundown;
selectedId: string | null;
@@ -35,7 +36,7 @@ interface StudioClockProps {
}
export default function StudioClock(props: StudioClockProps) {
const { isMirrored, eventNext, time, backstageEvents, selectedId, nextId, onAir, viewSettings } = props;
const { isMirrored, title, time, backstageEvents, selectedId, nextId, onAir, viewSettings } = props;
// deferring rendering seems to affect styling (font and useFitText)
useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
@@ -86,7 +87,7 @@ export default function StudioClock(props: StudioClockProps) {
className='next-title'
style={{ fontSize: titleFontSize, height: '10vh', width: '100%', maxWidth: '75%' }}
>
{eventNext?.title ?? ''}
{title.titleNext}
</div>
<div className={isNegative ? 'next-countdown' : 'next-countdown next-countdown--overtime'}>
{selectedId !== null && formatDisplay(time.current)}
@@ -1,6 +1,6 @@
import { useEffect } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import { OntimeEvent, Playback, TimerMessage, TimerType, ViewSettings } from 'ontime-types';
import { EventData, Playback, TimerMessage, TimerType, ViewSettings } from 'ontime-types';
import { overrideStylesURL } from '../../../common/api/apiConstants';
import MultiPartProgressBar from '../../../common/components/multi-part-progress-bar/MultiPartProgressBar';
@@ -13,6 +13,7 @@ import { TimeManagerType } from '../../../common/models/TimeManager.type';
import { formatTime } from '../../../common/utils/time';
import { useTranslation } from '../../../translation/TranslationProvider';
import { formatTimerDisplay, getTimerByType } from '../common/viewerUtils';
import { TitleManager } from '../ViewWrapper';
import './Timer.scss';
@@ -39,15 +40,15 @@ const titleVariants = {
interface TimerProps {
isMirrored: boolean;
general: EventData;
pres: TimerMessage;
eventNow: OntimeEvent | null;
eventNext: OntimeEvent | null;
title: TitleManager;
time: TimeManagerType;
viewSettings: ViewSettings;
}
export default function Timer(props: TimerProps) {
const { isMirrored, pres, eventNow, eventNext, time, viewSettings } = props;
const { isMirrored, pres, title, time, viewSettings } = props;
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
const { getLocalizedString } = useTranslation();
@@ -138,7 +139,7 @@ export default function Timer(props: TimerProps) {
/>
<AnimatePresence>
{eventNow && !finished && (
{title.showNow && !finished && (
<motion.div
className='event now'
key='now'
@@ -147,13 +148,13 @@ export default function Timer(props: TimerProps) {
animate='visible'
exit='exit'
>
<TitleCard label='now' title={eventNow.title} subtitle={eventNow.subtitle} presenter={eventNow.presenter} />
<TitleCard label='now' title={title.titleNow} subtitle={title.subtitleNow} presenter={title.presenterNow} />
</motion.div>
)}
</AnimatePresence>
<AnimatePresence>
{eventNext && (
{title.showNext && (
<motion.div
className='event next'
key='next'
@@ -164,9 +165,9 @@ export default function Timer(props: TimerProps) {
>
<TitleCard
label='next'
title={eventNext.title}
subtitle={eventNext.subtitle}
presenter={eventNext.presenter}
title={title.titleNext}
subtitle={title.subtitleNext}
presenter={title.presenterNext}
/>
</motion.div>
)}
-8
View File
@@ -1,8 +0,0 @@
export const ontimeProgressGray = {
track: {
background: '#f6f6f6', // $gray-500
},
filledTrack: {
background: '#578AF4', // $blue-500
},
};
+5 -17
View File
@@ -2,8 +2,8 @@ export const ontimeModal = {
header: {
fontWeight: 400,
letterSpacing: '0.3px',
padding: '1rem 1.5rem',
fontSize: '1.25rem',
padding: '16px 24px',
fontSize: '20px',
color: '#202020', // $gray-50
},
dialog: {
@@ -20,29 +20,17 @@ export const ontimeModal = {
color: '#202020', // $gray-50
},
footer: {
padding: '0.5rem',
padding: '8px',
},
};
export const ontimeSmallModal = {
...ontimeModal,
body: {
padding: '1rem',
fontSize: 'calc(1rem - 2px)',
padding: '16px',
fontSize: '14px',
},
dialog: {
minHeight: 'min(200px, 10vh)',
},
};
export const ontimeUploadModal = {
...ontimeSmallModal,
body: {
padding: '1rem',
fontSize: 'calc(1rem - 2px)',
},
dialog: {
minHeight: 'min(200px, 10vh)',
maxWidth: 'min(800px, 80vh)',
},
};
+1 -8
View File
@@ -13,8 +13,7 @@ import {
import { ontimeCheckboxOnDark } from './ontimeCheckbox';
import { ontimeEditable } from './ontimeEditable';
import { ontimeMenuOnDark } from './ontimeMenu';
import { ontimeModal, ontimeSmallModal, ontimeUploadModal } from './ontimeModal';
import { ontimeProgressGray } from './OntimeProgress';
import { ontimeModal, ontimeSmallModal } from './ontimeModal';
import { ontimeBlockRadio } from './ontimeRadio';
import { ontimeSelect } from './ontimeSelect';
import { lightSwitch, ontimeSwitch } from './ontimeSwitch';
@@ -80,12 +79,6 @@ const theme = extendTheme({
variants: {
ontime: { ...ontimeModal },
'ontime-small': { ...ontimeSmallModal },
'ontime-upload': { ...ontimeUploadModal },
},
},
Progress: {
variants: {
'ontime-on-light': { ...ontimeProgressGray },
},
},
Radio: {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime",
"version": "2.9.0",
"version": "2.7.2",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
+2 -2
View File
@@ -2,7 +2,7 @@
"name": "ontime-server",
"type": "module",
"main": "src/index.ts",
"version": "2.9.0",
"version": "2.7.2",
"exports": "./src/index.js",
"dependencies": {
"body-parser": "^1.20.0",
@@ -15,7 +15,7 @@
"lowdb": "^5.0.5",
"multer": "^1.4.5-lts.1",
"node-osc": "^8.0.10",
"node-xlsx": "^0.23.0",
"node-xlsx": "^0.21.0",
"ontime-utils": "workspace:*",
"passport": "^0.6.0",
"passport-local": "~1.0.0",
+2 -2
View File
@@ -13,7 +13,7 @@ import { LogOrigin, OSCSettings } from 'ontime-types';
// Import Routes
import { router as rundownRouter } from './routes/rundownRouter.js';
import { router as projectRouter } from './routes/projectRouter.js';
import { router as eventDataRouter } from './routes/eventDataRouter.js';
import { router as ontimeRouter } from './routes/ontimeRouter.js';
import { router as playbackRouter } from './routes/playbackRouter.js';
@@ -55,7 +55,7 @@ app.use(express.json({ limit: '1mb' }));
// Implement route endpoints
app.use('/events', rundownRouter);
app.use('/project', projectRouter);
app.use('/eventdata', eventDataRouter);
app.use('/ontime', ontimeRouter);
app.use('/playback', playbackRouter);
@@ -2,16 +2,7 @@
* Class Event Provider is a mediator for handling the local db
* and adds logic specific to ontime data
*/
import {
ProjectData,
OntimeRundown,
ViewSettings,
DatabaseModel,
OSCSettings,
UserFields,
Alias,
Settings,
} from 'ontime-types';
import { EventData, OntimeRundown, ViewSettings } from 'ontime-types';
import { data, db } from '../../modules/loadDb.js';
import { safeMerge } from './DataProvider.utils.js';
@@ -21,14 +12,14 @@ export class DataProvider {
return data;
}
static async setProjectData(newData: Partial<ProjectData>) {
data.project = { ...data.project, ...newData };
static async setEventData(newData: Partial<EventData>) {
data.eventData = { ...data.eventData, ...newData };
await this.persist();
return data.project;
return data.eventData;
}
static getProjectData() {
return data.project;
static getEventData() {
return data.eventData;
}
static async setRundown(newData: OntimeRundown) {
@@ -54,7 +45,7 @@ export class DataProvider {
return data.settings;
}
static async setSettings(newData: Settings) {
static async setSettings(newData) {
data.settings = { ...newData };
await this.persist();
}
@@ -67,7 +58,7 @@ export class DataProvider {
return data.aliases;
}
static async setAliases(newData: Alias[]) {
static async setAliases(newData) {
data.aliases = newData;
await this.persist();
}
@@ -85,12 +76,12 @@ export class DataProvider {
await this.persist();
}
static async setUserFields(newData: UserFields) {
static async setUserFields(newData) {
data.userFields = { ...newData };
await this.persist();
}
static async setOsc(newData: OSCSettings) {
static async setOsc(newData) {
data.osc = { ...newData };
await this.persist();
}
@@ -104,9 +95,9 @@ export class DataProvider {
await db.write();
}
static async mergeIntoData(newData: Partial<DatabaseModel>) {
static async mergeIntoData(newData) {
const mergedData = safeMerge(data, newData);
data.project = mergedData.project;
data.eventData = mergedData.eventData;
data.settings = mergedData.settings;
data.viewSettings = mergedData.viewSettings;
data.osc = mergedData.osc;
@@ -1,16 +1,14 @@
import { DatabaseModel } from 'ontime-types';
/**
* Merges two data objects
* @param {object} existing
* @param {object} newData
*/
export function safeMerge(existing: DatabaseModel, newData: Partial<DatabaseModel>) {
const { rundown, project, settings, viewSettings, osc, aliases, userFields } = newData || {};
export function safeMerge(existing, newData) {
const { rundown, eventData, settings, viewSettings, osc, http, aliases, userFields } = newData || {};
return {
...existing,
rundown: rundown ?? existing.rundown,
project: { ...existing.project, ...project },
eventData: { ...existing.eventData, ...eventData },
settings: { ...existing.settings, ...settings },
viewSettings: { ...existing.viewSettings, ...viewSettings },
aliases: aliases ?? existing.aliases,
@@ -34,5 +32,6 @@ export function safeMerge(existing: DatabaseModel, newData: Partial<DatabaseMode
: {}),
},
},
http: { ...existing.http, ...http },
};
}
@@ -3,7 +3,7 @@ import { safeMerge } from '../DataProvider.utils.js';
describe('safeMerge', () => {
const existing = {
rundown: [],
project: {
eventData: {
title: 'existing title',
publicUrl: 'existing public URL',
backstageUrl: 'existing backstageUrl',
@@ -42,6 +42,11 @@ describe('safeMerge', () => {
onFinish: [],
},
},
http: {
enabled: true,
user: null,
pwd: null,
},
};
it('returns existing data if new data is not provided', () => {
@@ -57,15 +62,15 @@ describe('safeMerge', () => {
expect(mergedData.rundown).toEqual(newData.rundown);
});
it('merges the project key', () => {
it('merges the event key', () => {
const newData = {
project: {
eventData: {
title: 'new title',
publicInfo: 'new public info',
},
};
const mergedData = safeMerge(existing, newData);
expect(mergedData.project).toEqual({
expect(mergedData.eventData).toEqual({
title: 'new title',
publicUrl: 'existing public URL',
publicInfo: 'new public info',
@@ -183,6 +188,19 @@ describe('safeMerge', () => {
onFinish: [],
},
},
http: {
user: null,
pwd: null,
messages: {
onLoad: [],
onStart: [],
onUpdate: [],
onPause: [],
onStop: [],
onFinish: [],
},
enabled: true,
},
};
const newData = {
@@ -1,4 +1,4 @@
import { Loaded, OntimeEvent, SupportedEvent } from 'ontime-types';
import { Loaded, OntimeEvent, SupportedEvent, TitleBlock } from 'ontime-types';
import { DataProvider } from '../data-provider/DataProvider.js';
import { getRollTimers } from '../../services/rollUtils.js';
@@ -10,11 +10,10 @@ let instance;
* Manages business logic around loading events
*/
export class EventLoader {
loadedEvent: OntimeEvent | null;
loaded: Loaded;
eventNow: OntimeEvent | null;
publicEventNow: OntimeEvent | null;
eventNext: OntimeEvent | null;
publicEventNext: OntimeEvent | null;
titles: TitleBlock;
titlesPublic: TitleBlock;
constructor() {
if (instance) {
@@ -57,7 +56,7 @@ export class EventLoader {
}
/**
* returns an event given its index after filtering for OntimeEvents
* returns an event given its index
* @param {number} eventIndex
* @return {OntimeEvent | undefined}
*/
@@ -66,6 +65,16 @@ export class EventLoader {
return timedEvents?.[eventIndex];
}
/**
* returns an event given its index
* @param {number} eventIndex
* @return {object | undefined}
*/
static getPlayableAtIndex(eventIndex) {
const timedEvents = EventLoader.getPlayableEvents();
return timedEvents?.[eventIndex];
}
/**
* returns an event given its id
* @param {string} eventId
@@ -158,18 +167,16 @@ export class EventLoader {
timeNow,
);
// load events
this.eventNow = currentEvent;
this.publicEventNow = currentPublicEvent;
this.eventNext = nextEvent;
this.publicEventNext = nextPublicEvent;
// loaded data summary
this.loadedEvent = currentEvent;
this.loaded.selectedEventIndex = nowIndex;
this.loaded.selectedEventId = currentEvent?.id || null;
this.loaded.numEvents = timedEvents.length;
this.loaded.nextEventId = nextEvent.id;
this.loaded.nextPublicEventId = nextPublicEvent.id;
// titles
this._loadThisTitles(currentEvent, 'now-private');
this._loadThisTitles(currentPublicEvent, 'now-public');
this._loadThisTitles(nextEvent, 'next-private');
this._loadThisTitles(nextPublicEvent, 'next-public');
return { currentEvent, nextEvent, timeToNext };
}
@@ -180,11 +187,10 @@ export class EventLoader {
*/
getLoaded() {
return {
loadedEvent: this.loadedEvent,
loaded: this.loaded,
eventNow: this.eventNow,
publicEventNow: this.publicEventNow,
eventNext: this.eventNext,
publicEventNext: this.publicEventNext,
titles: this.titles,
titlesPublic: this.titlesPublic,
};
}
@@ -200,10 +206,7 @@ export class EventLoader {
* Resets instance state
*/
reset(emit = true) {
this.eventNow = null;
this.publicEventNow = null;
this.eventNext = null;
this.publicEventNext = null;
this.loadedEvent = null;
this.loaded = {
selectedEventIndex: null,
selectedEventId: null,
@@ -212,6 +215,26 @@ export class EventLoader {
nextPublicEventId: null,
numEvents: EventLoader.getPlayableEvents().length,
};
this.titles = {
titleNow: null,
subtitleNow: null,
presenterNow: null,
noteNow: null,
titleNext: null,
subtitleNext: null,
presenterNext: null,
noteNext: null,
};
this.titlesPublic = {
titleNow: null,
subtitleNow: null,
presenterNow: null,
noteNow: null,
titleNext: null,
subtitleNext: null,
presenterNext: null,
noteNext: null,
};
// workaround for socket not being ready in constructor
if (emit) {
@@ -232,12 +255,13 @@ export class EventLoader {
const playableEvents = EventLoader.getPlayableEvents();
// we know some stuff now
this.loadedEvent = event;
this.loaded.selectedEventIndex = eventIndex;
this.loaded.selectedEventId = event.id;
this.loaded.numEvents = timedEvents.length;
this.eventNow = event;
this._loadEventNow(event, playableEvents);
this._loadEventNext(playableEvents);
// this.nextEventId = playableEvents[eventIndex + 1].id;
this._loadTitlesNow(event, playableEvents);
this._loadTitlesNext(playableEvents);
this._loadEvent();
@@ -250,28 +274,29 @@ export class EventLoader {
private _loadEvent() {
eventStore.batchSet({
loaded: this.loaded,
eventNow: this.eventNow,
publicEventNow: this.publicEventNow,
eventNext: this.eventNext,
publicEventNext: this.publicEventNext,
titles: this.titles,
titlesPublic: this.titlesPublic,
});
}
/**
* @description loads currently running events
* @description loads given title (now)
* @private
* @param {object} event
* @param {array} rundown
*/
private _loadEventNow(event, rundown) {
this.eventNow = event;
private _loadTitlesNow(event, rundown) {
// private title is always current
// check if current is also public
if (event.isPublic) {
this.publicEventNow = event;
this._loadThisTitles(event, 'now');
} else {
this._loadThisTitles(event, 'now-private');
// assume there is no public event
this.publicEventNow = null;
this.titlesPublic.titleNow = null;
this.titlesPublic.subtitleNow = null;
this.titlesPublic.presenterNow = null;
this.loaded.selectedPublicEventId = null;
// if there is nothing before, return
@@ -280,8 +305,7 @@ export class EventLoader {
// iterate backwards to find it
for (let i = this.loaded.selectedEventIndex; i >= 0; i--) {
if (rundown[i].isPublic) {
this.publicEventNow = rundown[i];
this.loaded.selectedPublicEventId = rundown[i].id;
this._loadThisTitles(rundown[i], 'now-public');
break;
}
}
@@ -289,44 +313,180 @@ export class EventLoader {
}
/**
* @description look for next events
* @description look for next titles to load
* @private
*/
private _loadEventNext(rundown) {
// assume there are no next events
this.eventNext = null;
this.publicEventNext = null;
this.loaded.nextEventId = null;
this.loaded.nextPublicEventId = null;
private _loadTitlesNext(rundown) {
// maybe there is nothing to load
if (this.loaded.selectedEventIndex === null) return;
// assume there is no next event
this.titles.titleNext = null;
this.titles.subtitleNext = null;
this.titles.presenterNext = null;
this.titles.noteNext = null;
this.loaded.nextEventId = null;
this.titlesPublic.titleNext = null;
this.titlesPublic.subtitleNext = null;
this.titlesPublic.presenterNext = null;
this.loaded.nextPublicEventId = null;
const numEvents = rundown.length;
if (this.loaded.selectedEventIndex < numEvents - 1) {
let nextPublic = false;
let nextProduction = false;
let nextPrivate = false;
for (let i = this.loaded.selectedEventIndex + 1; i < numEvents; i++) {
// if we have not set private
if (!nextProduction) {
this.eventNext = rundown[i];
this.loaded.nextEventId = rundown[i].id;
nextProduction = true;
if (!nextPrivate) {
this._loadThisTitles(rundown[i], 'next-private');
nextPrivate = true;
}
// if event is public
if (rundown[i].isPublic) {
this.publicEventNext = rundown[i];
this.loaded.nextPublicEventId = rundown[i].id;
this._loadThisTitles(rundown[i], 'next-public');
nextPublic = true;
}
// Stop if both are set
if (nextPublic && nextProduction) break;
if (nextPublic && nextPrivate) break;
}
}
}
/**
* @description loads given title
* @param event
* @param type
* @private
*/
private _loadThisTitles(event, type) {
if (type === 'now') {
if (event === null) {
// public
this.titlesPublic.titleNow = null;
this.titlesPublic.subtitleNow = null;
this.titlesPublic.presenterNow = null;
this.titlesPublic.noteNow = null;
this.loaded.selectedPublicEventId = null;
// private
this.titles.titleNow = null;
this.titles.subtitleNow = null;
this.titles.presenterNow = null;
this.titles.noteNow = null;
this.loaded.selectedEventId = null;
} else {
// public
this.titlesPublic.titleNow = event.title;
this.titlesPublic.subtitleNow = event.subtitle;
this.titlesPublic.presenterNow = event.presenter;
this.titlesPublic.noteNow = event.note;
this.loaded.selectedPublicEventId = event.id;
// private
this.titles.titleNow = event.title;
this.titles.subtitleNow = event.subtitle;
this.titles.presenterNow = event.presenter;
this.titles.noteNow = event.note;
this.loaded.selectedEventId = event.id;
}
} else if (type === 'now-public') {
if (event === null) {
this.titlesPublic.titleNow = null;
this.titlesPublic.subtitleNow = null;
this.titlesPublic.presenterNow = null;
this.titlesPublic.noteNow = null;
this.loaded.selectedPublicEventId = null;
} else {
this.titlesPublic.titleNow = event.title;
this.titlesPublic.subtitleNow = event.subtitle;
this.titlesPublic.presenterNow = event.presenter;
this.titlesPublic.noteNow = event.note;
this.loaded.selectedPublicEventId = event.id;
}
} else if (type === 'now-private') {
if (event === null) {
this.titles.titleNow = null;
this.titles.subtitleNow = null;
this.titles.presenterNow = null;
this.titles.noteNow = null;
this.loaded.selectedEventId = null;
} else {
this.titles.titleNow = event.title;
this.titles.subtitleNow = event.subtitle;
this.titles.presenterNow = event.presenter;
this.titles.noteNow = event.note;
this.loaded.selectedEventId = event.id;
}
}
// next, load to both public and private
else if (type === 'next') {
if (event === null) {
// public
this.titlesPublic.titleNext = null;
this.titlesPublic.subtitleNext = null;
this.titlesPublic.presenterNext = null;
this.titlesPublic.noteNext = null;
this.loaded.nextPublicEventId = null;
// private
this.titles.titleNext = null;
this.titles.subtitleNext = null;
this.titles.presenterNext = null;
this.titles.noteNext = null;
this.loaded.nextEventId = null;
} else {
// public
this.titlesPublic.titleNext = event.title;
this.titlesPublic.subtitleNext = event.subtitle;
this.titlesPublic.presenterNext = event.presenter;
this.titlesPublic.noteNext = event.note;
this.loaded.nextPublicEventId = event.id;
// private
this.titles.titleNext = event.title;
this.titles.subtitleNext = event.subtitle;
this.titles.presenterNext = event.presenter;
this.titles.noteNext = event.note;
this.loaded.nextEventId = event.id;
}
} else if (type === 'next-public') {
if (event === null) {
this.titlesPublic.titleNext = null;
this.titlesPublic.subtitleNext = null;
this.titlesPublic.presenterNext = null;
this.titlesPublic.noteNext = null;
this.loaded.nextPublicEventId = null;
} else {
this.titlesPublic.titleNext = event.title;
this.titlesPublic.subtitleNext = event.subtitle;
this.titlesPublic.presenterNext = event.presenter;
this.titlesPublic.noteNext = event.note;
this.loaded.nextPublicEventId = event.id;
}
} else if (type === 'next-private') {
if (event === null) {
this.titles.titleNext = null;
this.titles.subtitleNext = null;
this.titles.presenterNext = null;
this.titles.noteNext = null;
this.loaded.nextEventId = null;
} else {
this.titles.titleNext = event.title;
this.titles.subtitleNext = event.subtitle;
this.titles.presenterNext = event.presenter;
this.titles.noteNext = event.note;
this.loaded.nextEventId = event.id;
}
} else {
throw new Error(`Unhandled title type: ${type}`);
}
}
}
export const eventLoader = new EventLoader();
@@ -1,24 +1,24 @@
import { RequestHandler } from 'express';
import { ProjectData } from 'ontime-types';
import { EventData } from 'ontime-types';
import { removeUndefined } from '../utils/parserUtils.js';
import { failEmptyObjects } from '../utils/routerUtils.js';
import { DataProvider } from '../classes/data-provider/DataProvider.js';
// Create controller for GET request to 'project'
export const getProject: RequestHandler = async (req, res) => {
res.json(DataProvider.getProjectData());
// Create controller for GET request to 'event'
export const getEventData: RequestHandler = async (req, res) => {
res.json(DataProvider.getEventData());
};
// Create controller for POST request to 'project'
export const postProject: RequestHandler = async (req, res) => {
// Create controller for POST request to 'event'
export const postEventData: RequestHandler = async (req, res) => {
if (failEmptyObjects(req.body, res)) {
return;
}
try {
const newEvent: Partial<ProjectData> = removeUndefined({
const newEvent: Partial<EventData> = removeUndefined({
title: req.body?.title,
description: req.body?.description,
publicUrl: req.body?.publicUrl,
@@ -27,7 +27,7 @@ export const postProject: RequestHandler = async (req, res) => {
backstageInfo: req.body?.backstageInfo,
endMessage: req.body?.endMessage,
});
const newData = await DataProvider.setProjectData(newEvent);
const newData = await DataProvider.setEventData(newEvent);
res.status(200).send(newData);
} catch (error) {
res.status(400).send(error);
@@ -1,6 +1,6 @@
import { body, validationResult } from 'express-validator';
export const projectSanitiser = [
export const eventDataSanitizer = [
body('title').optional().isString().trim(),
body('description').optional().isString().trim(),
body('publicUrl').optional().isString().trim(),
+42 -102
View File
@@ -1,4 +1,4 @@
import { Alias, DatabaseModel, LogOrigin, ProjectData } from 'ontime-types';
import { Alias, EventData, LogOrigin } from 'ontime-types';
import { RequestHandler } from 'express';
import fs from 'fs';
@@ -7,15 +7,13 @@ import { networkInterfaces } from 'os';
import { fileHandler } from '../utils/parser.js';
import { DataProvider } from '../classes/data-provider/DataProvider.js';
import { failEmptyObjects, failIsNotArray } from '../utils/routerUtils.js';
import { mergeObject } from '../utils/parserUtils.js';
import { PlaybackService } from '../services/PlaybackService.js';
import { eventStore } from '../stores/EventStore.js';
import { isDocker, resolveDbPath } from '../setup.js';
import { oscIntegration } from '../services/integration-service/OscIntegration.js';
import { logger } from '../classes/Logger.js';
import { deleteAllEvents, notifyChanges } from '../services/rundown-service/RundownService.js';
import { deepmerge } from 'ontime-utils';
import { runtimeCacheStore } from '../stores/cachingStore.js';
import { delayedRundownCacheKey } from '../services/rundown-service/delayedRundown.utils.js';
import { deleteAllEvents, forceReset } from '../services/rundown-service/RundownService.js';
// Create controller for GET request to '/ontime/poll'
// Returns data for current state
@@ -33,7 +31,7 @@ export const poll = async (req, res) => {
// Create controller for GET request to '/ontime/db'
// Returns -
export const dbDownload = async (req, res) => {
const { title } = DataProvider.getProjectData();
const { title } = DataProvider.getEventData();
const fileTitle = title || 'ontime data';
res.download(resolveDbPath, `${fileTitle}.json`, (err) => {
@@ -45,43 +43,44 @@ export const dbDownload = async (req, res) => {
});
};
// TODO: docs
// TODO: cleanup usage
/**
* Parses a file and returns the result objects
* @param file
* @param _req
* @param _res
* @param options
*/
async function parseFile(file, _req, _res, options) {
if (!fs.existsSync(file)) {
throw new Error('Upload failed');
}
const result = await fileHandler(file, options);
return result.data;
}
/**
* parse an uploaded file and apply its parsed objects
* handles file upload
* @param file
* @param req
* @param res
* @param [options]
* @returns {Promise<void>}
*/
const parseAndApply = async (file, _req, res, options) => {
const result = await parseFile(file, _req, res, options);
PlaybackService.stop();
const newRundown = result.rundown || [];
if (options?.onlyRundown === 'true') {
await DataProvider.setRundown(newRundown);
} else {
await DataProvider.mergeIntoData(result);
const uploadAndParse = async (file, req, res, options) => {
if (!fs.existsSync(file)) {
res.status(500).send({ message: 'Upload failed' });
return;
}
try {
const result = await fileHandler(file);
if ('error' in result && result.error) {
res.status(400).send({ message: result.message });
} else if ('data' in result && result.message === 'success') {
PlaybackService.stop();
// explicitly write objects
if (typeof result !== 'undefined') {
const newRundown = result.data.rundown || [];
if (options?.onlyRundown === 'true') {
await DataProvider.setRundown(newRundown);
} else {
await DataProvider.mergeIntoData(result.data);
}
}
forceReset();
res.sendStatus(200);
} else {
res.status(400).send({ message: 'Failed parsing, no data' });
}
} catch (error) {
res.status(400).send({ message: `Failed parsing ${error}` });
}
notifyChanges({ timer: true, external: true, reset: true });
};
/**
@@ -170,7 +169,7 @@ export const postUserFields = async (req, res) => {
}
try {
const persistedData = DataProvider.getUserFields();
const newData = deepmerge(persistedData, req.body);
const newData = mergeObject(persistedData, req.body);
await DataProvider.setUserFields(newData);
res.status(200).send(newData);
} catch (error) {
@@ -210,7 +209,7 @@ export const postSettings = async (req, res) => {
const operatorKey = extractPin(req.body?.operatorKey, settings.operatorKey);
if (isDocker && req.body?.serverPort) {
return res.status(403).json({ message: 'Can`t change port when running inside docker' });
return res.status(403).json({ message: `Can't change port when running inside docker` });
}
const serverPort = parseInt(req.body?.serverPort ?? settings.serverPort, 10);
@@ -320,38 +319,8 @@ export const postOSC = async (req, res) => {
}
};
export async function patchPartialProjectFile(req, res) {
if (failEmptyObjects(req.body, res)) {
return;
}
try {
const patchDb: Partial<DatabaseModel> = {
project: req.body?.project,
settings: req.body?.settings,
viewSettings: req.body?.viewSettings,
osc: req.body?.osc,
aliases: req.body?.aliases,
userFields: req.body?.userFields,
rundown: req.body?.rundown,
};
await DataProvider.mergeIntoData(patchDb);
if (patchDb.rundown !== undefined) {
// it is likely cheaper to invalidate cache than to calculate diff
PlaybackService.stop();
runtimeCacheStore.invalidate(delayedRundownCacheKey);
notifyChanges({ external: true, reset: true });
}
res.status(200).send();
} catch (error) {
res.status(400).send(error);
}
}
/**
* uploads and parses a given file
*/
// Create controller for POST request to '/ontime/db'
// Returns -
export const dbUpload = async (req, res) => {
if (!req.file) {
res.status(400).send({ message: 'File not found' });
@@ -359,42 +328,13 @@ export const dbUpload = async (req, res) => {
}
const options = req.query;
const file = req.file.path;
try {
await parseAndApply(file, req, res, options);
res.status(200).send();
} catch (error) {
res.status(400).send({ message: `Failed parsing ${error}` });
}
await uploadAndParse(file, req, res, options);
};
/**
* uploads and parses an excel file
* @returns parsed result
*/
export async function previewExcel(req, res) {
if (!req.file) {
res.status(400).send({ message: 'File not found' });
return;
}
try {
const options = JSON.parse(req.body.options);
const file = req.file.path;
const data = await parseFile(file, req, res, options);
res.status(200).send(data);
} catch (error) {
res.status(500).send({ message: error.toString() });
}
}
/**
* Meant to create a new project file, it will clear only fields which are specific to a project
* @param req
* @param res
*/
// Create controller for POST request to '/ontime/new'
export const postNew: RequestHandler = async (req, res) => {
try {
const newProjectData: ProjectData = {
const newEventData: EventData = {
title: req.body?.title ?? '',
description: req.body?.description ?? '',
publicUrl: req.body?.publicUrl ?? '',
@@ -402,7 +342,7 @@ export const postNew: RequestHandler = async (req, res) => {
backstageUrl: req.body?.backstageUrl ?? '',
backstageInfo: req.body?.backstageInfo ?? '',
};
const newData = await DataProvider.setProjectData(newProjectData);
const newData = await DataProvider.setEventData(newEventData);
await deleteAllEvents();
res.status(201).send(newData);
} catch (error) {
@@ -118,18 +118,3 @@ export const validateOscSubscription = [
next();
},
];
export const validatePatchProjectFile = [
body('rundown').isArray().optional({ nullable: false }),
body('project').isObject().optional({ nullable: false }),
body('settings').isObject().optional({ nullable: false }),
body('viewSettings').isObject().optional({ nullable: false }),
body('aliases').isArray().optional({ nullable: false }),
body('userFields').isObject().optional({ nullable: false }),
body('osc').isObject().optional({ nullable: false }),
(req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
+13 -18
View File
@@ -1,23 +1,18 @@
:root {
--background-color-override: #ececec;
--color-override: #101010;
--secondary-color-override: #404040;
--accent-color-override: #fa5656;
--label-color-override: #6c6c6c;
--timer-color-override: #202020;
--card-background-color-override: #fff;
--card-background-color-blink-override: #339e4e;
--font-family-override: "Open Sans";
--font-family-bold-override: "Arial Black";
--timer-progress-bg-override: #fff;
--timer-progress-override: #202020;
--cuesheet-running-bg-override: #D20300;
--operator-running-bg-override: #D20300;
--operator-highlight-override: #FFAB33;
--background-color-override: #ececec;
--color-override: #101010;
--secondary-color-override: #404040;
--accent-color-override: #fa5656;
--label-color-override: #6c6c6c;
--timer-color-override: #202020;
--card-background-color-override: #fff;
--card-background-color-blink-override: #339e4e;
--font-family-override: "Open Sans";
--font-family-bold-override: "Arial Black";
--timer-progress-bg-override: #fff;
--timer-progress-override: #202020;
}
.timer {
color: black !important;
color: black !important;
}
+1 -1
View File
@@ -2,7 +2,7 @@ import { DatabaseModel } from 'ontime-types';
export const dbModel: DatabaseModel = {
rundown: [],
project: {
eventData: {
title: '',
description: '',
publicUrl: '',
+1 -1
View File
@@ -42,7 +42,7 @@ const parseDb = async (fileToRead, adapterToUse) => {
adapterToUse.data = dbModel;
}
return parseJson(adapterToUse.data);
return parseJson(adapterToUse.data, true);
};
/**
+11
View File
@@ -0,0 +1,11 @@
import express from 'express';
import { getEventData, postEventData } from '../controllers/eventDataController.js';
import { eventDataSanitizer } from '../controllers/eventDataController.validate.js';
export const router = express.Router();
// create route between controller and 'GET /event' endpoint
router.get('/', getEventData);
// create route between controller and 'POST /event' endpoint
router.post('/', eventDataSanitizer, postEventData);
+2 -11
View File
@@ -9,7 +9,6 @@ import {
getSettings,
getUserFields,
getViewSettings,
patchPartialProjectFile,
poll,
postAliases,
postNew,
@@ -18,19 +17,17 @@ import {
postSettings,
postUserFields,
postViewSettings,
previewExcel,
} from '../controllers/ontimeController.js';
import {
validateAliases,
validateOSC,
validateOscSubscription,
validatePatchProjectFile,
validateSettings,
validateUserFields,
viewValidator,
} from '../controllers/ontimeController.validate.js';
import { projectSanitiser } from '../controllers/projectController.validate.js';
import { eventDataSanitizer } from '../controllers/eventDataController.validate.js';
export const router = express.Router();
@@ -43,12 +40,6 @@ router.get('/db', dbDownload);
// create route between controller and '/ontime/db' endpoint
router.post('/db', uploadFile, dbUpload);
// create route between controller and '/ontime/excel' endpoint
router.patch('/db', validatePatchProjectFile, patchPartialProjectFile);
// create route between controller and '/ontime/preview-spreadsheet' endpoint
router.post('/preview-spreadsheet', uploadFile, previewExcel);
// create route between controller and '/ontime/settings' endpoint
router.get('/settings', getSettings);
@@ -86,4 +77,4 @@ router.post('/osc', validateOSC, postOSC);
router.post('/osc-subscriptions', validateOscSubscription, postOscSubscriptions);
// create route between controller and '/ontime/new' endpoint
router.post('/new', projectSanitiser, postNew);
router.post('/new', eventDataSanitizer, postNew);
-11
View File
@@ -1,11 +0,0 @@
import express from 'express';
import { getProject, postProject } from '../controllers/projectController.js';
import { projectSanitiser } from '../controllers/projectController.validate.js';
export const router = express.Router();
// create route between controller and 'GET /project' endpoint
router.get('/', getProject);
// create route between controller and 'POST /project' endpoint
router.post('/', projectSanitiser, postProject);
@@ -35,6 +35,7 @@ import { clock } from '../Clock.js';
*/
export function forceReset() {
eventLoader.reset();
sendRefetch();
runtimeCacheStore.invalidate(delayedRundownCacheKey);
}
@@ -115,8 +116,8 @@ export function updateTimer(affectedIds?: string[]) {
if (safeOption) {
eventLoader.reset();
const { eventNow } = eventLoader.loadById(runningEventId) || {};
eventTimer.hotReload(eventNow);
const { loadedEvent } = eventLoader.loadById(runningEventId) || {};
eventTimer.hotReload(loadedEvent);
return true;
}
@@ -132,9 +133,9 @@ export function updateTimer(affectedIds?: string[]) {
eventTimer.roll(currentEvent, nextEvent);
}
} else {
const { eventNow } = eventLoader.loadById(runningEventId) || {};
if (eventNow) {
eventTimer.hotReload(eventNow);
const { loadedEvent } = eventLoader.loadById(runningEventId) || {};
if (loadedEvent) {
eventTimer.hotReload(loadedEvent);
} else {
eventTimer.stop();
}
@@ -143,8 +144,8 @@ export function updateTimer(affectedIds?: string[]) {
}
if (isNext) {
const { eventNow } = eventLoader.loadById(runningEventId) || {};
eventTimer.hotReload(eventNow);
const { loadedEvent } = eventLoader.loadById(runningEventId) || {};
eventTimer.hotReload(loadedEvent);
return true;
}
return false;
@@ -191,22 +192,30 @@ export async function addEvent(eventData: Partial<OntimeEvent> | Partial<OntimeD
// modify rundown
await cachedAdd(insertIndex, newEvent as OntimeEvent | OntimeDelay | OntimeBlock);
notifyChanges({ timer: [id], external: true });
// notify timer service of changed events
updateTimer([id]);
// notify event loader that rundown size has changed
updateChangeNumEvents();
// advice socket subscribers of change
sendRefetch();
return newEvent;
}
export async function editEvent(eventData: Partial<OntimeEvent> | Partial<OntimeBlock> | Partial<OntimeDelay>) {
if (eventData.type === SupportedEvent.Event && eventData?.cue === '') {
throw new Error('Cue value invalid');
throw new Error(`Cue value invalid`);
}
const newEvent = await cachedEdit(eventData.id, eventData);
notifyChanges({ timer: [newEvent.id], external: true });
// notify timer service of changed events
updateTimer([newEvent.id]);
// advice socket subscribers of change
sendRefetch();
return newEvent;
}
@@ -219,9 +228,14 @@ export async function editEvent(eventData: Partial<OntimeEvent> | Partial<Ontime
export async function deleteEvent(eventId) {
await cachedDelete(eventId);
notifyChanges({ timer: [eventId], external: true });
// notify timer service of changed events
updateTimer([eventId]);
// notify event loader that rundown size has changed
updateChangeNumEvents();
// advice socket subscribers of change
sendRefetch();
}
/**
@@ -231,7 +245,9 @@ export async function deleteEvent(eventId) {
export async function deleteAllEvents() {
await cachedClear();
notifyChanges({ timer: true, external: true, reset: true });
// notify timer service of changed events
updateTimer();
forceReset();
}
/**
@@ -244,15 +260,22 @@ export async function deleteAllEvents() {
export async function reorderEvent(eventId: string, from: number, to: number) {
const reorderedItem = await cachedReorder(eventId, from, to);
notifyChanges({ timer: true, external: true });
// notify timer service of changed events
updateTimer();
// advice socket subscribers of change
sendRefetch();
return reorderedItem;
}
export async function applyDelay(eventId: string) {
await cachedApplyDelay(eventId);
notifyChanges({ timer: true, external: true });
// notify timer service of changed events
updateTimer();
// advice socket subscribers of change
sendRefetch();
}
/**
@@ -264,7 +287,11 @@ export async function applyDelay(eventId: string) {
export async function swapEvents(from: string, to: string) {
await cachedSwap(from, to);
notifyChanges({ timer: true, external: true });
// notify timer service of changed events
updateTimer();
// advice socket subscribers of change
sendRefetch();
}
/**
@@ -274,26 +301,3 @@ export async function swapEvents(from: string, to: string) {
function updateChangeNumEvents() {
eventLoader.updateNumEvents();
}
/**
* Notify services of changes in the rundown
*/
export function notifyChanges(options: { timer?: boolean | string[]; external?: boolean; reset?: boolean }) {
if (options.timer) {
// notify timer service of changed events
if (Array.isArray(options.timer)) {
updateTimer(options.timer);
}
updateTimer();
}
if (options.reset) {
// force rundown to be recalculated
forceReset();
}
if (options.external) {
// advice socket subscribers of change
sendRefetch();
}
}
+4 -8
View File
@@ -56,10 +56,8 @@ export const eventStore = {
* - Message Service lowerMessage
* - Message Service onAir
* - Event Loader loaded
* - Event Loader eventNow
* - Event Loader publicEventNow
* - Event Loader eventNext
* - Event Loader publicEventNext
* - Event Loader titles
* - Event Loader titlesPublic
*/
export const getInitialPayload = () => ({
@@ -70,8 +68,6 @@ export const getInitialPayload = () => ({
lowerMessage: messageService.lowerMessage,
onAir: messageService.onAir,
loaded: eventLoader.loaded,
eventNow: eventLoader.eventNow,
publicEventNow: eventLoader.publicEventNow,
eventNext: eventLoader.eventNext,
publicEventNext: eventLoader.publicEventNext,
titles: eventLoader.titles,
titlesPublic: eventLoader.titlesPublic,
});
+40 -78
View File
@@ -192,7 +192,7 @@ describe('test json parser with valid def', () => {
user9: '',
},
],
project: {
eventData: {
title: 'This is a test definition',
url: 'www.carlosvalente.com',
publicInfo: 'WiFi: demoproject \nPassword: ontimeproject',
@@ -246,7 +246,7 @@ describe('test json parser with valid def', () => {
});
it('loaded event settings', () => {
const eventTitle = parseResponse?.project?.title;
const eventTitle = parseResponse?.eventData?.title;
expect(eventTitle).toBe('This is a test definition');
});
@@ -269,24 +269,6 @@ describe('test json parser with valid def', () => {
});
describe('test parser edge cases', () => {
it('stringifies necessary values', async () => {
const testData = {
rundown: [
{
cue: 101,
type: 'event',
},
{
cue: 101.1,
type: 'event',
},
],
};
const parseResponse = await parseJson(testData);
expect(typeof (parseResponse.rundown[0] as OntimeEvent).cue).toBe('string');
expect(typeof (parseResponse.rundown[1] as OntimeEvent).cue).toBe('string');
});
it('generates missing ids', async () => {
const testData = {
rundown: [
@@ -420,10 +402,10 @@ describe('test corrupt data', () => {
expect(parsedDef.rundown.length).toBe(0);
});
it('handles missing project data', async () => {
const emptyProjectData = {
it('handles missing event data', async () => {
const emptyEventData = {
rundown: [{}, {}, {}, {}, {}, {}, {}, {}],
project: {},
eventData: {},
settings: {
app: 'ontime',
version: 2,
@@ -433,8 +415,8 @@ describe('test corrupt data', () => {
},
};
const parsedDef = await parseJson(emptyProjectData);
expect(parsedDef.project).toStrictEqual(dbModel.project);
const parsedDef = await parseJson(emptyEventData);
expect(parsedDef.eventData).toStrictEqual(dbModel.eventData);
});
it('handles missing settings', async () => {
@@ -525,7 +507,7 @@ describe('test event validator', () => {
expect(typeof validated.timeStart).toEqual('number');
expect(validated.timeStart).toEqual(0);
expect(typeof validated.timeEnd).toEqual('number');
expect(validated.timeEnd).toEqual(2);
expect(validated.timeEnd).toEqual(0);
});
it('handles bad objects', () => {
@@ -568,8 +550,7 @@ describe('test parseExcel function', () => {
const testdata = [
['Ontime ┬À Schedule Template'],
[],
['Project Name', 'Test Event'],
['Project Description', 'test description'],
['Event Name', 'Test Event'],
['Public URL', 'www.public.com'],
['Backstage URL', 'www.backstage.com'],
['Public Info', 'test public info'],
@@ -579,26 +560,25 @@ describe('test parseExcel function', () => {
[
'Time Start',
'Time End',
'Title',
'Presenter',
'Subtitle',
'Event Title',
'Presenter Name',
'Event Subtitle',
'End Action',
'Timer type',
'Public',
'Skip',
'Is Public? (x)',
'Skip? (x)',
'Notes',
'test0',
'test1',
'test2',
'test3',
'test4',
'test5',
'test6',
'test7',
'test8',
'test9',
'User0:test0',
'User1:test1',
'User2:test2',
'User3:test3',
'User4:test4',
'User5:test5',
'User6:test6',
'user7:test7',
'user8:test8',
'user9:test9',
'Colour',
'cue',
],
[
'1899-12-30T07:00:00.000Z',
@@ -622,7 +602,6 @@ describe('test parseExcel function', () => {
'a8',
'a9',
'red',
101,
],
[
'1899-12-30T08:00:00.000Z',
@@ -646,27 +625,12 @@ describe('test parseExcel function', () => {
'',
'',
'#F00',
102,
],
[],
];
const partialOptions = {
user0: 'test0',
user1: 'test1',
user2: 'test2',
user3: 'test3',
user4: 'test4',
user5: 'test5',
user6: 'test6',
user7: 'test7',
user8: 'test8',
user9: 'test9',
};
const expectedParsedProjectData = {
const expectedParsedEvent = {
title: 'Test Event',
description: 'test description',
publicUrl: 'www.public.com',
backstageUrl: 'www.backstage.com',
publicInfo: 'test public info',
@@ -698,7 +662,6 @@ describe('test parseExcel function', () => {
user9: 'a9',
colour: 'red',
type: 'event',
cue: '101',
},
{
//timeStart: 32400000,
@@ -715,12 +678,11 @@ describe('test parseExcel function', () => {
user5: 'b5',
colour: '#F00',
type: 'event',
cue: '102',
},
];
const parsedData = parseExcel(testdata, partialOptions);
expect(parsedData.project).toStrictEqual(expectedParsedProjectData);
const parsedData = await parseExcel(testdata);
expect(parsedData.eventData).toStrictEqual(expectedParsedEvent);
expect(parsedData.rundown).toBeDefined();
expect(parsedData.rundown[0]).toMatchObject(expectedParsedRundown[0]);
expect(parsedData.rundown[1]).toMatchObject(expectedParsedRundown[1]);
@@ -848,16 +810,7 @@ describe('test views import', () => {
app: 'ontime',
version: 2,
},
viewSettings: {
normalColor: '#ffffffcc',
warningColor: '#FFAB33',
warningThreshold: 120000,
dangerColor: '#ED3333',
dangerThreshold: 60000,
endMessage: '',
overrideStyles: false,
notAthing: true,
},
viewSettings: {},
views: {
overrideStyles: true,
},
@@ -871,7 +824,7 @@ describe('test views import', () => {
endMessage: '',
overrideStyles: false,
};
const parsed = parseViewSettings(testData);
const parsed = parseViewSettings(testData, false);
expect(parsed).toStrictEqual(expectedParsedViewSettings);
});
@@ -883,7 +836,16 @@ describe('test views import', () => {
version: 2,
},
};
const parsed = parseViewSettings(testData);
expect(parsed).toStrictEqual({});
const expectedParsedViewSettings = {
normalColor: '#ffffffcc',
warningColor: '#FFAB33',
warningThreshold: 120000,
dangerColor: '#ED3333',
dangerThreshold: 60000,
endMessage: '',
overrideStyles: false,
};
const parsed = parseViewSettings(testData, true);
expect(parsed).toStrictEqual(expectedParsedViewSettings);
});
});

Some files were not shown because too many files have changed in this diff Show More