mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-06 07:53:54 +00:00
refactor: cuesheet v2 (#435)
* refactor: typescript migration * chore: remove prop-types package * refactor: file structure * refactor: migrate ontime table to tanstack table 8 * refactor: rundown controller uses service as data source * refactor: convert to typescript * feat: caching store * refactor: add delay values to rundown * feat: toggle past visibility * chore: update tests * refactor: add extra fields to CSV * style: show skipped events * chore: add route to navigation menu * style: allow jumping to bottom * chore: add tests
This commit is contained in:
@@ -20,6 +20,12 @@ From the project root, run the following commands
|
||||
- __Install the project dependencies__ by running `pnpm i`
|
||||
- __Run dev mode__ by running `turbo dev`
|
||||
|
||||
### Debugging backend
|
||||
To debug backend code in Node.js:
|
||||
- Open two separate terminals and navigate to the `apps/client` and `apps/server` directories.
|
||||
- In each terminal, run the command `pnpm dev` to start the development servers for both the client and server applications.
|
||||
- If you need to set breakpoints and inspect the code execution, enable Node.js inspect mode by running `pnpm dev:inspect`.
|
||||
|
||||
## TESTING
|
||||
|
||||
Generally we have 2 types of tests.
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"extends": [
|
||||
"stylelint-config-standard-scss",
|
||||
"stylelint-config-prettier"
|
||||
]
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
"version": "2.0.9",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@chakra-ui/react": "^2.5.5",
|
||||
"@chakra-ui/react": "^2.7.0",
|
||||
"@dnd-kit/core": "^6.0.8",
|
||||
"@dnd-kit/sortable": "^7.0.2",
|
||||
"@dnd-kit/utilities": "^3.2.1",
|
||||
@@ -14,7 +14,8 @@
|
||||
"@sentry/tracing": "^7.46.0",
|
||||
"@tanstack/react-query": "^4.28.0",
|
||||
"@tanstack/react-query-devtools": "^4.29.0",
|
||||
"autosize": "^5.0.2",
|
||||
"@tanstack/react-table": "^8.9.2",
|
||||
"autosize": "^6.0.1",
|
||||
"axios": "^1.2.0",
|
||||
"color": "^4.2.3",
|
||||
"csv-stringify": "^6.2.3",
|
||||
@@ -27,7 +28,6 @@
|
||||
"react-hook-form": "^7.43.5",
|
||||
"react-qr-code": "^2.0.11",
|
||||
"react-router-dom": "^6.3.0",
|
||||
"react-table": "^7.7.0",
|
||||
"typeface-open-sans": "^1.1.13",
|
||||
"web-vitals": "^3.1.1",
|
||||
"zustand": "^4.3.6"
|
||||
@@ -40,7 +40,6 @@
|
||||
"build:local": "cross-env NODE_ENV=local vite build",
|
||||
"build:docker": "vite build",
|
||||
"lint": "eslint .",
|
||||
"stylelint": "npx stylelint \"**/*.scss\"\n",
|
||||
"test": "vitest",
|
||||
"test:pipeline": "vitest run",
|
||||
"cleanup": "rm -rf .turbo && rm -rf node_modules && rm -rf build"
|
||||
@@ -65,7 +64,6 @@
|
||||
"@testing-library/user-event": "^14.1.1",
|
||||
"@types/color": "^3.0.3",
|
||||
"@types/luxon": "^3.2.0",
|
||||
"@types/prop-types": "^15.7.5",
|
||||
"@types/react": "^18.0.26",
|
||||
"@types/react-dom": "^18.0.10",
|
||||
"@types/testing-library__jest-dom": "^5.14.5",
|
||||
@@ -84,11 +82,7 @@
|
||||
"ontime-types": "workspace:*",
|
||||
"ontime-utils": "workspace:*",
|
||||
"prettier": "^2.8.3",
|
||||
"prop-types": "^15.8.1",
|
||||
"sass": "^1.57.1",
|
||||
"stylelint": "^14.16.1",
|
||||
"stylelint-config-prettier": "^9.0.4",
|
||||
"stylelint-config-standard-scss": "^6.1.0",
|
||||
"typescript": "^4.9.4",
|
||||
"vite": "^4.3.1",
|
||||
"vite-plugin-compression2": "^0.9.0",
|
||||
|
||||
@@ -5,7 +5,7 @@ import useAliases from './common/hooks-query/useAliases';
|
||||
import withData from './features/viewers/ViewWrapper';
|
||||
|
||||
const Editor = lazy(() => import('./features/editors/ProtectedEditor'));
|
||||
const Table = lazy(() => import('./features/table/ProtectedTable'));
|
||||
const Cuesheet = lazy(() => import('./features/cuesheet/ProtectedCuesheet'));
|
||||
|
||||
const TimerView = lazy(() => import('./features/viewers/timer/Timer'));
|
||||
const MinimalTimerView = lazy(() => import('./features/viewers/minimal-timer/MinimalTimer'));
|
||||
@@ -76,9 +76,9 @@ export default function AppRouter() {
|
||||
|
||||
{/*/!* Protected Routes *!/*/}
|
||||
<Route path='/editor' element={<Editor />} />
|
||||
<Route path='/cuesheet' element={<Table />} />
|
||||
<Route path='/cuelist' element={<Table />} />
|
||||
<Route path='/table' element={<Table />} />
|
||||
<Route path='/cuesheet' element={<Cuesheet />} />
|
||||
<Route path='/cuelist' element={<Cuesheet />} />
|
||||
<Route path='/table' element={<Cuesheet />} />
|
||||
|
||||
{/*/!* Protected Routes - Elements *!/*/}
|
||||
<Route
|
||||
|
||||
@@ -3,12 +3,7 @@ import { Textarea, TextareaProps } from '@chakra-ui/react';
|
||||
// @ts-expect-error no types from library
|
||||
import autosize from 'autosize/dist/autosize';
|
||||
|
||||
interface AutoTextAreaProps extends TextareaProps {
|
||||
isDark?: boolean;
|
||||
}
|
||||
|
||||
export const AutoTextArea = (props: AutoTextAreaProps) => {
|
||||
const { isDark, ...rest } = props;
|
||||
export const AutoTextArea = (props: TextareaProps) => {
|
||||
const ref = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -26,8 +21,8 @@ export const AutoTextArea = (props: AutoTextAreaProps) => {
|
||||
resize='none'
|
||||
ref={ref}
|
||||
transition='height none'
|
||||
variant={isDark ? 'ontime-filled' : 'ontime-filled-on-light'}
|
||||
{...rest}
|
||||
variant='ontime-transparent'
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -112,6 +112,11 @@ function NavigationMenu() {
|
||||
</div>
|
||||
</div>
|
||||
<hr className={style.separator} />
|
||||
<Link to='/cuesheet' className={style.link} tabIndex={0}>
|
||||
Cuesheet
|
||||
<IoArrowUp className={style.linkIcon} />
|
||||
</Link>
|
||||
<hr className={style.separator} />
|
||||
{navigatorConstants.map((route) => (
|
||||
<Link
|
||||
key={route.url}
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
import { createContext, useCallback, useState } from 'react';
|
||||
|
||||
import { useLocalStorage } from '../hooks/useLocalStorage';
|
||||
|
||||
export const TableSettingsContext = createContext({
|
||||
theme: '',
|
||||
showSettings: false,
|
||||
followSelected: false,
|
||||
|
||||
toggleSettings: () => undefined,
|
||||
toggleTheme: () => undefined,
|
||||
toggleFollow: () => undefined,
|
||||
});
|
||||
|
||||
export const TableSettingsProvider = ({ children }) => {
|
||||
const [theme, setTheme] = useLocalStorage('table-color-theme', 'dark');
|
||||
const [followSelected, setFollowSelected] = useLocalStorage('table-follow-selected', false);
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
|
||||
/**
|
||||
* @description Toggles the current value of dark mode
|
||||
* @param {string} val - 'light' or 'dark'
|
||||
*/
|
||||
const toggleTheme = useCallback(
|
||||
(val) => {
|
||||
if (val === undefined) {
|
||||
setTheme((prev) => (prev === 'light' ? 'dark' : 'light'));
|
||||
} else {
|
||||
setTheme(val);
|
||||
}
|
||||
},
|
||||
[setTheme]
|
||||
);
|
||||
|
||||
/**
|
||||
* @description Toggles visibility state for settings
|
||||
* @param {boolean} val - whether the settings window is visible
|
||||
*/
|
||||
const toggleSettings = useCallback(
|
||||
(val) => {
|
||||
if (val === undefined) {
|
||||
setShowSettings((prev) => !prev);
|
||||
} else {
|
||||
setShowSettings(val);
|
||||
}
|
||||
},
|
||||
[setShowSettings]
|
||||
);
|
||||
|
||||
/**
|
||||
* @description Toggles follow option
|
||||
* @param {boolean} val - whether the window follows selected event
|
||||
*/
|
||||
const toggleFollow = useCallback(
|
||||
(val) => {
|
||||
if (val === undefined) {
|
||||
setFollowSelected((prev) => !prev);
|
||||
} else {
|
||||
setFollowSelected(val);
|
||||
}
|
||||
},
|
||||
[setFollowSelected]
|
||||
);
|
||||
|
||||
return (
|
||||
<TableSettingsContext.Provider
|
||||
value={{
|
||||
theme,
|
||||
showSettings,
|
||||
followSelected,
|
||||
toggleSettings,
|
||||
toggleTheme,
|
||||
toggleFollow,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</TableSettingsContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback } from 'react';
|
||||
import { Log, LogLevel } from 'ontime-types';
|
||||
import { Log, LogLevel, LogOrigin } from 'ontime-types';
|
||||
import { generateId, millisToString } from 'ontime-utils';
|
||||
import { useStore } from 'zustand';
|
||||
import { createStore } from 'zustand/vanilla';
|
||||
@@ -34,7 +34,7 @@ export function useEmitLog() {
|
||||
const _emit = useCallback((text: string, level: LogLevel) => {
|
||||
const log = {
|
||||
id: generateId(),
|
||||
origin: 'CLIENT',
|
||||
origin: LogOrigin.Client,
|
||||
time: millisToString(nowInMillis()),
|
||||
level,
|
||||
text,
|
||||
|
||||
@@ -1,4 +1,21 @@
|
||||
import { forgivingStringToMillis, millisToDelayString, millisToMinutes, millisToSeconds } from '../dateConfig';
|
||||
import {
|
||||
forgivingStringToMillis,
|
||||
millisToDelayString,
|
||||
millisToMinutes,
|
||||
millisToSeconds,
|
||||
secondsInMillis,
|
||||
} from '../dateConfig';
|
||||
|
||||
describe('test secondsInMillis function', () => {
|
||||
it('return 0 if value is null', () => {
|
||||
expect(secondsInMillis(null)).toBe(0);
|
||||
});
|
||||
it('returns the seconds value of a millis date', () => {
|
||||
const date = 1686255053619; // Thu Jun 08 2023 20:10:53
|
||||
const seconds = secondsInMillis(date);
|
||||
expect(seconds).toBe(53);
|
||||
});
|
||||
});
|
||||
|
||||
describe('test millisToSeconds function', () => {
|
||||
it('test with null values', () => {
|
||||
|
||||
@@ -5,6 +5,13 @@ import { mth, mtm, mts } from './timeConstants';
|
||||
export const timeFormat = 'HH:mm';
|
||||
export const timeFormatSeconds = 'HH:mm:ss';
|
||||
|
||||
export function secondsInMillis(millis: number | null) {
|
||||
if (!millis) {
|
||||
return 0;
|
||||
}
|
||||
return Math.floor((millis % mtm) / mts);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Converts milliseconds to seconds
|
||||
* @param {number | null} millis - time in seconds
|
||||
|
||||
@@ -41,7 +41,7 @@ export const getEventsWithDelay = (rundown: OntimeRundownEntry[]): OntimeEvent[]
|
||||
* @param {number} limit - max number of events to return
|
||||
* @returns {Object[]} Event list with maximum <limit> objects
|
||||
*/
|
||||
export const trimRundown = (rundown: OntimeRundownEntry[], selectedId: string, limit: number) => {
|
||||
export const trimRundown = (rundown: OntimeEvent[], selectedId: string, limit: number): OntimeEvent[] => {
|
||||
if (rundown == null) return [];
|
||||
|
||||
const BEFORE = 2;
|
||||
@@ -78,7 +78,7 @@ export const formatEventList = (
|
||||
selectedId: string,
|
||||
nextId: string,
|
||||
options: FormatEventListOptionsProp,
|
||||
) => {
|
||||
): ScheduleEvent[] => {
|
||||
if (rundown == null) return [];
|
||||
const { showEnd = false } = options;
|
||||
|
||||
@@ -103,6 +103,15 @@ export const formatEventList = (
|
||||
return formattedEvents;
|
||||
};
|
||||
|
||||
export type ScheduleEvent = {
|
||||
id: string;
|
||||
time: string;
|
||||
title: string;
|
||||
isNow: boolean;
|
||||
isNext: boolean;
|
||||
colour: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Creates a safe duplicate of an event
|
||||
* @param {object} event
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
@use '../../theme/ontimeColours' as *;
|
||||
@use '../../theme/v2Styles' as *;
|
||||
|
||||
$table-font-size: calc(1rem - 2px);
|
||||
$table-header-font-size: calc(1rem - 3px);
|
||||
|
||||
@mixin ellipsis-overflow() {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.cuesheetContainer {
|
||||
grid-area: table;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
padding-bottom: 640px; // allow focus to reach last elements
|
||||
}
|
||||
|
||||
.cuesheet {
|
||||
font-size: $table-font-size;
|
||||
font-weight: 400;
|
||||
|
||||
tr {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
th, td {
|
||||
margin: 1px;
|
||||
font-weight: inherit;
|
||||
font-size: inherit;
|
||||
text-align: left;
|
||||
position: relative;
|
||||
@include ellipsis-overflow;
|
||||
}
|
||||
}
|
||||
|
||||
.tableHeader,
|
||||
.eventRow {
|
||||
.indexColumn {
|
||||
min-width: 2rem;
|
||||
text-align: right;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
|
||||
.tableHeader {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background-color: $gray-1300;
|
||||
|
||||
font-size: $table-header-font-size;
|
||||
color: $gray-700;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.eventRow {
|
||||
vertical-align: top;
|
||||
|
||||
td {
|
||||
background-color: $gray-1250;
|
||||
border-radius: 2px;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
&.skip {
|
||||
text-decoration: line-through;
|
||||
opacity: $opacity-disabled !important; // fighting inline styles
|
||||
}
|
||||
}
|
||||
|
||||
.blockRow {
|
||||
width: 100%;
|
||||
background-color: $gray-1350;
|
||||
font-size: 1rem;
|
||||
height: 2.5rem;
|
||||
|
||||
td {
|
||||
align-self: flex-end;
|
||||
position: sticky;
|
||||
left: 1rem;
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
}
|
||||
|
||||
.delayRow {
|
||||
width: 100%;
|
||||
color: $ontime-delay-text;
|
||||
|
||||
td {
|
||||
position: sticky;
|
||||
left: 47.5%; // center of the screen, ish
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
}
|
||||
|
||||
.check {
|
||||
font-size: 1.5rem;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.time {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
|
||||
> * {
|
||||
@include ellipsis-overflow;
|
||||
}
|
||||
}
|
||||
|
||||
.delaySymbol {
|
||||
svg {
|
||||
font-size: 1.5rem;
|
||||
color: $ontime-delay;
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
.delayedTime {
|
||||
color: $ontime-delay-text;
|
||||
font-size: calc(1rem - 2px);
|
||||
}
|
||||
|
||||
.resizer {
|
||||
cursor: col-resize;
|
||||
opacity: 0;
|
||||
display: inline-block;
|
||||
width: 3px;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
transform: translateX(50%);
|
||||
background-color: $action-blue;
|
||||
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
|
||||
transition-duration: $transition-time-action;
|
||||
transition-property: width, background-color;
|
||||
|
||||
&:hover {
|
||||
opacity: $opacity-disabled;
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
&.isResizing {
|
||||
opacity: 1;
|
||||
width: 6px;
|
||||
background-color: $action-blue;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
import { MutableRefObject, useEffect, 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 { OntimeBlock, OntimeDelay, OntimeEvent, OntimeRundown, OntimeRundownEntry, SupportedEvent } from 'ontime-types';
|
||||
|
||||
import { useLocalStorage } from '../../common/hooks/useLocalStorage';
|
||||
import { millisToDelayString } from '../../common/utils/dateConfig';
|
||||
import { getAccessibleColour } from '../../common/utils/styleUtils';
|
||||
import { tooltipDelayFast } from '../../ontimeConfig';
|
||||
|
||||
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>[];
|
||||
handleUpdate: (rowIndex: number, accessor: keyof OntimeRundownEntry, payload: unknown) => void;
|
||||
selectedId: string | null;
|
||||
}
|
||||
|
||||
export default function Cuesheet({ data, columns, handleUpdate, selectedId }: CuesheetProps) {
|
||||
const followSelected = useCuesheetSettings((state) => state.followSelected);
|
||||
const showSettings = useCuesheetSettings((state) => state.showSettings);
|
||||
const showDelayBlock = useCuesheetSettings((state) => state.showDelayBlock);
|
||||
const showPrevious = useCuesheetSettings((state) => state.showPrevious);
|
||||
|
||||
const [columnVisibility, setColumnVisibility] = useLocalStorage('table-hidden', {});
|
||||
const [columnOrder, saveColumnOrder] = useLocalStorage<string[]>('table-order', initialColumnOrder);
|
||||
const [columnSizing, setColumnSizing] = useLocalStorage('table-sizes', {});
|
||||
|
||||
const selectedRef = useRef<HTMLTableRowElement | null>(null);
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
columnResizeMode: 'onChange',
|
||||
state: {
|
||||
columnOrder,
|
||||
columnVisibility,
|
||||
columnSizing,
|
||||
},
|
||||
meta: {
|
||||
handleUpdate,
|
||||
},
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onColumnSizingChange: setColumnSizing,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
const tableContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
delay: 100,
|
||||
tolerance: 50,
|
||||
},
|
||||
}),
|
||||
useSensor(TouchSensor, {
|
||||
activationConstraint: {
|
||||
delay: 100,
|
||||
tolerance: 50,
|
||||
},
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
}),
|
||||
);
|
||||
|
||||
// when selection moves, view should follow
|
||||
useEffect(() => {
|
||||
function scrollToComponent(
|
||||
componentRef: MutableRefObject<HTMLTableRowElement>,
|
||||
scrollRef: MutableRefObject<HTMLDivElement>,
|
||||
) {
|
||||
const componentRect = componentRef.current.getBoundingClientRect();
|
||||
const scrollRect = scrollRef.current.getBoundingClientRect();
|
||||
const top = componentRect.top - scrollRect.top + scrollRef.current.scrollTop - 100;
|
||||
scrollRef.current.scrollTo({ top, behavior: 'smooth' });
|
||||
}
|
||||
|
||||
if (!followSelected) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedRef.current && tableContainerRef.current) {
|
||||
// Use requestAnimationFrame to ensure the component is fully loaded
|
||||
window.requestAnimationFrame(() => {
|
||||
scrollToComponent(
|
||||
selectedRef as MutableRefObject<HTMLTableRowElement>,
|
||||
tableContainerRef as MutableRefObject<HTMLDivElement>,
|
||||
);
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line -- the prompt seems incorrect, we need the refs
|
||||
}, [selectedRef.current, tableContainerRef.current, followSelected]);
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
const setAllVisible = () => {
|
||||
table.toggleAllColumnsVisible(true);
|
||||
};
|
||||
|
||||
const resetColumnResizing = () => {
|
||||
setColumnSizing({});
|
||||
};
|
||||
|
||||
let eventIndex = 0;
|
||||
let isPast = Boolean(selectedId);
|
||||
|
||||
return (
|
||||
<>
|
||||
{showSettings && (
|
||||
<CuesheetTableSettings
|
||||
columns={table.getAllLeafColumns()}
|
||||
handleResetResizing={resetColumnResizing}
|
||||
handleResetReordering={resetColumnOrder}
|
||||
handleClearToggles={setAllVisible}
|
||||
/>
|
||||
)}
|
||||
<div ref={tableContainerRef} className={style.cuesheetContainer}>
|
||||
<table className={style.cuesheet}>
|
||||
<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 entryType = row.original.type as SupportedEvent;
|
||||
const key = row.original.id;
|
||||
const isSelected = selectedId === key;
|
||||
if (isSelected) {
|
||||
isPast = false;
|
||||
}
|
||||
|
||||
if (entryType === SupportedEvent.Block) {
|
||||
const title = (row.original as OntimeBlock).title;
|
||||
|
||||
return (
|
||||
<tr key={key} className={style.blockRow}>
|
||||
<td>{title}</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
if (entryType === SupportedEvent.Delay) {
|
||||
const delayVal = (row.original as OntimeDelay).duration;
|
||||
|
||||
if (!showDelayBlock || delayVal === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const delayTime = millisToDelayString(delayVal);
|
||||
return (
|
||||
<tr key={key} className={style.delayRow}>
|
||||
<td>{delayTime}</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
if (entryType === SupportedEvent.Event) {
|
||||
eventIndex++;
|
||||
const isSelected = key === selectedId;
|
||||
if (isSelected) {
|
||||
isPast = false;
|
||||
}
|
||||
|
||||
if (isPast && !showPrevious) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const bgFallback = 'transparent';
|
||||
const bgColour = (row.original as OntimeEvent).colour || bgFallback;
|
||||
const textColour = bgColour === bgFallback ? undefined : getAccessibleColour(bgColour);
|
||||
const isSkipped = (row.original as OntimeEvent).skip;
|
||||
|
||||
let rowBgColour: string | undefined;
|
||||
if (row.original.id === selectedId) {
|
||||
rowBgColour = '#D20300'; // $red-700
|
||||
}
|
||||
return (
|
||||
<tr
|
||||
key={key}
|
||||
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 }}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
// currently there is no scenario where entryType is not handled above, either way...
|
||||
return null;
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
@use '../../theme/v2Styles' as *;
|
||||
@use '../../theme/ontimeColours' as *;
|
||||
|
||||
.tableWrapper {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
padding: 1rem;
|
||||
|
||||
display: grid;
|
||||
grid-template-rows: auto auto 1fr;
|
||||
grid-template-areas:
|
||||
'header'
|
||||
'settings'
|
||||
'table';
|
||||
gap: 1rem;
|
||||
|
||||
background-color: $gray-1300;
|
||||
color: white;
|
||||
|
||||
& > * {
|
||||
border: 1px solid $white-10;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
+23
-21
@@ -1,23 +1,25 @@
|
||||
import { useCallback, useContext, useEffect } from 'react';
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { EventData, OntimeRundownEntry } from 'ontime-types';
|
||||
|
||||
import { TableSettingsContext } from '../../common/context/TableSettingsContext';
|
||||
import Empty from '../../common/components/state/Empty';
|
||||
import { useEventAction } from '../../common/hooks/useEventAction';
|
||||
import { useCuesheet } from '../../common/hooks/useSocket';
|
||||
import useRundown from '../../common/hooks-query/useRundown';
|
||||
import useUserFields from '../../common/hooks-query/useUserFields';
|
||||
|
||||
import OntimeTable from './OntimeTable';
|
||||
import TableHeader from './TableHeader';
|
||||
import { makeCSV, makeTable } from './tableUtils';
|
||||
import CuesheetTableHeader from './cuesheet-table-header/CuesheetTableHeader';
|
||||
import Cuesheet from './Cuesheet';
|
||||
import { makeCuesheetColumns } from './cuesheetCols';
|
||||
import { makeCSV, makeTable } from './cuesheetUtils';
|
||||
|
||||
import style from './Table.module.scss';
|
||||
import styles from './CuesheetWrapper.module.scss';
|
||||
|
||||
export default function TableWrapper() {
|
||||
export default function CuesheetWrapper() {
|
||||
const { data: rundown } = useRundown();
|
||||
const { data: userFields } = useUserFields();
|
||||
const { updateEvent } = useEventAction();
|
||||
const featureData = useCuesheet();
|
||||
const { theme } = useContext(TableSettingsContext);
|
||||
const columns = useMemo(() => makeCuesheetColumns(userFields), [userFields]);
|
||||
|
||||
// Set window title
|
||||
useEffect(() => {
|
||||
@@ -25,14 +27,18 @@ export default function TableWrapper() {
|
||||
}, []);
|
||||
|
||||
const handleUpdate = useCallback(
|
||||
async (rowIndex, accessor, payload) => {
|
||||
async (rowIndex: number, accessor: keyof OntimeRundownEntry, payload: unknown) => {
|
||||
if (!rundown) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (rowIndex == null || accessor == null || payload == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// check if value is the same
|
||||
const event = rundown[rowIndex];
|
||||
if (event == null) {
|
||||
if (!event) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -63,7 +69,7 @@ export default function TableWrapper() {
|
||||
);
|
||||
|
||||
const exportHandler = useCallback(
|
||||
(headerData) => {
|
||||
(headerData: EventData) => {
|
||||
if (!headerData || !rundown || !userFields) {
|
||||
return;
|
||||
}
|
||||
@@ -80,18 +86,14 @@ export default function TableWrapper() {
|
||||
[rundown, userFields],
|
||||
);
|
||||
|
||||
if (typeof rundown === 'undefined' || typeof userFields === 'undefined') {
|
||||
return <span>loading...</span>;
|
||||
if (!rundown || !userFields) {
|
||||
return <Empty text='Loading...' />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={theme === 'dark' ? style.tableWrapper__dark : style.tableWrapper} data-testid='cuesheet'>
|
||||
<TableHeader handleCSVExport={exportHandler} featureData={featureData} />
|
||||
<OntimeTable
|
||||
tableData={rundown}
|
||||
userFields={userFields}
|
||||
handleUpdate={handleUpdate}
|
||||
selectedId={featureData.selectedEventId}
|
||||
/>
|
||||
<div className={styles.tableWrapper} data-testid='cuesheet'>
|
||||
<CuesheetTableHeader handleCSVExport={exportHandler} featureData={featureData} />
|
||||
<Cuesheet data={rundown} columns={columns} handleUpdate={handleUpdate} selectedId={featureData.selectedEventId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import ProtectRoute from '../../common/components/protect-route/ProtectRoute';
|
||||
|
||||
import CuesheetWrapper from './CuesheetWrapper';
|
||||
|
||||
export default function ProtectedCuesheet() {
|
||||
return (
|
||||
<ProtectRoute permission='operator'>
|
||||
<CuesheetWrapper />
|
||||
</ProtectRoute>
|
||||
);
|
||||
}
|
||||
+7
-1
@@ -25,8 +25,11 @@ exports[`makeTable() > returns array of arrays with given fields 1`] = `
|
||||
"Presenter Name",
|
||||
"Event Subtitle",
|
||||
"Is Public? (x)",
|
||||
"Notes",
|
||||
"Note",
|
||||
"Colour",
|
||||
"End Action",
|
||||
"Timer Type",
|
||||
"Skip?",
|
||||
"user0:test",
|
||||
],
|
||||
[
|
||||
@@ -38,6 +41,9 @@ exports[`makeTable() > returns array of arrays with given fields 1`] = `
|
||||
"x",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"test",
|
||||
"test",
|
||||
"",
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { makeCSV, makeTable, parseField } from '../tableUtils';
|
||||
import { makeCSV, makeTable, parseField } from '../cuesheetUtils';
|
||||
|
||||
describe('parseField()', () => {
|
||||
it('returns a string from given millis on timeStart and TimeEnd', () => {
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
@use '../../../theme/ontimeColours' as *;
|
||||
@use '../../../theme/v2Styles' as *;
|
||||
|
||||
$label-colour: $gray-700;
|
||||
$active-colour: $gray-500;
|
||||
|
||||
@mixin label {
|
||||
font-size: $inner-section-text-size;
|
||||
color: $label-colour;
|
||||
text-align: center;
|
||||
align-self: end;
|
||||
}
|
||||
|
||||
@mixin time {
|
||||
font-family: "Open Sans Light", $ontime-font-family;
|
||||
font-size: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.header {
|
||||
grid-area: header;
|
||||
display: grid;
|
||||
width: 100%;
|
||||
padding: 0.25rem 1rem;
|
||||
height: max-content;
|
||||
column-gap: 2rem;
|
||||
|
||||
grid-template-areas:
|
||||
'event playback timer clock actions';
|
||||
grid-template-columns: 1fr auto auto auto auto;
|
||||
align-items: center;
|
||||
justify-items: center;
|
||||
}
|
||||
|
||||
.event {
|
||||
grid-area: event;
|
||||
justify-self: start;
|
||||
|
||||
.title {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.eventNow {
|
||||
justify-self: start;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.playback {
|
||||
grid-area: playback;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-items: center;
|
||||
align-items: center;
|
||||
|
||||
.playbackLabel {
|
||||
@include label;
|
||||
}
|
||||
|
||||
svg {
|
||||
font-size: 2rem;
|
||||
height: 3rem;
|
||||
color: $label-colour;
|
||||
}
|
||||
}
|
||||
|
||||
.timer {
|
||||
grid-area: timer;
|
||||
|
||||
.timerLabel {
|
||||
@include label;
|
||||
}
|
||||
|
||||
.value {
|
||||
@include time;
|
||||
}
|
||||
}
|
||||
|
||||
.clock {
|
||||
grid-area: clock;
|
||||
|
||||
.clockLabel {
|
||||
@include label;
|
||||
}
|
||||
|
||||
.value {
|
||||
grid-area: clock;
|
||||
@include time;
|
||||
}
|
||||
}
|
||||
|
||||
.headerActions {
|
||||
grid-area: actions;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 1.125rem;
|
||||
color: $label-colour;
|
||||
height: 100%;
|
||||
|
||||
.actionIcon {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: $active-colour;
|
||||
}
|
||||
|
||||
&.enabled {
|
||||
color: $active-indicator;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1200px) {
|
||||
// in large screens we want to space the buttons
|
||||
.headerActions {
|
||||
padding-left: 10vw;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { Tooltip } from '@chakra-ui/react';
|
||||
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 { EventData, Playback } from 'ontime-types';
|
||||
import { formatDisplay } from 'ontime-utils';
|
||||
|
||||
import useFullscreen from '../../../common/hooks/useFullscreen';
|
||||
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 PlaybackIcon from '../tableElements/PlaybackIcon';
|
||||
|
||||
import style from './CuesheetTableHeader.module.scss';
|
||||
|
||||
interface CuesheetTableHeaderProps {
|
||||
handleCSVExport: (headerData: EventData) => void;
|
||||
featureData: {
|
||||
playback: Playback;
|
||||
selectedEventIndex: number | null;
|
||||
numEvents: number;
|
||||
titleNow: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export default function CuesheetTableHeader({ handleCSVExport, featureData }: CuesheetTableHeaderProps) {
|
||||
const followSelected = useCuesheetSettings((state) => state.followSelected);
|
||||
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: event } = useEventData();
|
||||
|
||||
const exportCsv = () => {
|
||||
if (event) {
|
||||
handleCSVExport(event);
|
||||
}
|
||||
};
|
||||
|
||||
const selected = !featureData.numEvents
|
||||
? 'No events'
|
||||
: `Event ${featureData.selectedEventIndex != null ? featureData.selectedEventIndex + 1 : '-'}/${
|
||||
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}>{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>
|
||||
<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 : ''}`}>
|
||||
<IoLocate />
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip openDelay={tooltipDelayFast} label='Toggle settings'>
|
||||
<span onClick={() => toggleSettings()} className={`${style.actionIcon} ${showSettings ? style.enabled : ''}`}>
|
||||
<IoSettingsOutline />
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip openDelay={tooltipDelayFast} label='Toggle Fullscreen'>
|
||||
<span onClick={() => toggleFullScreen()} className={style.actionIcon}>
|
||||
{isFullScreen ? <IoContract /> : <IoExpand />}
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip openDelay={tooltipDelayFast} label='Export rundown to CSV'>
|
||||
<span className={style.actionIcon} onClick={exportCsv}>
|
||||
CSV
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
@use '../../../theme/v2Styles' as *;
|
||||
@use '../../../theme/ontimeColours' as *;
|
||||
|
||||
.tableSettings {
|
||||
grid-area: settings;
|
||||
padding: 1rem;
|
||||
background-color: $ui-black;
|
||||
display: flex;
|
||||
font-size: $inner-section-text-size;
|
||||
}
|
||||
|
||||
.leftPanel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
color: $gray-700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.options {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
column-gap: 1rem;
|
||||
row-gap: 0.25em;
|
||||
}
|
||||
|
||||
.option {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.rightPanel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
|
||||
padding-left: 2rem;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Button, Checkbox, Switch } from '@chakra-ui/react';
|
||||
import { Column } from '@tanstack/react-table';
|
||||
import { OntimeRundownEntry } from 'ontime-types';
|
||||
|
||||
import { useCuesheetSettings } from '../store/CuesheetSettings';
|
||||
|
||||
import style from './CuesheetTableSettings.module.scss';
|
||||
|
||||
// reusable button styles
|
||||
const buttonProps = {
|
||||
size: 'sm',
|
||||
variant: 'ontime-subtle',
|
||||
};
|
||||
|
||||
interface CuesheetTableSettingsProps {
|
||||
columns: Column<OntimeRundownEntry, unknown>[];
|
||||
handleResetResizing: () => void;
|
||||
handleResetReordering: () => void;
|
||||
handleClearToggles: () => void;
|
||||
}
|
||||
|
||||
export default function CuesheetTableSettings(props: CuesheetTableSettingsProps) {
|
||||
const { columns, handleResetResizing, handleResetReordering, handleClearToggles } = props;
|
||||
const showPrevious = useCuesheetSettings((state) => state.showPrevious);
|
||||
const togglePreviousVisibility = useCuesheetSettings((state) => state.togglePreviousVisibility);
|
||||
const showDelayBlock = useCuesheetSettings((state) => state.showDelayBlock);
|
||||
const toggleDelayVisibility = useCuesheetSettings((state) => state.toggleDelayVisibility);
|
||||
const showDelayedTimes = useCuesheetSettings((state) => state.showDelayedTimes);
|
||||
const toggleDelayedTimes = useCuesheetSettings((state) => state.toggleDelayedTimes);
|
||||
|
||||
return (
|
||||
<div className={style.tableSettings}>
|
||||
<div className={style.leftPanel}>
|
||||
<div className={style.sectionTitle}>Toggle column visibility</div>
|
||||
<div className={style.options}>
|
||||
{columns.map((column) => {
|
||||
const columnHeader = column.columnDef.header;
|
||||
const visible = column.getIsVisible();
|
||||
return (
|
||||
<label key={`${column.id}-${visible}`} className={style.option}>
|
||||
<Checkbox
|
||||
variant='ontime-ondark'
|
||||
defaultChecked={visible}
|
||||
onChange={column.getToggleVisibilityHandler()}
|
||||
/>
|
||||
{columnHeader}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className={style.sectionTitle}>Table Options</div>
|
||||
<div className={style.options}>
|
||||
<label className={style.option}>
|
||||
<Switch variant='ontime' size='sm' isChecked={showPrevious} onChange={() => togglePreviousVisibility()} />
|
||||
Show past events
|
||||
</label>
|
||||
</div>
|
||||
<div className={style.sectionTitle}>Delay Flow</div>
|
||||
<div className={style.options}>
|
||||
<label className={style.option}>
|
||||
<Switch variant='ontime' size='sm' isChecked={showDelayedTimes} onChange={() => toggleDelayedTimes()} />
|
||||
Show delayed times
|
||||
</label>
|
||||
<label className={style.option}>
|
||||
<Switch variant='ontime' size='sm' isChecked={showDelayBlock} onChange={() => toggleDelayVisibility()} />
|
||||
Show delay blocks
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className={style.rightPanel}>
|
||||
<Button onClick={handleClearToggles} {...buttonProps}>
|
||||
Show All
|
||||
</Button>
|
||||
<Button onClick={handleResetResizing} {...buttonProps}>
|
||||
Reset Resizing
|
||||
</Button>
|
||||
<Button onClick={handleResetReordering} {...buttonProps}>
|
||||
Reset Reordering
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import { useCallback } from 'react';
|
||||
import { Tooltip } from '@chakra-ui/react';
|
||||
import { IoCheckmark } from '@react-icons/all-files/io5/IoCheckmark';
|
||||
import { IoChevronDown } from '@react-icons/all-files/io5/IoChevronDown';
|
||||
import { IoChevronUp } from '@react-icons/all-files/io5/IoChevronUp';
|
||||
import { CellContext, ColumnDef } from '@tanstack/react-table';
|
||||
import { OntimeEvent, OntimeRundownEntry, UserFields } from 'ontime-types';
|
||||
import { millisToString } from 'ontime-utils';
|
||||
|
||||
import { millisToDelayString } from '../../common/utils/dateConfig';
|
||||
import { tooltipDelayFast } from '../../ontimeConfig';
|
||||
|
||||
import { useCuesheetSettings } from './store/CuesheetSettings';
|
||||
import EditableCell from './tableElements/EditableCell';
|
||||
|
||||
import style from './Cuesheet.module.scss';
|
||||
|
||||
function makePublic(row: CellContext<OntimeRundownEntry, unknown>) {
|
||||
const cellValue = row.getValue();
|
||||
return cellValue ? <IoCheckmark className={style.check} /> : '';
|
||||
}
|
||||
|
||||
function DelayIndicator(props: { delayValue: number }) {
|
||||
const { delayValue } = props;
|
||||
if (delayValue < 0) {
|
||||
return (
|
||||
<Tooltip openDelay={tooltipDelayFast} label={millisToDelayString(delayValue)}>
|
||||
<span className={style.delaySymbol}>
|
||||
<IoChevronDown />
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
if (delayValue > 0) {
|
||||
return (
|
||||
<Tooltip openDelay={tooltipDelayFast} label={millisToDelayString(delayValue)}>
|
||||
<span className={style.delaySymbol}>
|
||||
<IoChevronUp />
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function MakeTimer({ getValue, row: { original } }: CellContext<OntimeRundownEntry, unknown>) {
|
||||
const showDelayedTimes = useCuesheetSettings((state) => state.showDelayedTimes);
|
||||
const cellValue = (getValue() as number | null) ?? 0;
|
||||
const delayValue = (original as OntimeEvent)?.delay ?? 0;
|
||||
|
||||
console.log(delayValue)
|
||||
|
||||
return (
|
||||
<span className={style.time}>
|
||||
<DelayIndicator delayValue={delayValue} />
|
||||
{millisToString(cellValue)}
|
||||
{(delayValue !== 0 && showDelayedTimes) && <span className={style.delayedTime}>{` ${millisToString(cellValue + delayValue)}`}</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function MakeUserField({ getValue, row: { index }, column: { id }, table }: CellContext<OntimeRundownEntry, unknown>) {
|
||||
const update = useCallback(
|
||||
(newValue: string) => {
|
||||
// @ts-expect-error -- we inject this into react-table
|
||||
table.options.meta?.handleUpdate(index, id, newValue);
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- we skip table.options.meta since the reference seems unstable
|
||||
[id, index],
|
||||
);
|
||||
|
||||
const initialValue = getValue() as string;
|
||||
|
||||
return <EditableCell value={initialValue} handleUpdate={update} />;
|
||||
}
|
||||
|
||||
export function makeCuesheetColumns(userFields?: UserFields): ColumnDef<OntimeRundownEntry>[] {
|
||||
return [
|
||||
{
|
||||
accessorKey: 'isPublic',
|
||||
id: 'isPublic',
|
||||
header: 'Public',
|
||||
cell: makePublic,
|
||||
size: 45,
|
||||
},
|
||||
{
|
||||
accessorKey: 'timeStart',
|
||||
id: 'timeStart',
|
||||
header: 'Start',
|
||||
cell: MakeTimer,
|
||||
size: 75,
|
||||
},
|
||||
{
|
||||
accessorKey: 'timeEnd',
|
||||
id: 'timeEnd',
|
||||
header: 'End',
|
||||
cell: MakeTimer,
|
||||
size: 75,
|
||||
},
|
||||
{
|
||||
accessorKey: 'duration',
|
||||
id: 'duration',
|
||||
header: 'Duration',
|
||||
cell: (row) => millisToString(row.getValue() as number | null),
|
||||
size: 75,
|
||||
},
|
||||
{
|
||||
accessorKey: 'title',
|
||||
id: 'title',
|
||||
header: 'Title',
|
||||
cell: (row) => row.getValue(),
|
||||
},
|
||||
{
|
||||
accessorKey: 'subtitle',
|
||||
id: 'subtitle',
|
||||
header: 'Subtitle',
|
||||
cell: (row) => row.getValue(),
|
||||
},
|
||||
{
|
||||
accessorKey: 'presenter',
|
||||
id: 'presenter',
|
||||
header: 'Presenter',
|
||||
cell: (row) => row.getValue(),
|
||||
},
|
||||
{
|
||||
accessorKey: 'note',
|
||||
id: 'note',
|
||||
header: 'Note',
|
||||
cell: (row) => row.getValue(),
|
||||
},
|
||||
{
|
||||
accessorKey: 'user0',
|
||||
id: 'user0',
|
||||
header: userFields?.user0 || 'User 0',
|
||||
cell: MakeUserField,
|
||||
},
|
||||
{
|
||||
accessorKey: 'user1',
|
||||
id: 'user1',
|
||||
header: userFields?.user1 || 'User 1',
|
||||
cell: MakeUserField,
|
||||
},
|
||||
{
|
||||
accessorKey: 'user2',
|
||||
id: 'user2',
|
||||
header: userFields?.user2 || 'User 2',
|
||||
cell: MakeUserField,
|
||||
},
|
||||
{
|
||||
accessorKey: 'user3',
|
||||
id: 'user3',
|
||||
header: userFields?.user3 || 'User 3',
|
||||
cell: MakeUserField,
|
||||
},
|
||||
{
|
||||
accessorKey: 'user4',
|
||||
id: 'user4',
|
||||
header: userFields?.user4 || 'User 4',
|
||||
cell: MakeUserField,
|
||||
},
|
||||
{
|
||||
accessorKey: 'user5',
|
||||
id: 'user5',
|
||||
header: userFields?.user5 || 'User 5',
|
||||
cell: MakeUserField,
|
||||
},
|
||||
{
|
||||
accessorKey: 'user6',
|
||||
id: 'user6',
|
||||
header: userFields?.user6 || 'User 6',
|
||||
cell: MakeUserField,
|
||||
},
|
||||
{
|
||||
accessorKey: 'user7',
|
||||
id: 'user7',
|
||||
header: userFields?.user7 || 'User 7',
|
||||
cell: MakeUserField,
|
||||
},
|
||||
{
|
||||
accessorKey: 'user8',
|
||||
id: 'user8',
|
||||
header: userFields?.user8 || 'User 8',
|
||||
cell: MakeUserField,
|
||||
},
|
||||
{
|
||||
accessorKey: 'user9',
|
||||
id: 'user9',
|
||||
header: userFields?.user9 || 'User 9',
|
||||
cell: MakeUserField,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export const initialColumnOrder: string[] = makeCuesheetColumns().map((column) => column.id as string);
|
||||
+22
-13
@@ -1,4 +1,5 @@
|
||||
import { stringify } from 'csv-stringify/browser/esm/sync';
|
||||
import { EventData, OntimeEntryCommonKeys, OntimeRundown, UserFields } from 'ontime-types';
|
||||
import { millisToString } from 'ontime-utils';
|
||||
|
||||
/**
|
||||
@@ -8,14 +9,15 @@ import { millisToString } from 'ontime-utils';
|
||||
* @return {string}
|
||||
*/
|
||||
|
||||
export const parseField = (field, data) => {
|
||||
export const parseField = (field: keyof OntimeRundown, data: unknown): string => {
|
||||
let val;
|
||||
switch (field) {
|
||||
case 'timeStart':
|
||||
case 'timeEnd':
|
||||
val = millisToString(data);
|
||||
val = millisToString(data as number | null);
|
||||
break;
|
||||
case 'isPublic':
|
||||
case 'skip':
|
||||
val = data ? 'x' : '';
|
||||
break;
|
||||
default:
|
||||
@@ -25,17 +27,18 @@ export const parseField = (field, data) => {
|
||||
if (typeof data === 'undefined') {
|
||||
return '';
|
||||
}
|
||||
return val;
|
||||
// all other values are strings
|
||||
return val as string;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Creates an array of arrays usable by xlsx for export
|
||||
* @param {object} headerData
|
||||
* @param {array} tableData
|
||||
* @param {array} rundown
|
||||
* @param {object} userFields
|
||||
* @return {(string[])[]}
|
||||
*/
|
||||
export const makeTable = (headerData, tableData, userFields) => {
|
||||
export const makeTable = (headerData: EventData, rundown: OntimeRundown, userFields: UserFields): string[][] => {
|
||||
const data = [
|
||||
['Ontime · Schedule Template'],
|
||||
['Event Name', headerData?.title || ''],
|
||||
@@ -44,15 +47,18 @@ export const makeTable = (headerData, tableData, userFields) => {
|
||||
[],
|
||||
];
|
||||
|
||||
const fieldOrder = [
|
||||
const fieldOrder: OntimeEntryCommonKeys[] = [
|
||||
'timeStart',
|
||||
'timeEnd',
|
||||
'title',
|
||||
'presenter',
|
||||
'subtitle',
|
||||
'isPublic',
|
||||
'notes',
|
||||
'note',
|
||||
'colour',
|
||||
'endAction',
|
||||
'timerType',
|
||||
'skip',
|
||||
'user0',
|
||||
'user1',
|
||||
'user2',
|
||||
@@ -72,20 +78,23 @@ export const makeTable = (headerData, tableData, userFields) => {
|
||||
'Presenter Name',
|
||||
'Event Subtitle',
|
||||
'Is Public? (x)',
|
||||
'Notes',
|
||||
'Note',
|
||||
'Colour',
|
||||
'End Action',
|
||||
'Timer Type',
|
||||
'Skip?',
|
||||
];
|
||||
|
||||
for (const field in userFields) {
|
||||
const fieldValue = userFields[field];
|
||||
const fieldValue = userFields[field as keyof UserFields];
|
||||
const displayName = `${field}${fieldValue !== field && fieldValue !== '' ? `:${fieldValue}` : ''}`;
|
||||
fieldTitles.push(displayName);
|
||||
}
|
||||
|
||||
data.push(fieldTitles);
|
||||
|
||||
tableData.forEach((entry) => {
|
||||
const row = [];
|
||||
rundown.forEach((entry) => {
|
||||
const row: string[] = [];
|
||||
fieldOrder.forEach((field) => row.push(parseField(field, entry[field])));
|
||||
data.push(row);
|
||||
});
|
||||
@@ -98,8 +107,8 @@ export const makeTable = (headerData, tableData, userFields) => {
|
||||
* @param {array[]} arrayOfArrays
|
||||
* @return {string}
|
||||
*/
|
||||
export const makeCSV = (arrayOfArrays) => {
|
||||
let csvData = 'data:text/csv;charset=utf-8,';
|
||||
export const makeCSV = (arrayOfArrays: string[][]) => {
|
||||
const csvData = 'data:text/csv;charset=utf-8,';
|
||||
const stringifiedData = stringify(arrayOfArrays);
|
||||
return csvData + stringifiedData;
|
||||
};
|
||||
+4
-2
@@ -1,7 +1,9 @@
|
||||
import { OntimeEntryCommonKeys, OntimeEvent } from 'ontime-types';
|
||||
|
||||
/**
|
||||
* @description set default column order
|
||||
*/
|
||||
export const defaultColumnOrder = [
|
||||
export const defaultColumnOrder: OntimeEntryCommonKeys[] = [
|
||||
'isPublic',
|
||||
'timeStart',
|
||||
'timeEnd',
|
||||
@@ -25,7 +27,7 @@ export const defaultColumnOrder = [
|
||||
/**
|
||||
* @description set default hidden columns
|
||||
*/
|
||||
export const defaultHiddenColumns = [
|
||||
export const defaultHiddenColumns: (keyof OntimeEvent)[] = [
|
||||
'user0',
|
||||
'user1',
|
||||
'user2',
|
||||
@@ -0,0 +1,65 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
import { booleanFromLocalStorage } from '../../../common/utils/localStorage';
|
||||
|
||||
interface CuesheetSettings {
|
||||
showSettings: boolean;
|
||||
followSelected: boolean;
|
||||
showPrevious: boolean;
|
||||
showDelayBlock: boolean;
|
||||
showDelayedTimes: boolean;
|
||||
|
||||
toggleSettings: (newValue?: boolean) => void;
|
||||
toggleFollow: (newValue?: boolean) => void;
|
||||
togglePreviousVisibility: (newValue?: boolean) => void;
|
||||
toggleDelayVisibility: (newValue?: boolean) => void;
|
||||
toggleDelayedTimes: (newValue?: boolean) => void;
|
||||
}
|
||||
|
||||
function toggle(oldValue: boolean, value?: boolean) {
|
||||
if (typeof value === 'undefined') {
|
||||
return !oldValue;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
enum CuesheetKeys {
|
||||
Follow = 'ontime-cuesheet-follow-selected',
|
||||
DelayVisibility = 'ontime-cuesheet-show-delay',
|
||||
PreviousVisibility = 'ontime-cuesheet-show-previous',
|
||||
DelayedTimes = 'ontime-cuesheet-show-delayed',
|
||||
}
|
||||
|
||||
export const useCuesheetSettings = create<CuesheetSettings>()((set) => ({
|
||||
showSettings: false,
|
||||
followSelected: booleanFromLocalStorage(CuesheetKeys.Follow, false),
|
||||
showPrevious: booleanFromLocalStorage(CuesheetKeys.PreviousVisibility, true),
|
||||
showDelayBlock: booleanFromLocalStorage(CuesheetKeys.DelayVisibility, true),
|
||||
showDelayedTimes: booleanFromLocalStorage(CuesheetKeys.DelayedTimes, false),
|
||||
|
||||
toggleSettings: (newValue?: boolean) => set((state) => ({ showSettings: toggle(state.showSettings, newValue) })),
|
||||
toggleFollow: (newValue?: boolean) =>
|
||||
set((state) => {
|
||||
const followSelected = toggle(state.followSelected, newValue);
|
||||
localStorage.setItem(CuesheetKeys.Follow, String(followSelected));
|
||||
return { followSelected };
|
||||
}),
|
||||
togglePreviousVisibility: (newValue?: boolean) =>
|
||||
set((state) => {
|
||||
const showPrevious = toggle(state.showPrevious, newValue);
|
||||
localStorage.setItem(CuesheetKeys.PreviousVisibility, String(showPrevious));
|
||||
return { showPrevious };
|
||||
}),
|
||||
toggleDelayVisibility: (newValue?: boolean) =>
|
||||
set((state) => {
|
||||
const showDelayBlock = toggle(state.showDelayBlock, newValue);
|
||||
localStorage.setItem(CuesheetKeys.DelayVisibility, String(showDelayBlock));
|
||||
return { showDelayBlock };
|
||||
}),
|
||||
toggleDelayedTimes: (newValue?: boolean) =>
|
||||
set((state) => {
|
||||
const showDelayedTimes = toggle(state.showDelayedTimes, newValue);
|
||||
localStorage.setItem(CuesheetKeys.DelayedTimes, String(showDelayedTimes));
|
||||
return { showDelayedTimes };
|
||||
}),
|
||||
}));
|
||||
@@ -0,0 +1,40 @@
|
||||
import { ChangeEvent, memo, useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { AutoTextArea } from '../../../common/components/input/auto-text-area/AutoTextArea';
|
||||
|
||||
interface EditableCellProps {
|
||||
value: string;
|
||||
handleUpdate: (newValue: string) => void;
|
||||
}
|
||||
|
||||
const EditableCell = (props: EditableCellProps) => {
|
||||
const { value: initialValue, handleUpdate } = props;
|
||||
|
||||
// We need to keep and update the state of the cell normally
|
||||
const [value, setValue] = useState(initialValue);
|
||||
|
||||
const onChange = useCallback((event: ChangeEvent<HTMLTextAreaElement>) => setValue(event.target.value), []);
|
||||
|
||||
// We'll only update the external data when the input is blurred
|
||||
const onBlur = useCallback(() => handleUpdate(value), [handleUpdate, value]);
|
||||
|
||||
// If the initialValue is changed external, sync it up with our state
|
||||
useEffect(() => {
|
||||
setValue(initialValue);
|
||||
}, [initialValue]);
|
||||
|
||||
return (
|
||||
<AutoTextArea
|
||||
size='sm'
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onBlur={onBlur}
|
||||
rows={1}
|
||||
transition='none'
|
||||
spellCheck={false}
|
||||
style={{ padding: 0 }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(EditableCell);
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Tooltip } from '@chakra-ui/react';
|
||||
import { IoPause } from '@react-icons/all-files/io5/IoPause';
|
||||
import { IoPlay } from '@react-icons/all-files/io5/IoPlay';
|
||||
import { IoStop } from '@react-icons/all-files/io5/IoStop';
|
||||
import { Playback } from 'ontime-types';
|
||||
|
||||
import { tooltipDelayFast } from '../../../ontimeConfig';
|
||||
|
||||
interface PlaybackIconProps {
|
||||
state: Playback;
|
||||
}
|
||||
|
||||
export default function PlaybackIcon(props: PlaybackIconProps) {
|
||||
const { state } = props;
|
||||
|
||||
// if timer is Pause or Armed
|
||||
let label = 'Timer Paused';
|
||||
let Icon = IoPause;
|
||||
|
||||
if (state === Playback.Roll) {
|
||||
label = 'Timer Rolling';
|
||||
Icon = IoPlay;
|
||||
} else if (state === Playback.Play) {
|
||||
label = 'Timer Playing';
|
||||
Icon = IoPlay;
|
||||
} else if (state === Playback.Stop) {
|
||||
label = 'Timer Stopped';
|
||||
Icon = IoStop;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip openDelay={tooltipDelayFast} label={label} shouldWrapChildren>
|
||||
<Icon />
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { CSSProperties, ReactNode } from 'react';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { Header } from '@tanstack/react-table';
|
||||
import { OntimeRundownEntry } from 'ontime-types';
|
||||
|
||||
import { cx } from '../../../common/utils/styleUtils';
|
||||
|
||||
import styles from '../Cuesheet.module.scss';
|
||||
|
||||
interface SortableCellProps {
|
||||
header: Header<OntimeRundownEntry, unknown>;
|
||||
style: CSSProperties;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function SortableCell({ header, style, children }: SortableCellProps) {
|
||||
const { column, colSpan } = header;
|
||||
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: column.id,
|
||||
});
|
||||
|
||||
// build drag styles
|
||||
const dragStyle = {
|
||||
...style,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
transform: CSS.Translate.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
const resizerClasses = cx([styles.resizer, header.column.getIsResizing() ? styles.isResizing : null]);
|
||||
|
||||
return (
|
||||
<th ref={setNodeRef} style={dragStyle} colSpan={colSpan}>
|
||||
<div {...attributes} {...listeners}>
|
||||
{children}
|
||||
</div>
|
||||
<div
|
||||
{...{
|
||||
onMouseDown: header.getResizeHandler(),
|
||||
onTouchStart: header.getResizeHandler(),
|
||||
}}
|
||||
className={resizerClasses}
|
||||
/>
|
||||
</th>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +1,11 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Button } from '@chakra-ui/react';
|
||||
import { LogOrigin } from 'ontime-types';
|
||||
|
||||
import { clearLogs, useLogData } from '../../common/stores/logger';
|
||||
|
||||
import style from './InfoLogger.module.scss';
|
||||
|
||||
enum LogFilter {
|
||||
User = 'USER',
|
||||
Client = 'CLIENT',
|
||||
Server = 'SERVER',
|
||||
RX = 'RX',
|
||||
TX = 'TX',
|
||||
Playback = 'PLAYBACK',
|
||||
}
|
||||
|
||||
export default function InfoLogger() {
|
||||
const { logs: logData } = useLogData();
|
||||
|
||||
@@ -24,35 +16,35 @@ export default function InfoLogger() {
|
||||
const [showPlayback, setShowPlayback] = useState(true);
|
||||
const [showUser, setShowUser] = useState(true);
|
||||
|
||||
const matchers: LogFilter[] = [];
|
||||
const matchers: LogOrigin[] = [];
|
||||
if (showUser) {
|
||||
matchers.push(LogFilter.User);
|
||||
matchers.push(LogOrigin.User);
|
||||
}
|
||||
if (showClient) {
|
||||
matchers.push(LogFilter.Client);
|
||||
matchers.push(LogOrigin.Client);
|
||||
}
|
||||
if (showServer) {
|
||||
matchers.push(LogFilter.Server);
|
||||
matchers.push(LogOrigin.Server);
|
||||
}
|
||||
if (showRx) {
|
||||
matchers.push(LogFilter.RX);
|
||||
matchers.push(LogOrigin.Rx);
|
||||
}
|
||||
if (showTx) {
|
||||
matchers.push(LogFilter.TX);
|
||||
matchers.push(LogOrigin.Tx);
|
||||
}
|
||||
if (showPlayback) {
|
||||
matchers.push(LogFilter.Playback);
|
||||
matchers.push(LogOrigin.Playback);
|
||||
}
|
||||
|
||||
const filteredData = logData.filter((entry) => matchers.some((match) => entry.origin === match));
|
||||
|
||||
const disableOthers = useCallback((toEnable: LogFilter) => {
|
||||
toEnable === LogFilter.User ? setShowUser(true) : setShowUser(false);
|
||||
toEnable === LogFilter.Client ? setShowClient(true) : setShowClient(false);
|
||||
toEnable === LogFilter.Server ? setShowServer(true) : setShowServer(false);
|
||||
toEnable === LogFilter.RX ? setShowRx(true) : setShowRx(false);
|
||||
toEnable === LogFilter.TX ? setShowTx(true) : setShowTx(false);
|
||||
toEnable === LogFilter.Playback ? setShowPlayback(true) : setShowPlayback(false);
|
||||
const disableOthers = useCallback((toEnable: LogOrigin) => {
|
||||
toEnable === LogOrigin.User ? setShowUser(true) : setShowUser(false);
|
||||
toEnable === LogOrigin.Client ? setShowClient(true) : setShowClient(false);
|
||||
toEnable === LogOrigin.Server ? setShowServer(true) : setShowServer(false);
|
||||
toEnable === LogOrigin.Rx ? setShowRx(true) : setShowRx(false);
|
||||
toEnable === LogOrigin.Tx ? setShowTx(true) : setShowTx(false);
|
||||
toEnable === LogOrigin.Playback ? setShowPlayback(true) : setShowPlayback(false);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
@@ -62,55 +54,55 @@ export default function InfoLogger() {
|
||||
variant={showUser ? 'ontime-filled' : 'ontime-subtle'}
|
||||
size='xs'
|
||||
onClick={() => setShowUser((s) => !s)}
|
||||
onAuxClick={() => disableOthers(LogFilter.User)}
|
||||
onAuxClick={() => disableOthers(LogOrigin.User)}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
>
|
||||
{LogFilter.User}
|
||||
{LogOrigin.User}
|
||||
</Button>
|
||||
<Button
|
||||
variant={showClient ? 'ontime-filled' : 'ontime-subtle'}
|
||||
size='xs'
|
||||
onClick={() => setShowClient((s) => !s)}
|
||||
onAuxClick={() => disableOthers(LogFilter.Client)}
|
||||
onAuxClick={() => disableOthers(LogOrigin.Client)}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
>
|
||||
{LogFilter.Client}
|
||||
{LogOrigin.Client}
|
||||
</Button>
|
||||
<Button
|
||||
variant={showServer ? 'ontime-filled' : 'ontime-subtle'}
|
||||
size='xs'
|
||||
onClick={() => setShowServer((s) => !s)}
|
||||
onAuxClick={() => disableOthers(LogFilter.Server)}
|
||||
onAuxClick={() => disableOthers(LogOrigin.Server)}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
>
|
||||
{LogFilter.Server}
|
||||
{LogOrigin.Server}
|
||||
</Button>
|
||||
<Button
|
||||
variant={showPlayback ? 'ontime-filled' : 'ontime-subtle'}
|
||||
size='xs'
|
||||
onClick={() => setShowPlayback((s) => !s)}
|
||||
onAuxClick={() => disableOthers(LogFilter.Playback)}
|
||||
onAuxClick={() => disableOthers(LogOrigin.Playback)}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
>
|
||||
{LogFilter.Playback}
|
||||
{LogOrigin.Playback}
|
||||
</Button>
|
||||
<Button
|
||||
variant={showRx ? 'ontime-filled' : 'ontime-subtle'}
|
||||
size='xs'
|
||||
onClick={() => setShowRx((s) => !s)}
|
||||
onAuxClick={() => disableOthers(LogFilter.RX)}
|
||||
onAuxClick={() => disableOthers(LogOrigin.Rx)}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
>
|
||||
{LogFilter.RX}
|
||||
{LogOrigin.Rx}
|
||||
</Button>
|
||||
<Button
|
||||
variant={showTx ? 'ontime-filled' : 'ontime-subtle'}
|
||||
size='xs'
|
||||
onClick={() => setShowTx((s) => !s)}
|
||||
onAuxClick={() => disableOthers(LogFilter.TX)}
|
||||
onAuxClick={() => disableOthers(LogOrigin.Tx)}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
>
|
||||
{LogFilter.TX}
|
||||
{LogOrigin.Tx}
|
||||
</Button>
|
||||
<Button variant='ontime-outlined' size='xs' onClick={clearLogs}>
|
||||
Clear
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ import style from './SettingsModal.module.scss';
|
||||
|
||||
const userFieldsDocsUrl = 'https://ontime.gitbook.io/v2/features/user-fields';
|
||||
|
||||
export default function CuesheetSettings() {
|
||||
export default function CuesheetSettingsForm() {
|
||||
const { data, status, isFetching, refetch } = useUserFields();
|
||||
const { emitError } = useEmitLog();
|
||||
const {
|
||||
@@ -4,7 +4,7 @@ import ModalWrapper from '../ModalWrapper';
|
||||
|
||||
import AliasesForm from './AliasesForm';
|
||||
import AppSettingsModal from './AppSettings';
|
||||
import CuesheetSettings from './CuesheetSettings';
|
||||
import CuesheetSettingsForm from './CuesheetSettingsForm';
|
||||
import EditorSettings from './EditorSettings';
|
||||
import EventDataForm from './EventDataForm';
|
||||
import ViewSettingsForm from './ViewSettingsForm';
|
||||
@@ -39,7 +39,7 @@ export default function SettingsModal(props: ModalManagerProps) {
|
||||
<EditorSettings />
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<CuesheetSettings />
|
||||
<CuesheetSettingsForm />
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<ViewSettingsForm />
|
||||
|
||||
@@ -203,7 +203,6 @@ export default function Rundown(props: RundownProps) {
|
||||
return <RundownEmpty handleAddNew={() => insertAtCursor(SupportedEvent.Event, null)} />;
|
||||
}
|
||||
|
||||
let cumulativeDelay = 0;
|
||||
let previousEnd = 0;
|
||||
let thisEnd = 0;
|
||||
let previousEventId: string | undefined;
|
||||
@@ -217,14 +216,9 @@ export default function Rundown(props: RundownProps) {
|
||||
<div className={style.list}>
|
||||
{statefulEntries.map((entry, index) => {
|
||||
if (index === 0) {
|
||||
cumulativeDelay = 0;
|
||||
eventIndex = -1;
|
||||
}
|
||||
if (entry.type === SupportedEvent.Delay && entry.duration !== null) {
|
||||
cumulativeDelay += entry.duration;
|
||||
} else if (entry.type === SupportedEvent.Block) {
|
||||
cumulativeDelay = 0;
|
||||
} else if (entry.type === SupportedEvent.Event) {
|
||||
if (entry.type === SupportedEvent.Event) {
|
||||
eventIndex++;
|
||||
previousEnd = thisEnd;
|
||||
thisEnd = entry.timeEnd;
|
||||
@@ -248,7 +242,6 @@ export default function Rundown(props: RundownProps) {
|
||||
selected={isSelected}
|
||||
hasCursor={hasCursor}
|
||||
next={isNext}
|
||||
delay={cumulativeDelay}
|
||||
previousEnd={previousEnd}
|
||||
previousEventId={previousEventId}
|
||||
playback={isSelected ? featureData.playback : undefined}
|
||||
|
||||
@@ -22,7 +22,6 @@ interface RundownEntryProps {
|
||||
selected: boolean;
|
||||
hasCursor: boolean;
|
||||
next: boolean;
|
||||
delay: number;
|
||||
previousEnd: number;
|
||||
previousEventId?: string;
|
||||
playback?: Playback; // we only care about this if this event is playing
|
||||
@@ -38,7 +37,6 @@ export default function RundownEntry(props: RundownEntryProps) {
|
||||
selected,
|
||||
hasCursor,
|
||||
next,
|
||||
delay,
|
||||
previousEnd,
|
||||
previousEventId,
|
||||
playback,
|
||||
@@ -167,7 +165,7 @@ export default function RundownEntry(props: RundownEntryProps) {
|
||||
timerType={data.timerType}
|
||||
title={data.title}
|
||||
note={data.note}
|
||||
delay={delay}
|
||||
delay={data.delay || 0}
|
||||
previousEnd={previousEnd}
|
||||
colour={data.colour}
|
||||
isPast={isPast}
|
||||
|
||||
@@ -1,245 +0,0 @@
|
||||
import { useCallback, useContext, useEffect, useMemo } from 'react';
|
||||
import { useBlockLayout, useColumnOrder, useResizeColumns, useTable } from 'react-table';
|
||||
import { Tooltip } from '@chakra-ui/react';
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
TouchSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import { horizontalListSortingStrategy, SortableContext, sortableKeyboardCoordinates } from '@dnd-kit/sortable';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { TableSettingsContext } from '../../common/context/TableSettingsContext';
|
||||
import { useLocalStorage } from '../../common/hooks/useLocalStorage';
|
||||
import { tooltipDelayFast } from '../../ontimeConfig';
|
||||
|
||||
import SortableCell from './tableElements/SortableCell';
|
||||
import TableSettings from './tableElements/TableSettings';
|
||||
import BlockRow from './tableRows/BlockRow';
|
||||
import DelayRow from './tableRows/DelayRow';
|
||||
import EventRow from './tableRows/EventRow';
|
||||
import { makeColumns } from './columns';
|
||||
import { defaultColumnOrder, defaultHiddenColumns } from './defaults';
|
||||
|
||||
import style from './Table.module.scss';
|
||||
|
||||
export default function OntimeTable({ tableData, userFields, selectedId, handleUpdate }) {
|
||||
const { followSelected, showSettings } = useContext(TableSettingsContext);
|
||||
const [columnOrder, saveColumnOrder] = useLocalStorage('table-order', defaultColumnOrder);
|
||||
const [columnSize, saveColumnSize] = useLocalStorage('table-sizes', {});
|
||||
const [hiddenColumns, saveHiddenColumns] = useLocalStorage('table-hidden', defaultHiddenColumns);
|
||||
const columns = useMemo(() => makeColumns(columnSize, userFields), [columnSize, userFields]);
|
||||
|
||||
const {
|
||||
getTableProps,
|
||||
getTableBodyProps,
|
||||
headerGroups,
|
||||
rows,
|
||||
prepareRow,
|
||||
setColumnOrder,
|
||||
allColumns,
|
||||
setHiddenColumns,
|
||||
toggleHideAllColumns,
|
||||
state,
|
||||
} = useTable(
|
||||
{
|
||||
columns,
|
||||
data: tableData,
|
||||
initialState: {
|
||||
hiddenColumns,
|
||||
},
|
||||
handleUpdate,
|
||||
},
|
||||
useColumnOrder,
|
||||
useBlockLayout,
|
||||
useResizeColumns
|
||||
);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
delay: 100,
|
||||
tolerance: 50,
|
||||
},
|
||||
}),
|
||||
useSensor(TouchSensor, {
|
||||
activationConstraint: {
|
||||
delay: 100,
|
||||
tolerance: 50,
|
||||
},
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
);
|
||||
|
||||
const handleResetReordering = useCallback(() => {
|
||||
saveColumnOrder(defaultColumnOrder);
|
||||
setColumnOrder(defaultColumnOrder);
|
||||
}, [saveColumnOrder, setColumnOrder]);
|
||||
|
||||
const handleResetResizing = useCallback(() => {
|
||||
saveColumnSize({});
|
||||
}, [saveColumnSize]);
|
||||
|
||||
const handleResetToggles = useCallback(() => {
|
||||
setHiddenColumns(defaultHiddenColumns);
|
||||
saveHiddenColumns(defaultHiddenColumns);
|
||||
}, [saveHiddenColumns, setHiddenColumns]);
|
||||
|
||||
const clearToggles = useCallback(() => {
|
||||
toggleHideAllColumns(false);
|
||||
saveHiddenColumns([]);
|
||||
}, [saveHiddenColumns, toggleHideAllColumns]);
|
||||
|
||||
const handleOnDragEnd = useCallback((event) => {
|
||||
const { delta, active, over } = event;
|
||||
|
||||
// cancel if delta y is greater than 200
|
||||
if (delta.y > 200) return;
|
||||
|
||||
const cols = [...columnOrder];
|
||||
|
||||
// get index of from
|
||||
const fromIndex = cols.findIndex((i) => i === active.id);
|
||||
|
||||
// get index of to
|
||||
const toIndex = cols.findIndex((i) => i === over.id);
|
||||
|
||||
if (toIndex === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
// reorder
|
||||
const [reorderedItem] = cols.splice(fromIndex, 1);
|
||||
cols.splice(toIndex, 0, reorderedItem);
|
||||
|
||||
saveColumnOrder(cols);
|
||||
setColumnOrder(cols);
|
||||
}, [columnOrder, saveColumnOrder, setColumnOrder]);
|
||||
|
||||
// save hidden columns object to local storage
|
||||
useEffect(() => {
|
||||
saveHiddenColumns(state.hiddenColumns);
|
||||
}, [saveHiddenColumns, state.hiddenColumns]);
|
||||
|
||||
// save column sizes to local storage
|
||||
useEffect(() => {
|
||||
// property changes from title of column to null on resize end
|
||||
if (state.columnResizing?.isResizingColumn !== null) {
|
||||
return;
|
||||
}
|
||||
const cols = state.columnResizing.columnWidths;
|
||||
saveColumnSize((prev) => ({ ...prev, ...cols }));
|
||||
}, [saveColumnSize, state.columnResizing]);
|
||||
|
||||
// scroll to active cue
|
||||
useEffect(() => {
|
||||
if (followSelected) {
|
||||
const el = document.getElementById(selectedId);
|
||||
if (el) {
|
||||
el.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
inline: 'nearest',
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [followSelected, selectedId]);
|
||||
|
||||
// keep order of events
|
||||
let eventIndex = 0;
|
||||
// keep delay (ms)
|
||||
let cumulativeDelay = 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
{showSettings && (
|
||||
<TableSettings
|
||||
columns={allColumns}
|
||||
handleResetResizing={handleResetResizing}
|
||||
handleResetReordering={handleResetReordering}
|
||||
handleResetToggles={handleResetToggles}
|
||||
handleClearToggles={clearToggles}
|
||||
/>
|
||||
)}
|
||||
<table {...getTableProps()} className={style.ontimeTable}>
|
||||
<thead className={style.tableHeader}>
|
||||
{headerGroups.map((headerGroup) => {
|
||||
const { key, ...restHeaderGroupProps } = headerGroup.getHeaderGroupProps();
|
||||
return (
|
||||
<DndContext
|
||||
key={key}
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleOnDragEnd}
|
||||
>
|
||||
<tr {...restHeaderGroupProps}>
|
||||
<th className={style.indexColumn}>
|
||||
<Tooltip label='Event Order' openDelay={tooltipDelayFast}>
|
||||
#
|
||||
</Tooltip>
|
||||
</th>
|
||||
<SortableContext
|
||||
key={key}
|
||||
items={headerGroup.headers}
|
||||
strategy={horizontalListSortingStrategy}
|
||||
>
|
||||
{headerGroup.headers.map((column) => {
|
||||
const { key } = column.getHeaderProps();
|
||||
return <SortableCell key={key} column={column} />;
|
||||
})}
|
||||
</SortableContext>
|
||||
</tr>
|
||||
</DndContext>
|
||||
);
|
||||
})}
|
||||
</thead>
|
||||
<tbody {...getTableBodyProps} className={style.tableBody}>
|
||||
{/*This is saving in place of a default component*/}
|
||||
{/* eslint-disable-next-line array-callback-return */}
|
||||
{rows.map((row) => {
|
||||
prepareRow(row);
|
||||
const { key } = row.getRowProps();
|
||||
const type = row.original.type;
|
||||
if (type === 'event') {
|
||||
eventIndex++;
|
||||
return (
|
||||
<EventRow
|
||||
key={key}
|
||||
row={row}
|
||||
index={eventIndex}
|
||||
selectedId={selectedId}
|
||||
delay={cumulativeDelay}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (type === 'delay') {
|
||||
if (row.original.duration != null) {
|
||||
cumulativeDelay += row.original.duration;
|
||||
}
|
||||
return <DelayRow key={key} row={row} />;
|
||||
}
|
||||
if (type === 'block') {
|
||||
cumulativeDelay = 0;
|
||||
return <BlockRow key={key} row={row} />;
|
||||
}
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
OntimeTable.propTypes = {
|
||||
tableData: PropTypes.array,
|
||||
userFields: PropTypes.object,
|
||||
handleUpdate: PropTypes.func.isRequired,
|
||||
selectedId: PropTypes.string,
|
||||
showSettings: PropTypes.bool,
|
||||
followSelected: PropTypes.bool,
|
||||
};
|
||||
@@ -1,14 +0,0 @@
|
||||
import ProtectRoute from '../../common/components/protect-route/ProtectRoute';
|
||||
import { TableSettingsProvider } from '../../common/context/TableSettingsContext';
|
||||
|
||||
import TableWrapper from './TableWrapper';
|
||||
|
||||
export default function ProtectedTable() {
|
||||
return (
|
||||
<ProtectRoute permission='operator'>
|
||||
<TableSettingsProvider>
|
||||
<TableWrapper />
|
||||
</TableSettingsProvider>
|
||||
</ProtectRoute>
|
||||
);
|
||||
}
|
||||
@@ -1,310 +0,0 @@
|
||||
@use '../../theme/main' as *;
|
||||
|
||||
.tableWrapper,
|
||||
.tableWrapper__dark {
|
||||
font-family: "Open Sans", sans-serif;
|
||||
font-size: 16px;
|
||||
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
padding: 2rem;
|
||||
display: grid;
|
||||
|
||||
grid-template-columns: calc(100vw - 4rem);
|
||||
grid-template-rows: auto auto 1fr;
|
||||
grid-template-areas:
|
||||
'header'
|
||||
'settings'
|
||||
'table';
|
||||
gap: 1rem;
|
||||
overflow: scroll;
|
||||
|
||||
& > * {
|
||||
width: 100%;
|
||||
border: 1px solid $ontime-pink;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.header {
|
||||
grid-area: header;
|
||||
display: grid;
|
||||
height: max-content;
|
||||
grid-template-areas:
|
||||
'name playback running time actions'
|
||||
'now playback running time actions';
|
||||
grid-template-columns: 1fr auto 10em 12.5em auto;
|
||||
align-items: center;
|
||||
padding: 0.25em 1em;
|
||||
|
||||
.headerName {
|
||||
grid-area: name;
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
.headerName:after {
|
||||
content: '\200b';
|
||||
}
|
||||
|
||||
.headerNow {
|
||||
grid-area: now;
|
||||
font-size: 1.25em;
|
||||
}
|
||||
|
||||
.headerNow:after {
|
||||
content: '\200b';
|
||||
}
|
||||
|
||||
.headerPlayback {
|
||||
grid-area: playback;
|
||||
color: $ontime-pink;
|
||||
text-align: center;
|
||||
|
||||
svg {
|
||||
font-size: 2em;
|
||||
}
|
||||
}
|
||||
|
||||
.headerRunning {
|
||||
grid-area: running;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.headerClock {
|
||||
grid-area: time;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.headerActions {
|
||||
grid-area: actions;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
font-size: 1.5em;
|
||||
padding-left: 10vw;
|
||||
color: darken($ontime-pink, 8%);
|
||||
}
|
||||
}
|
||||
|
||||
.tableSettings {
|
||||
grid-area: settings;
|
||||
padding: 1rem;
|
||||
|
||||
.options,
|
||||
.buttonRow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
flex-direction: row;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.options {
|
||||
padding-left: 0.5em;
|
||||
}
|
||||
|
||||
.buttonRow {
|
||||
padding-top: 2em;
|
||||
}
|
||||
}
|
||||
|
||||
.ontimeTable {
|
||||
grid-area: table;
|
||||
|
||||
border-collapse: separate;
|
||||
border-spacing: 16px;
|
||||
padding: 1rem;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
|
||||
th, td {
|
||||
touch-action: auto;
|
||||
padding: 4px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
td {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.tableHeader {
|
||||
position: sticky;
|
||||
top: -1em;
|
||||
z-index: 10;
|
||||
|
||||
th {
|
||||
font-size: 0.9em;
|
||||
font-weight: 200;
|
||||
text-align: left;
|
||||
|
||||
.resizer {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
transform: translateX(50%);
|
||||
z-index: 1;
|
||||
touch-action: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tableBody {
|
||||
tr {
|
||||
td:hover {
|
||||
background-color: lighten($ontime-pink, 10%) !important;
|
||||
color: black;
|
||||
}
|
||||
}
|
||||
|
||||
.selected > td {
|
||||
background-color: rgba($ontime-accent, 0.8);
|
||||
}
|
||||
}
|
||||
|
||||
.indexColumn {
|
||||
font-weight: 200;
|
||||
text-align: right;
|
||||
width: 2.5em;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.blockCell,
|
||||
.delayCell {
|
||||
width: 100%;
|
||||
font-weight: 400;
|
||||
font-size: 0.9em;
|
||||
text-align: center;
|
||||
color: black;
|
||||
}
|
||||
|
||||
.delayCell {
|
||||
background-color: $block-delay-color;
|
||||
}
|
||||
|
||||
.blockCell {
|
||||
background-color: $block-block-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$bg-theme-light: #fcfcfc;
|
||||
$cell-theme-light: #ececec;
|
||||
$text-theme-light: #202020;
|
||||
$bg-theme-dark: #121212;
|
||||
$bg2-theme-dark: #1c1c1c;
|
||||
$cell-theme-dark: #2d2d2d;
|
||||
$text-theme-dark: white;
|
||||
|
||||
.tableWrapper {
|
||||
background-color: $bg-theme-light;
|
||||
color: black;
|
||||
|
||||
* {
|
||||
background-color: $bg-theme-light;
|
||||
scrollbar-color: $ontime-pink rgba(0, 0, 0, 0.13);
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar {
|
||||
background-color: rgba(0, 0, 0, 0.07);
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background-color: lighten($ontime-pink, 15%);
|
||||
}
|
||||
|
||||
td {
|
||||
background-color: $cell-theme-light;
|
||||
border: 1px solid $bg-theme-light;
|
||||
color: #121212;
|
||||
}
|
||||
.actionText:hover,
|
||||
.actionIcon:hover,
|
||||
.actionDisabled:hover {
|
||||
color: black;
|
||||
transition: 300ms;
|
||||
}
|
||||
}
|
||||
|
||||
.tableWrapper__dark {
|
||||
background-color: $bg-theme-dark;
|
||||
color: white;
|
||||
|
||||
* {
|
||||
background-color: $bg-theme-dark;
|
||||
scrollbar-color: $ontime-pink rgba(255, 255, 255, 0.13);
|
||||
}
|
||||
|
||||
td {
|
||||
background-color: $cell-theme-dark;
|
||||
border: 1px solid $bg-theme-dark;
|
||||
color: #ececec;
|
||||
}
|
||||
.actionText:hover,
|
||||
.actionIcon:hover,
|
||||
.actionDisabled:hover {
|
||||
color: white;
|
||||
transition: 300ms;
|
||||
}
|
||||
}
|
||||
|
||||
.timer {
|
||||
font-size: 1.7em;
|
||||
letter-spacing: 1px;
|
||||
line-height: 1.2em;
|
||||
font-weight: 200;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: $ontime-pink;
|
||||
font-size: 0.8em;
|
||||
font-weight: 200;
|
||||
line-height: 0.75em;
|
||||
}
|
||||
|
||||
svg {
|
||||
background-color: inherit !important;
|
||||
}
|
||||
|
||||
@mixin action-element() {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.actionIcon {
|
||||
@include action-element();
|
||||
}
|
||||
|
||||
.actionText {
|
||||
@include action-element();
|
||||
font-size: 0.65em;
|
||||
}
|
||||
|
||||
.actionDisabled {
|
||||
@include action-element();
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.dragging {
|
||||
border: 1px solid $ontime-pink;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.check {
|
||||
font-size: 1.5em;
|
||||
background-color: transparent !important;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
@keyframes rotation {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
import { useContext } from 'react';
|
||||
import { Divider, Tooltip } from '@chakra-ui/react';
|
||||
import { FiSettings } from '@react-icons/all-files/fi/FiSettings';
|
||||
import { FiTarget } from '@react-icons/all-files/fi/FiTarget';
|
||||
import { IoContract } from '@react-icons/all-files/io5/IoContract';
|
||||
import { IoExpand } from '@react-icons/all-files/io5/IoExpand';
|
||||
import { IoMoon } from '@react-icons/all-files/io5/IoMoon';
|
||||
import { formatDisplay } from 'ontime-utils';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { TableSettingsContext } from '../../common/context/TableSettingsContext';
|
||||
import useFullscreen from '../../common/hooks/useFullscreen';
|
||||
import { useTimer } from '../../common/hooks/useSocket';
|
||||
import useEventData from '../../common/hooks-query/useEventData';
|
||||
import { formatTime } from '../../common/utils/time';
|
||||
import { tooltipDelayFast } from '../../ontimeConfig';
|
||||
|
||||
import PlaybackIcon from './tableElements/PlaybackIcon';
|
||||
|
||||
import style from './Table.module.scss';
|
||||
|
||||
export default function TableHeader({ handleCSVExport, featureData }) {
|
||||
const { followSelected, showSettings, toggleTheme, toggleSettings, toggleFollow } = useContext(TableSettingsContext);
|
||||
const timer = useTimer();
|
||||
const { isFullScreen, toggleFullScreen } = useFullscreen();
|
||||
const { data: event } = useEventData();
|
||||
|
||||
const selected = !featureData.numEvents
|
||||
? 'No events'
|
||||
: `Event ${featureData.selectedEventIndex != null ? featureData.selectedEventIndex + 1 : '-'}/${
|
||||
featureData.numEvents ? featureData.numEvents : '-'
|
||||
}`;
|
||||
|
||||
// prepare presentation variables
|
||||
const isOvertime = timer.current < 0;
|
||||
const timerNow = `${isOvertime ? '-' : ''}${formatDisplay(timer.current)}`;
|
||||
const timeNow = formatTime(timer.clock, {
|
||||
showSeconds: true,
|
||||
format: 'hh:mm:ss a',
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={style.header}>
|
||||
<div className={style.headerName}>{event?.title || ''}</div>
|
||||
<div className={style.headerNow}>{featureData.titleNow}</div>
|
||||
<div className={style.headerPlayback}>
|
||||
<span className={style.label}>{selected}</span>
|
||||
<br />
|
||||
<PlaybackIcon state={featureData.playback} />
|
||||
</div>
|
||||
<div className={style.headerRunning}>
|
||||
<span className={style.label}>Running Timer</span>
|
||||
<br />
|
||||
<span className={style.timer}>{timerNow}</span>
|
||||
</div>
|
||||
<div className={style.headerClock}>
|
||||
<span className={style.label}>Time Now</span>
|
||||
<br />
|
||||
<span className={style.timer}>{timeNow}</span>
|
||||
</div>
|
||||
<div className={style.headerActions}>
|
||||
<Tooltip openDelay={tooltipDelayFast} label='Follow selected'>
|
||||
<span className={followSelected ? style.actionIcon : style.actionDisabled}>
|
||||
<FiTarget onClick={() => toggleFollow()} />
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip openDelay={tooltipDelayFast} label='Show settings'>
|
||||
<span className={showSettings ? style.actionIcon : style.actionDisabled}>
|
||||
<FiSettings onClick={() => toggleSettings()} />
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip openDelay={tooltipDelayFast} label='Toggle dark mode'>
|
||||
<span className={style.actionIcon}>
|
||||
<IoMoon onClick={() => toggleTheme()} />
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip openDelay={tooltipDelayFast} label='Toggle Fullscreen'>
|
||||
<span className={style.actionIcon}>
|
||||
{isFullScreen ? (
|
||||
<IoContract onClick={() => toggleFullScreen()} />
|
||||
) : (
|
||||
<IoExpand onClick={() => toggleFullScreen()} />
|
||||
)}
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Divider />
|
||||
<Tooltip openDelay={tooltipDelayFast} label='Export to CSV'>
|
||||
<span className={style.actionText} onClick={() => handleCSVExport(event)}>
|
||||
CSV
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
TableHeader.propTypes = {
|
||||
handleCSVExport: PropTypes.func.isRequired,
|
||||
featureData: PropTypes.shape({
|
||||
playback: PropTypes.string,
|
||||
selectedEventId: PropTypes.string,
|
||||
selectedEventIndex: PropTypes.number,
|
||||
numEvents: PropTypes.number,
|
||||
titleNow: PropTypes.string,
|
||||
}),
|
||||
};
|
||||
@@ -1,104 +0,0 @@
|
||||
import { FiCheck } from '@react-icons/all-files/fi/FiCheck';
|
||||
|
||||
import EditableCell from './tableElements/EditableCell';
|
||||
|
||||
import style from './Table.module.scss';
|
||||
import { millisToString } from 'ontime-utils';
|
||||
|
||||
/**
|
||||
* React - Table column object
|
||||
* @param sizes
|
||||
* @param userFields
|
||||
*/
|
||||
export const makeColumns = (sizes, userFields) => {
|
||||
return [
|
||||
{
|
||||
Header: 'Public',
|
||||
accessor: 'isPublic',
|
||||
Cell: ({ cell: { value } }) => (value ? <FiCheck className={style.check} /> : ''),
|
||||
width: sizes?.isPublic || 50,
|
||||
},
|
||||
{
|
||||
Header: 'Start',
|
||||
accessor: 'timeStart',
|
||||
Cell: ({ cell: { value, delayed } }) => millisToString(delayed || value),
|
||||
width: sizes?.timeStart || 90,
|
||||
},
|
||||
{
|
||||
Header: 'End',
|
||||
accessor: 'timeEnd',
|
||||
Cell: ({ cell: { value, delayed } }) => millisToString(delayed || value),
|
||||
width: sizes?.timeEnd || 90,
|
||||
},
|
||||
{
|
||||
Header: 'Duration',
|
||||
accessor: 'duration',
|
||||
Cell: ({ cell: { value } }) => millisToString(value),
|
||||
width: sizes?.duration || 90,
|
||||
},
|
||||
{ Header: 'Title', accessor: 'title', width: sizes?.title || 400 },
|
||||
{ Header: 'Subtitle', accessor: 'subtitle', width: sizes?.subtitle || 350 },
|
||||
{ Header: 'Presenter', accessor: 'presenter', width: sizes?.presenter || 250 },
|
||||
{ Header: 'Notes', accessor: 'note', width: sizes?.note || 500 },
|
||||
{
|
||||
Header: userFields.user0 || 'User 0',
|
||||
accessor: 'user0',
|
||||
Cell: EditableCell,
|
||||
width: sizes?.user0 || 200,
|
||||
},
|
||||
{
|
||||
Header: userFields.user1 || 'User 1',
|
||||
accessor: 'user1',
|
||||
Cell: EditableCell,
|
||||
width: sizes?.user1 || 200,
|
||||
},
|
||||
{
|
||||
Header: userFields.user2 || 'User 2',
|
||||
accessor: 'user2',
|
||||
Cell: EditableCell,
|
||||
width: sizes?.user2 || 200,
|
||||
},
|
||||
{
|
||||
Header: userFields.user3 || 'User 3',
|
||||
accessor: 'user3',
|
||||
Cell: EditableCell,
|
||||
width: sizes?.user3 || 200,
|
||||
},
|
||||
{
|
||||
Header: userFields.user4 || 'User 4',
|
||||
accessor: 'user4',
|
||||
Cell: EditableCell,
|
||||
width: sizes?.user4 || 200,
|
||||
},
|
||||
{
|
||||
Header: userFields.user5 || 'User 5',
|
||||
accessor: 'user5',
|
||||
Cell: EditableCell,
|
||||
width: sizes?.user5 || 200,
|
||||
},
|
||||
{
|
||||
Header: userFields.user6 || 'User 6',
|
||||
accessor: 'user6',
|
||||
Cell: EditableCell,
|
||||
width: sizes?.user6 || 200,
|
||||
},
|
||||
{
|
||||
Header: userFields.user7 || 'User 7',
|
||||
accessor: 'user7',
|
||||
Cell: EditableCell,
|
||||
width: sizes?.user7 || 200,
|
||||
},
|
||||
{
|
||||
Header: userFields.user8 || 'User 8',
|
||||
accessor: 'user8',
|
||||
Cell: EditableCell,
|
||||
width: sizes?.user8 || 200,
|
||||
},
|
||||
{
|
||||
Header: userFields.user9 || 'User 9',
|
||||
accessor: 'user9',
|
||||
Cell: EditableCell,
|
||||
width: sizes?.user9 || 200,
|
||||
},
|
||||
];
|
||||
};
|
||||
@@ -1,56 +0,0 @@
|
||||
import { useCallback, useContext, useEffect, useState } from 'react';
|
||||
import { AutoTextArea } from '@/common/components/input/auto-text-area/AutoTextArea';
|
||||
import { TableSettingsContext } from '@/common/context/TableSettingsContext';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
/**
|
||||
* Shamelessly copied from react-table docs
|
||||
* Plugged into chakra-ui editable component
|
||||
* @description Custom editable field for table component
|
||||
* @param props
|
||||
* @return {JSX.Element}
|
||||
* @constructor
|
||||
*/
|
||||
export default function EditableCell(props) {
|
||||
const {
|
||||
value: initialValue,
|
||||
row: { index },
|
||||
column: { id },
|
||||
handleUpdate,
|
||||
} = props;
|
||||
const { theme } = useContext(TableSettingsContext);
|
||||
|
||||
// We need to keep and update the state of the cell normally
|
||||
const [value, setValue] = useState(initialValue);
|
||||
|
||||
const onChange = useCallback((e) => setValue(e.target.value), []);
|
||||
|
||||
// We'll only update the external data when the input is blurred
|
||||
const onBlur = useCallback(() => handleUpdate(index, id, value), [handleUpdate, id, index, value]);
|
||||
|
||||
|
||||
// If the initialValue is changed external, sync it up with our state
|
||||
useEffect(() => {
|
||||
setValue(initialValue);
|
||||
}, [initialValue]);
|
||||
|
||||
return (
|
||||
<AutoTextArea
|
||||
size='sm'
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onBlur={onBlur}
|
||||
rows={3}
|
||||
transition='none'
|
||||
spellCheck={false}
|
||||
isDark={theme === "dark"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
EditableCell.propTypes = {
|
||||
value: PropTypes.string,
|
||||
row: PropTypes.object,
|
||||
column: PropTypes.object,
|
||||
handleUpdate: PropTypes.func,
|
||||
};
|
||||
@@ -1,50 +0,0 @@
|
||||
import { Tooltip } from '@chakra-ui/react';
|
||||
import { IoPause } from '@react-icons/all-files/io5/IoPause';
|
||||
import { IoPlay } from '@react-icons/all-files/io5/IoPlay';
|
||||
import { IoStop } from '@react-icons/all-files/io5/IoStop';
|
||||
import { IoTimeOutline } from '@react-icons/all-files/io5/IoTimeOutline';
|
||||
import { Playback } from 'ontime-types';
|
||||
|
||||
import { tooltipDelayFast } from '../../../ontimeConfig';
|
||||
|
||||
interface PlaybackIconProps {
|
||||
state: Playback;
|
||||
}
|
||||
|
||||
export default function PlaybackIcon(props: PlaybackIconProps) {
|
||||
const { state } = props;
|
||||
|
||||
if (state === Playback.Stop) {
|
||||
return (
|
||||
<Tooltip openDelay={tooltipDelayFast} label='Timer Stopped' shouldWrapChildren>
|
||||
<IoStop />
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
if (state === Playback.Play) {
|
||||
return (
|
||||
<Tooltip openDelay={tooltipDelayFast} label='Timer Playing' shouldWrapChildren>
|
||||
<IoPlay />
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
if (state === Playback.Pause) {
|
||||
return (
|
||||
<Tooltip openDelay={tooltipDelayFast} label='Timer Paused' shouldWrapChildren>
|
||||
<IoPause />
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
if (state === Playback.Roll) {
|
||||
return (
|
||||
<Tooltip openDelay={tooltipDelayFast} label='Timer Rolling' shouldWrapChildren>
|
||||
<IoTimeOutline />
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { Tooltip } from '@chakra-ui/react';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { tooltipDelayFast } from '../../../ontimeConfig';
|
||||
|
||||
import styles from '../Table.module.scss';
|
||||
|
||||
export default function SortableCell({ column }) {
|
||||
const { style, ...restColumn } = column.getHeaderProps();
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: column.id,
|
||||
});
|
||||
|
||||
// build drag styles
|
||||
const dragStyle = {
|
||||
...style,
|
||||
transform: CSS.Translate.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
return (
|
||||
<th {...restColumn} ref={setNodeRef} style={dragStyle} className={isDragging ? styles.dragging : ''}>
|
||||
<div {...attributes} {...listeners}>
|
||||
<Tooltip label={column.Header} openDelay={tooltipDelayFast}>
|
||||
{column.render('Header')}
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div {...column.getResizerProps()} className={styles.resizer} />
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
||||
SortableCell.propTypes = {
|
||||
column: PropTypes.object.isRequired,
|
||||
};
|
||||
@@ -1,56 +0,0 @@
|
||||
import { Button } from '@chakra-ui/react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import style from '../Table.module.scss';
|
||||
|
||||
// reusable button styles
|
||||
const buttonProps = {
|
||||
colorScheme: 'blue',
|
||||
size: 'sm',
|
||||
variant: 'ghost',
|
||||
};
|
||||
|
||||
export default function TableSettings(props) {
|
||||
const {
|
||||
columns,
|
||||
handleResetResizing,
|
||||
handleResetReordering,
|
||||
handleResetToggles,
|
||||
handleClearToggles,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<div className={style.tableSettings}>
|
||||
<div className={style.hSeparator}>Select and order fields to show in table</div>
|
||||
<div className={style.options}>
|
||||
{columns.map((column) => (
|
||||
<label key={column.id}>
|
||||
<input type='checkbox' {...column.getToggleHiddenProps()} /> {column.Header}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div className={style.buttonRow}>
|
||||
<Button onClick={handleResetResizing} {...buttonProps}>
|
||||
Reset Resizing
|
||||
</Button>
|
||||
<Button onClick={handleResetReordering} {...buttonProps}>
|
||||
Reset Reordering
|
||||
</Button>
|
||||
<Button onClick={handleResetToggles} {...buttonProps}>
|
||||
Reset Toggles
|
||||
</Button>
|
||||
<Button onClick={handleClearToggles} {...buttonProps}>
|
||||
Show All
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
TableSettings.propTypes = {
|
||||
columns: PropTypes.array,
|
||||
handleResetResizing: PropTypes.func.isRequired,
|
||||
handleResetReordering: PropTypes.func.isRequired,
|
||||
handleResetToggles: PropTypes.func.isRequired,
|
||||
handleClearToggles: PropTypes.func.isRequired,
|
||||
};
|
||||
@@ -1,16 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import style from '../Table.module.scss';
|
||||
|
||||
export default function BlockRow(props) {
|
||||
const { row } = props;
|
||||
return (
|
||||
<tr {...row.getRowProps()}>
|
||||
<td className={style.blockCell}>{row.original?.title || 'Block'}</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
BlockRow.propTypes = {
|
||||
row: PropTypes.object.isRequired,
|
||||
};
|
||||
@@ -1,21 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { millisToDelayString } from '../../../common/utils/dateConfig';
|
||||
|
||||
import style from '../Table.module.scss';
|
||||
|
||||
export default function DelayRow(props) {
|
||||
const { row } = props;
|
||||
const delayVal = row.original.duration;
|
||||
const delayTime = delayVal !== 0 ? millisToDelayString(delayVal) : null;
|
||||
|
||||
return (
|
||||
<tr {...row.getRowProps()}>
|
||||
<td className={style.delayCell}>{delayTime}</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
DelayRow.propTypes = {
|
||||
row: PropTypes.object.isRequired,
|
||||
};
|
||||
@@ -1,46 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { getAccessibleColour } from '../../../common/utils/styleUtils';
|
||||
|
||||
import style from '../Table.module.scss';
|
||||
|
||||
export default function EventRow(props) {
|
||||
const { row, index, selectedId, delay } = props;
|
||||
const selected = row.original.id === selectedId;
|
||||
|
||||
const colours = row.original.colour
|
||||
? getAccessibleColour(row.original.colour)
|
||||
: {};
|
||||
|
||||
return (
|
||||
<tr {...row.getRowProps()} className={selected ? style.selected : ''} id={row.original.id}>
|
||||
<td className={style.indexColumn}>{index}</td>
|
||||
{row.cells.map((cell) => {
|
||||
const { key, style, ...restCellProps } = cell.getCellProps();
|
||||
const dynamicStyles = { ...style, ...colours };
|
||||
|
||||
|
||||
// Inject delay value if exits
|
||||
if (delay !== 0 && delay != null) {
|
||||
const col = cell.column.Header;
|
||||
if (col === 'End' || col === 'Start') {
|
||||
cell.delayed = cell.value + delay;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<td key={key} style={{ ...dynamicStyles }} {...restCellProps}>
|
||||
{cell.render('Cell')}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
EventRow.propTypes = {
|
||||
row: PropTypes.object.isRequired,
|
||||
index: PropTypes.number.isRequired,
|
||||
selectedId: PropTypes.string,
|
||||
delay: PropTypes.number,
|
||||
};
|
||||
+27
-19
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { formatDisplay, millisToString } from 'ontime-utils';
|
||||
import PropTypes from 'prop-types';
|
||||
import type { OntimeRundown, ViewSettings } from 'ontime-types';
|
||||
import { formatDisplay } from 'ontime-utils';
|
||||
|
||||
import { overrideStylesURL } from '../../../common/api/apiConstants';
|
||||
import NavigationMenu from '../../../common/components/navigation-menu/NavigationMenu';
|
||||
@@ -9,8 +9,16 @@ import { STUDIO_CLOCK_OPTIONS } from '../../../common/components/view-params-edi
|
||||
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
|
||||
import useFitText from '../../../common/hooks/useFitText';
|
||||
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
|
||||
import { formatEventList, getEventsWithDelay, trimRundown } from '../../../common/utils/eventsManager';
|
||||
import { TimeManagerType } from '../../../common/models/TimeManager.type';
|
||||
import { secondsInMillis } from '../../../common/utils/dateConfig';
|
||||
import {
|
||||
formatEventList,
|
||||
getEventsWithDelay,
|
||||
type ScheduleEvent,
|
||||
trimRundown,
|
||||
} from '../../../common/utils/eventsManager';
|
||||
import { formatTime } from '../../../common/utils/time';
|
||||
import { TitleManager } from '../ViewWrapper';
|
||||
|
||||
import './StudioClock.scss';
|
||||
|
||||
@@ -19,25 +27,25 @@ const formatOptions = {
|
||||
format: 'hh:mm',
|
||||
};
|
||||
|
||||
StudioClock.propTypes = {
|
||||
isMirrored: PropTypes.bool,
|
||||
title: PropTypes.object,
|
||||
time: PropTypes.object,
|
||||
backstageEvents: PropTypes.array,
|
||||
selectedId: PropTypes.string,
|
||||
nextId: PropTypes.string,
|
||||
onAir: PropTypes.bool,
|
||||
viewSettings: PropTypes.object,
|
||||
};
|
||||
interface StudioClockProps {
|
||||
isMirrored: boolean;
|
||||
title: TitleManager;
|
||||
time: TimeManagerType;
|
||||
backstageEvents: OntimeRundown;
|
||||
selectedId: string | null;
|
||||
nextId: string | null;
|
||||
onAir: boolean;
|
||||
viewSettings: ViewSettings;
|
||||
}
|
||||
|
||||
export default function StudioClock(props) {
|
||||
export default function StudioClock(props: StudioClockProps) {
|
||||
const { isMirrored, title, time, backstageEvents, selectedId, nextId, onAir, viewSettings } = props;
|
||||
|
||||
// deferring rendering seems to affect styling (font and useFitText)
|
||||
useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
|
||||
const { fontSize: titleFontSize, ref: titleRef } = useFitText({ maxFontSize: 500 });
|
||||
|
||||
const [schedule, setSchedule] = useState([]);
|
||||
const [schedule, setSchedule] = useState<ScheduleEvent[]>([]);
|
||||
|
||||
const activeIndicators = [...Array(12).keys()];
|
||||
const secondsIndicators = [...Array(60).keys()];
|
||||
@@ -59,16 +67,16 @@ export default function StudioClock(props) {
|
||||
}
|
||||
|
||||
const delayed = getEventsWithDelay(backstageEvents);
|
||||
const events = delayed.filter((e) => e.type === 'event');
|
||||
const trimmed = trimRundown(events, selectedId, MAX_TITLES);
|
||||
const formatted = formatEventList(trimmed, selectedId, nextId, {
|
||||
const trimmed = trimRundown(delayed, selectedId || '', MAX_TITLES);
|
||||
|
||||
const formatted = formatEventList(trimmed, selectedId || '', nextId || '', {
|
||||
showEnd: false,
|
||||
});
|
||||
setSchedule(formatted);
|
||||
}, [backstageEvents, nextId, selectedId]);
|
||||
|
||||
const clock = formatTime(time.clock, formatOptions);
|
||||
const [, , secondsNow] = millisToString(time.clock).split(':');
|
||||
const secondsNow = secondsInMillis(time.clock);
|
||||
const isNegative = (time.current ?? 0) < 0;
|
||||
|
||||
return (
|
||||
@@ -30,7 +30,6 @@ html,
|
||||
}
|
||||
|
||||
@media (min-width: 1450px) and (max-width: 1666px) {
|
||||
|
||||
body,
|
||||
html,
|
||||
.App {
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
@use "./ontimeColours" as *;
|
||||
|
||||
$transition-time-action: 0.1s;
|
||||
$transition-time-feedback: 0.3s;
|
||||
|
||||
//////////////////////////////////// general app colours
|
||||
|
||||
$bg-black-gradient: #202020;
|
||||
$bg-black: #121212;
|
||||
$bg-black-100: #070707;
|
||||
$bg-black-200: #1a1a1a; // container borders
|
||||
$bg-black-300: #1f1f1f; // container text
|
||||
$bg-gray-1100: #232323; // container borders
|
||||
$bg-gray-1050: #242424; // container borders
|
||||
$bg-gray-1000: #262626; // container borders
|
||||
$bg-gray-950: #292929;
|
||||
$bg-gray-900: #303030;
|
||||
$bg-gray-800: #404040;
|
||||
$bg-gray-700: #505050;
|
||||
$bg-gray-500: #666666;
|
||||
$bg-gray-100: #c0c0c0; // borders and whatnot
|
||||
|
||||
$bg-overlay: rgba(0, 0, 0, 0.85);
|
||||
|
||||
// $ontime-accent: #4bffab);
|
||||
$ontime-accent: #58A151;
|
||||
$ontime-accent-text: mix($bg-black, $ontime-accent, 10%);
|
||||
$ontime-pink: #ff7597;
|
||||
$ontime-pink-variant: #ff6969;
|
||||
$ontime-roll: #0274B6;
|
||||
$ontime-delay: #F57C13;
|
||||
$action-blue: #3182ce;
|
||||
$ontime-paused: #c05621;
|
||||
$opacity-disabled: 0.4;
|
||||
$ontime-red: #E4281E;
|
||||
|
||||
//rgba(255, 255, 255, 0.39); - $bg-gray-700
|
||||
//rgba(255, 255, 255, 0.13); - $bg-gray-900
|
||||
//rgba(255, 255, 255, 0.05); - $bg-gray-1000
|
||||
//rgba(255, 255, 255, 0.07) - $bg-gray-1000
|
||||
// text input bg -
|
||||
// was rgba(255, 255, 255, 0.03)
|
||||
// container level 1 - $bg-gray-1000
|
||||
// container level 2 - $bg-gray-1100
|
||||
// container level 2 border - rgba(0, 0, 0, 0.05)
|
||||
// indent in level 2 - $bg-black-300
|
||||
// outdent in level2 - $bg-gray-950
|
||||
//////////////////////////////////// editor
|
||||
|
||||
$notes-color: #f6f6f6;
|
||||
|
||||
$text-white: #fffffa;
|
||||
$text-gray-disabled: #505050;
|
||||
$label-gray: #aaa;
|
||||
$header-gray: $label-gray;
|
||||
$clocks: #ddd;
|
||||
$bg-gray: #f4f4f8;
|
||||
$text-delay: #F57C13;
|
||||
|
||||
$light-bg: #2b6cb0;
|
||||
$light-bg-transparent: #2b6cb055;
|
||||
$light-text: #2b6cb022;
|
||||
|
||||
$info-gray: #aaa;
|
||||
$info-gray-hover: #ddd;
|
||||
$warning-orange: #dd6b20;
|
||||
$error-red: #e53e3e;
|
||||
|
||||
//////////////////////////////////// viewers
|
||||
$title-white: #fffd;
|
||||
|
||||
//////////////////////////////////// block elements
|
||||
$bg-container-over: #0b1521;
|
||||
$bg-container-over-l1: #132337;
|
||||
$bg-container-l1: #202020;
|
||||
$bg-container-l2: #232323;
|
||||
$bg-container-l3: #2b2b2b;
|
||||
$border-l1: 1px solid $bg-gray-1000;
|
||||
$border-l3: 1px solid $bg-gray-900;
|
||||
|
||||
$block-delay-color: #E2720D;
|
||||
$delay-text: #d69e2e;
|
||||
$block-delay-border: #d69e2e55;
|
||||
$block-block-color: #7347AD;
|
||||
$block-border: 1px solid $bg-gray-1100;
|
||||
|
||||
//////////////////////////////////// utils
|
||||
|
||||
@mixin ellipsis {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
//////////////////////////////////// custom inputs
|
||||
$input-bg: rgba(255, 255, 255, 0.03);
|
||||
$input-hover-bg: rgba(255, 255, 255, 0.13);
|
||||
$input-bg-delayed: rgba(214, 158, 46, 0.07);
|
||||
$input-hover-bg-delayed: rgba(214, 158, 46, 0.17);
|
||||
$input-border: 1px solid transparent;
|
||||
$input-delayed-border: 1px solid $block-delay-border;
|
||||
|
||||
//////////////////////////////////// general app element overriders
|
||||
|
||||
// no decoration on lists
|
||||
ul {
|
||||
list-style-type: none;
|
||||
}
|
||||
|
||||
// no resizing on text areas
|
||||
textarea {
|
||||
resize: none !important;
|
||||
}
|
||||
|
||||
// Define style for a link
|
||||
a:hover {
|
||||
color: $ontime-pink;
|
||||
}
|
||||
|
||||
// horizontal separator
|
||||
.hSeparator {
|
||||
width: 100%;
|
||||
border-bottom: 1px solid $light-bg;
|
||||
margin: 1em auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
// inline vertical separator
|
||||
.vSpan {
|
||||
margin: 0 0.5em;
|
||||
}
|
||||
@@ -51,8 +51,8 @@ $ontime-font-family: "Open Sans", "Segoe UI", sans-serif;
|
||||
$label-gray: $gray-400;
|
||||
$secondary-text-gray: $gray-400;
|
||||
$section-white: $ui-white;
|
||||
$inner-section-text-size: 14px;
|
||||
$text-body-size: 15px;
|
||||
$inner-section-text-size: calc(1rem - 2px);
|
||||
$text-body-size: calc(1rem - 1px);
|
||||
|
||||
.blink {
|
||||
animation: blink $blinking-time linear infinite;
|
||||
|
||||
@@ -36,6 +36,13 @@ export const ontimeInputFilledOnLight = {
|
||||
export const ontimeTextAreaFilled = {
|
||||
...commonStyles,
|
||||
};
|
||||
export const ontimeTextAreaTransparent = {
|
||||
...commonStyles,
|
||||
backgroundColor: 'transparent',
|
||||
_hover: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.10)', // $white-10
|
||||
},
|
||||
};
|
||||
|
||||
export const ontimeTextAreaFilledOnLight = {
|
||||
borderRadius: '3px',
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
ontimeInputFilledOnLight,
|
||||
ontimeTextAreaFilled,
|
||||
ontimeTextAreaFilledOnLight,
|
||||
ontimeTextAreaTransparent,
|
||||
} from './ontimeTextInputs';
|
||||
import { ontimeTooltip } from './ontimeTooltip';
|
||||
|
||||
@@ -96,6 +97,7 @@ const theme = extendTheme({
|
||||
},
|
||||
variants: {
|
||||
'ontime-filled': { ...ontimeTextAreaFilled },
|
||||
'ontime-transparent': { ...ontimeTextAreaTransparent },
|
||||
'ontime-filled-on-light': { ...ontimeTextAreaFilledOnLight },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
"setdb": "shx cp ../../demo-db/db.json src/preloaded-db/db.json",
|
||||
"postinstall": "pnpm addversion && pnpm setdb",
|
||||
"dev": "cross-env NODE_ENV=development nodemon --exec \"ts-node-esm\" ./src/index.ts",
|
||||
"dev:inspect": "cross-env NODE_ENV=development nodemon --exec \"node --inspect --loader ts-node/esm\" ./src/index.ts",
|
||||
"dev:test": "cross-env IS_TEST=true nodemon --exec \"ts-node-esm\" ./src/index.ts",
|
||||
"prebuild": "pnpm setdb",
|
||||
"build": "pnpm prebuild && esbuild src/app.ts --log-level=error --platform=node --format=cjs --bundle --minify --outfile=dist/index.cjs",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { LogOrigin, OSCSettings } from 'ontime-types';
|
||||
|
||||
import { Server } from 'node-osc';
|
||||
import { OSCSettings } from 'ontime-types';
|
||||
|
||||
import { IAdapter } from './IAdapter.js';
|
||||
import { dispatchFromAdapter } from '../controllers/integrationController.js';
|
||||
@@ -25,13 +26,13 @@ export class OscServer implements IAdapter {
|
||||
|
||||
// get first part before (ontime)
|
||||
if (address !== 'ontime') {
|
||||
logger.error('RX', `OSC IN: OSC messages to ontime must start with /ontime/, received: ${msg}`);
|
||||
logger.error(LogOrigin.Rx, `OSC IN: OSC messages to ontime must start with /ontime/, received: ${msg}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// get second part (command)
|
||||
if (!path) {
|
||||
logger.error('RX', 'OSC IN: No path found');
|
||||
logger.error(LogOrigin.Rx, 'OSC IN: No path found');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -42,7 +43,7 @@ export class OscServer implements IAdapter {
|
||||
this.osc.emit(topic, payload);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('RX', `OSC IN: ${error}`);
|
||||
logger.error(LogOrigin.Rx, `OSC IN: ${error}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
* Payload: adds necessary payload for the request to be completed
|
||||
*/
|
||||
|
||||
import { LogOrigin } from 'ontime-types';
|
||||
|
||||
import { WebSocket, WebSocketServer } from 'ws';
|
||||
|
||||
import getRandomName from '../utils/getRandomName.js';
|
||||
@@ -47,7 +49,7 @@ export class SocketServer implements IAdapter {
|
||||
this.wss.on('connection', (ws) => {
|
||||
let clientId = getRandomName();
|
||||
this.clientIds.add(clientId);
|
||||
logger.info('CLIENT', `${this.wss.clients.size} Connections with new: ${clientId}`);
|
||||
logger.info(LogOrigin.Client, `${this.wss.clients.size} Connections with new: ${clientId}`);
|
||||
|
||||
// send store payload on connect
|
||||
ws.send(
|
||||
@@ -67,7 +69,7 @@ export class SocketServer implements IAdapter {
|
||||
ws.on('error', console.error);
|
||||
|
||||
ws.on('close', () => {
|
||||
logger.info('CLIENT', `${this.wss.clients.size} Connections with disconnected: ${clientId}`);
|
||||
logger.info(LogOrigin.Client, `${this.wss.clients.size} Connections with disconnected: ${clientId}`);
|
||||
this.clientIds.delete(clientId);
|
||||
});
|
||||
|
||||
@@ -96,7 +98,7 @@ export class SocketServer implements IAdapter {
|
||||
clientId = payload;
|
||||
this.clientIds.delete(previousName);
|
||||
this.clientIds.add(clientId);
|
||||
logger.info('CLIENT', `Client ${previousName} renamed to ${clientId}`);
|
||||
logger.info(LogOrigin.Client, `Client ${previousName} renamed to ${clientId}`);
|
||||
}
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
@@ -127,7 +129,7 @@ export class SocketServer implements IAdapter {
|
||||
ws.send(topic, payload);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('RX', `WS IN: ${error}`);
|
||||
logger.error(LogOrigin.Rx, `WS IN: ${error}`);
|
||||
}
|
||||
} catch (_) {
|
||||
// we ignore unknown
|
||||
|
||||
@@ -9,7 +9,7 @@ import { join, resolve } from 'path';
|
||||
|
||||
import { currentDirectory, environment, externalsStartDirectory, isProduction, resolvedPath } from './setup.js';
|
||||
import { ONTIME_VERSION } from './ONTIME_VERSION.js';
|
||||
import { OSCSettings } from 'ontime-types';
|
||||
import { LogOrigin, OSCSettings } from 'ontime-types';
|
||||
|
||||
// Import Routes
|
||||
import { router as rundownRouter } from './routes/rundownRouter.js';
|
||||
@@ -159,7 +159,7 @@ export const startOSCServer = async (overrideConfig = null) => {
|
||||
const { osc } = DataProvider.getData();
|
||||
|
||||
if (!osc.enabledIn) {
|
||||
logger.info('RX', 'OSC Input Disabled');
|
||||
logger.info(LogOrigin.Rx, 'OSC Input Disabled');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -170,7 +170,7 @@ export const startOSCServer = async (overrideConfig = null) => {
|
||||
};
|
||||
|
||||
// Start OSC Server
|
||||
logger.info('RX', `Starting OSC Server on port: ${oscSettings.portIn}`);
|
||||
logger.info(LogOrigin.Rx, `Starting OSC Server on port: ${oscSettings.portIn}`);
|
||||
oscServer = new OscServer(oscSettings);
|
||||
};
|
||||
|
||||
@@ -187,7 +187,7 @@ export const startIntegrations = async (config?: { osc: OSCSettings }) => {
|
||||
}
|
||||
|
||||
const { success, message } = oscIntegration.init(osc);
|
||||
logger.info('RX', message);
|
||||
logger.info(LogOrigin.Rx, message);
|
||||
|
||||
if (success) {
|
||||
integrationService.register(oscIntegration);
|
||||
@@ -214,12 +214,12 @@ export const shutdown = async (exitCode = 0) => {
|
||||
process.on('exit', (code) => console.log(`Ontime exited with code: ${code}`));
|
||||
|
||||
process.on('unhandledRejection', async (error) => {
|
||||
logger.error('SERVER', `Error: unhandled rejection ${error}`);
|
||||
logger.error(LogOrigin.Server, `Error: unhandled rejection ${error}`);
|
||||
await shutdown(1);
|
||||
});
|
||||
|
||||
process.on('uncaughtException', async (error) => {
|
||||
logger.error('SERVER', `Error: uncaught exception ${error}`);
|
||||
logger.error(LogOrigin.Server, `Error: uncaught exception ${error}`);
|
||||
await shutdown(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Class Event Provider is a mediator for handling the local db
|
||||
* and adds logic specific to ontime data
|
||||
*/
|
||||
import { EventData, SupportedEvent, ViewSettings } from 'ontime-types';
|
||||
import { EventData, ViewSettings } from 'ontime-types';
|
||||
|
||||
import { data, db } from '../../modules/loadDb.js';
|
||||
import { safeMerge } from './DataProvider.utils.js';
|
||||
@@ -27,31 +27,14 @@ export class DataProvider {
|
||||
await this.persist();
|
||||
}
|
||||
|
||||
static getIndexOf(eventId) {
|
||||
return data.rundown.findIndex((e) => e.id === eventId);
|
||||
}
|
||||
|
||||
static getEventById(eventId) {
|
||||
return data.rundown.find((e) => e.id === eventId);
|
||||
}
|
||||
|
||||
static async updateEventById(eventId, newData) {
|
||||
const eventIndex = data.rundown.findIndex((e) => e.id === eventId);
|
||||
const persistedEvent = data.rundown[eventIndex];
|
||||
const newEvent = { ...persistedEvent, ...newData };
|
||||
if (newEvent.type === SupportedEvent.Event) {
|
||||
newEvent.revision++;
|
||||
}
|
||||
data.rundown[eventIndex] = newEvent;
|
||||
await this.persist();
|
||||
return data.rundown[eventIndex];
|
||||
}
|
||||
|
||||
static async deleteEvent(eventId) {
|
||||
const eventIndex = data.rundown.findIndex((e) => e.id === eventId);
|
||||
|
||||
if (eventIndex !== -1) {
|
||||
data.rundown.splice(eventIndex, 1);
|
||||
await this.persist();
|
||||
}
|
||||
}
|
||||
|
||||
static getRundownLength() {
|
||||
return data.rundown.length;
|
||||
}
|
||||
@@ -62,53 +45,6 @@ export class DataProvider {
|
||||
await db.write();
|
||||
}
|
||||
|
||||
/**
|
||||
* Insets an event after a given index
|
||||
* @param entry
|
||||
* @param index
|
||||
* @return {Promise<void>}
|
||||
*/
|
||||
static async insertEventAt(entry, index) {
|
||||
// get events
|
||||
const events = DataProvider.getRundown();
|
||||
const count = events.length;
|
||||
const order = entry.order;
|
||||
|
||||
// Remove order field from object
|
||||
delete entry.order;
|
||||
|
||||
// Insert at beginning
|
||||
if (order === 0) {
|
||||
events.unshift(entry);
|
||||
}
|
||||
|
||||
// insert at end
|
||||
else if (order >= count) {
|
||||
events.push(entry);
|
||||
}
|
||||
|
||||
// insert in the middle
|
||||
else {
|
||||
events.splice(index, 0, entry);
|
||||
}
|
||||
|
||||
// save events
|
||||
await DataProvider.setRundown(events);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Inserts an entry after an element with given ID
|
||||
* @param entry
|
||||
* @param id
|
||||
* @return {Promise<void>}
|
||||
*/
|
||||
static async insertEventAfterId(entry, id) {
|
||||
const index = [...data.rundown].findIndex((event) => event.id === id);
|
||||
// eslint-disable-next-line no-unused-vars,@typescript-eslint/no-unused-vars -- we are just getting rid of after parameter
|
||||
const { after, ...sanitisedEvent } = entry;
|
||||
await DataProvider.insertEventAt(sanitisedEvent, index + 1);
|
||||
}
|
||||
|
||||
static getSettings() {
|
||||
return data.settings;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Loaded, OntimeEvent, TitleBlock } from 'ontime-types';
|
||||
import { Loaded, OntimeEvent, SupportedEvent, TitleBlock } from 'ontime-types';
|
||||
|
||||
import { DataProvider } from '../data-provider/DataProvider.js';
|
||||
import { getRollTimers } from '../../services/rollUtils.js';
|
||||
@@ -34,8 +34,7 @@ export class EventLoader {
|
||||
* @return {array}
|
||||
*/
|
||||
static getTimedEvents(): OntimeEvent[] {
|
||||
// return mockLoaderData.filter((event) => event.type === 'event');
|
||||
return DataProvider.getRundown().filter((event) => event.type === 'event');
|
||||
return DataProvider.getRundown().filter((event) => event.type === SupportedEvent.Event) as OntimeEvent[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -43,8 +42,9 @@ export class EventLoader {
|
||||
* @return {array}
|
||||
*/
|
||||
static getPlayableEvents(): OntimeEvent[] {
|
||||
// return mockLoaderData.filter((event) => event.type === 'event' && !event.skip);
|
||||
return DataProvider.getRundown().filter((event) => event.type === 'event' && !event.skip);
|
||||
return DataProvider.getRundown().filter(
|
||||
(event) => event.type === SupportedEvent.Event && !event.skip,
|
||||
) as OntimeEvent[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Alias, EventData, LogOrigin } from 'ontime-types';
|
||||
|
||||
import fs from 'fs';
|
||||
import type { Alias, EventData } from 'ontime-types';
|
||||
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';
|
||||
@@ -10,7 +12,7 @@ import { eventStore } from '../stores/EventStore.js';
|
||||
import { resolveDbPath } from '../setup.js';
|
||||
import { oscIntegration } from '../services/integration-service/OscIntegration.js';
|
||||
import { logger } from '../classes/Logger.js';
|
||||
import { deleteAllEvents, forceReset } from '../services/RundownService.js';
|
||||
import { deleteAllEvents, forceReset } from '../services/rundown-service/RundownService.js';
|
||||
|
||||
// Create controller for GET request to '/ontime/poll'
|
||||
// Returns data for current state
|
||||
@@ -281,7 +283,7 @@ export const postOscSubscriptions = async (req, res) => {
|
||||
|
||||
// TODO: this update could be more granular, checking that relevant data was changed
|
||||
const { message } = oscIntegration.init(oscSettings);
|
||||
logger.info('RX', message);
|
||||
logger.info(LogOrigin.Rx, message);
|
||||
|
||||
res.send(oscSettings).status(200);
|
||||
} catch (error) {
|
||||
@@ -302,7 +304,7 @@ export const postOSC = async (req, res) => {
|
||||
|
||||
// TODO: this update could be more granular, checking that relevant data was changed
|
||||
const { message } = oscIntegration.init(oscSettings);
|
||||
logger.info('RX', message);
|
||||
logger.info(LogOrigin.Rx, message);
|
||||
|
||||
res.send(oscSettings).status(200);
|
||||
} catch (error) {
|
||||
|
||||
+5
-9
@@ -1,4 +1,4 @@
|
||||
import { DataProvider } from '../classes/data-provider/DataProvider.ts';
|
||||
import { OntimeEvent } from 'ontime-types';
|
||||
import { failEmptyObjects } from '../utils/routerUtils.js';
|
||||
import {
|
||||
addEvent,
|
||||
@@ -7,18 +7,14 @@ import {
|
||||
deleteEvent,
|
||||
editEvent,
|
||||
reorderEvent,
|
||||
} from '../services/RundownService.ts';
|
||||
} from '../services/rundown-service/RundownService.js';
|
||||
import { getDelayedRundown } from '../services/rundown-service/delayedRundown.utils.js';
|
||||
|
||||
// Create controller for GET request to '/events'
|
||||
// Returns -
|
||||
export const rundownGetAll = async (req, res) => {
|
||||
res.json(DataProvider.getRundown());
|
||||
};
|
||||
|
||||
// Create controller for GET request to '/events/:eventId'
|
||||
// Returns -
|
||||
export const getEventById = async (req, res) => {
|
||||
res.json(DataProvider.getEventById(req.params?.eventId));
|
||||
const delayedRundown = getDelayedRundown();
|
||||
res.json(delayedRundown);
|
||||
};
|
||||
|
||||
// Create controller for POST request to '/events/'
|
||||
@@ -1,6 +1,6 @@
|
||||
import { EndAction, OntimeBlock, OntimeDelay, OntimeEvent, SupportedEvent, TimerType } from 'ontime-types';
|
||||
|
||||
export const event: Omit<OntimeEvent, 'id'> = {
|
||||
export const event: Omit<OntimeEvent, 'id' | 'delay'> = {
|
||||
title: '',
|
||||
subtitle: '',
|
||||
presenter: '',
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import express from 'express';
|
||||
import {
|
||||
deleteEventById,
|
||||
getEventById,
|
||||
rundownApplyDelay,
|
||||
rundownDelete,
|
||||
rundownGetAll,
|
||||
@@ -21,9 +20,6 @@ export const router = express.Router();
|
||||
// create route between controller and '/events/' endpoint
|
||||
router.get('/', rundownGetAll);
|
||||
|
||||
// create route between controller and '/events/:eventId' endpoint
|
||||
router.get('/:eventId', paramsMustHaveEventId, getEventById);
|
||||
|
||||
// create route between controller and '/events/' endpoint
|
||||
router.post('/', rundownPostValidator, rundownPost);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { OntimeEvent } from 'ontime-types';
|
||||
import { LogOrigin, OntimeEvent, Playback } from 'ontime-types';
|
||||
import { validatePlayback } from 'ontime-utils';
|
||||
|
||||
import { eventLoader, EventLoader } from '../classes/event-loader/EventLoader.js';
|
||||
@@ -20,9 +20,9 @@ export class PlaybackService {
|
||||
static loadEvent(event: OntimeEvent): boolean {
|
||||
let success = false;
|
||||
if (!event) {
|
||||
logger.error('PLAYBACK', 'No event found');
|
||||
logger.error(LogOrigin.Playback, 'No event found');
|
||||
} else if (event.skip) {
|
||||
logger.warning('PLAYBACK', `Refused playback of skipped event ID ${event.id}`);
|
||||
logger.warning(LogOrigin.Playback, `Refused playback of skipped event ID ${event.id}`);
|
||||
} else {
|
||||
eventLoader.loadEvent(event);
|
||||
eventTimer.load(event);
|
||||
@@ -41,7 +41,7 @@ export class PlaybackService {
|
||||
const event = EventLoader.getEventWithId(eventId);
|
||||
const success = PlaybackService.loadEvent(event);
|
||||
if (success) {
|
||||
logger.info('PLAYBACK', `Loaded event with ID ${event.id}`);
|
||||
logger.info(LogOrigin.Playback, `Loaded event with ID ${event.id}`);
|
||||
PlaybackService.start();
|
||||
}
|
||||
return success;
|
||||
@@ -56,7 +56,7 @@ export class PlaybackService {
|
||||
const event = EventLoader.getEventAtIndex(eventIndex);
|
||||
const success = PlaybackService.loadEvent(event);
|
||||
if (success) {
|
||||
logger.info('PLAYBACK', `Loaded event with ID ${event.id}`);
|
||||
logger.info(LogOrigin.Playback, `Loaded event with ID ${event.id}`);
|
||||
PlaybackService.start();
|
||||
}
|
||||
return success;
|
||||
@@ -71,7 +71,7 @@ export class PlaybackService {
|
||||
const event = EventLoader.getEventWithId(eventId);
|
||||
const success = PlaybackService.loadEvent(event);
|
||||
if (success) {
|
||||
logger.info('PLAYBACK', `Loaded event with ID ${event.id}`);
|
||||
logger.info(LogOrigin.Playback, `Loaded event with ID ${event.id}`);
|
||||
}
|
||||
return success;
|
||||
}
|
||||
@@ -85,7 +85,7 @@ export class PlaybackService {
|
||||
const event = EventLoader.getEventAtIndex(eventIndex);
|
||||
const success = PlaybackService.loadEvent(event);
|
||||
if (success) {
|
||||
logger.info('PLAYBACK', `Loaded event with ID ${event.id}`);
|
||||
logger.info(LogOrigin.Playback, `Loaded event with ID ${event.id}`);
|
||||
}
|
||||
return success;
|
||||
}
|
||||
@@ -98,7 +98,7 @@ export class PlaybackService {
|
||||
if (previousEvent) {
|
||||
const success = PlaybackService.loadEvent(previousEvent);
|
||||
if (success) {
|
||||
logger.info('PLAYBACK', `Loaded event with ID ${previousEvent.id}`);
|
||||
logger.info(LogOrigin.Playback, `Loaded event with ID ${previousEvent.id}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -113,19 +113,19 @@ export class PlaybackService {
|
||||
if (nextEvent) {
|
||||
const success = PlaybackService.loadEvent(nextEvent);
|
||||
if (success) {
|
||||
logger.info('PLAYBACK', `Loaded event with ID ${nextEvent.id}`);
|
||||
logger.info(LogOrigin.Playback, `Loaded event with ID ${nextEvent.id}`);
|
||||
return true;
|
||||
}
|
||||
} else if (fallbackAction === 'stop') {
|
||||
logger.info('PLAYBACK', 'No next event found! Stopping playback');
|
||||
logger.info(LogOrigin.Playback, 'No next event found! Stopping playback');
|
||||
PlaybackService.stop();
|
||||
return false;
|
||||
} else if (fallbackAction === 'pause') {
|
||||
logger.info('PLAYBACK', 'No next event found! Pausing playback');
|
||||
logger.info(LogOrigin.Playback, 'No next event found! Pausing playback');
|
||||
PlaybackService.pause();
|
||||
return false;
|
||||
} else {
|
||||
logger.info('PLAYBACK', 'No next event found! Continuing playback');
|
||||
logger.info(LogOrigin.Playback, 'No next event found! Continuing playback');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -137,7 +137,7 @@ export class PlaybackService {
|
||||
if (validatePlayback(eventTimer.playback).start) {
|
||||
eventTimer.start();
|
||||
const newState = eventTimer.playback;
|
||||
logger.info('PLAYBACK', `Play Mode ${newState.toUpperCase()}`);
|
||||
logger.info(LogOrigin.Playback, `Play Mode ${newState.toUpperCase()}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,7 +159,7 @@ export class PlaybackService {
|
||||
if (validatePlayback(eventTimer.playback).pause) {
|
||||
eventTimer.pause();
|
||||
const newState = eventTimer.playback;
|
||||
logger.info('PLAYBACK', `Play Mode ${newState.toUpperCase()}`);
|
||||
logger.info(LogOrigin.Playback, `Play Mode ${newState.toUpperCase()}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,7 +171,7 @@ export class PlaybackService {
|
||||
eventLoader.reset();
|
||||
eventTimer.stop();
|
||||
const newState = eventTimer.playback;
|
||||
logger.info('PLAYBACK', `Play Mode ${newState.toUpperCase()}`);
|
||||
logger.info(LogOrigin.Playback, `Play Mode ${newState.toUpperCase()}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,14 +193,14 @@ export class PlaybackService {
|
||||
|
||||
// nothing to play
|
||||
if (rollTimers === null) {
|
||||
logger.warning('SERVER', 'Roll: no events found');
|
||||
logger.warning(LogOrigin.Server, 'Roll: no events found');
|
||||
PlaybackService.stop();
|
||||
return;
|
||||
}
|
||||
|
||||
const { currentEvent, nextEvent } = rollTimers;
|
||||
if (!currentEvent && !nextEvent) {
|
||||
logger.warning('SERVER', 'Roll: no events found');
|
||||
logger.warning(LogOrigin.Server, 'Roll: no events found');
|
||||
PlaybackService.stop();
|
||||
return;
|
||||
}
|
||||
@@ -208,7 +208,7 @@ export class PlaybackService {
|
||||
eventTimer.roll(currentEvent, nextEvent);
|
||||
|
||||
const newState = eventTimer.playback;
|
||||
logger.info('PLAYBACK', `Play Mode ${newState.toUpperCase()}`);
|
||||
logger.info(LogOrigin.Playback, `Play Mode ${newState.toUpperCase()}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,8 +221,8 @@ export class PlaybackService {
|
||||
const delayInMs = delayTime * 1000 * 60;
|
||||
eventTimer.delay(delayInMs);
|
||||
delayInMs > 0
|
||||
? logger.info('PLAYBACK', `Added ${delayTime} min delay`)
|
||||
: logger.info('PLAYBACK', `Removed ${delayTime} min delay`);
|
||||
? logger.info(LogOrigin.Playback, `Added ${delayTime} min delay`)
|
||||
: logger.info(LogOrigin.Playback, `Removed ${delayTime} min delay`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+125
-81
@@ -1,11 +1,30 @@
|
||||
import { OntimeBaseEvent, OntimeBlock, OntimeDelay, OntimeEvent, SupportedEvent } from 'ontime-types';
|
||||
import {
|
||||
LogOrigin,
|
||||
OntimeBaseEvent,
|
||||
OntimeBlock,
|
||||
OntimeDelay,
|
||||
OntimeEvent,
|
||||
OntimeRundown,
|
||||
SupportedEvent,
|
||||
} from 'ontime-types';
|
||||
import { generateId } from 'ontime-utils';
|
||||
import { DataProvider } from '../classes/data-provider/DataProvider.js';
|
||||
import { block as blockDef, delay as delayDef, event as eventDef } from '../models/eventsDefinition.js';
|
||||
import { MAX_EVENTS } from '../settings.js';
|
||||
import { EventLoader, eventLoader } from '../classes/event-loader/EventLoader.js';
|
||||
import { eventTimer } from './TimerService.js';
|
||||
import { sendRefetch } from '../adapters/websocketAux.js';
|
||||
import { DataProvider } from '../../classes/data-provider/DataProvider.js';
|
||||
import { block as blockDef, delay, delay as delayDef, event as eventDef } from '../../models/eventsDefinition.js';
|
||||
import { MAX_EVENTS } from '../../settings.js';
|
||||
import { EventLoader, eventLoader } from '../../classes/event-loader/EventLoader.js';
|
||||
import { eventTimer } from '../TimerService.js';
|
||||
import { sendRefetch } from '../../adapters/websocketAux.js';
|
||||
import { runtimeCacheStore } from '../../stores/cachingStore.js';
|
||||
import {
|
||||
cachedAdd,
|
||||
cachedDelete,
|
||||
cachedEdit,
|
||||
cachedReorder,
|
||||
calculateRuntimeDelaysFrom,
|
||||
delayedRundownCacheKey,
|
||||
getDelayedRundown,
|
||||
} from './delayedRundown.utils.js';
|
||||
import { logger } from '../../classes/Logger.js';
|
||||
|
||||
/**
|
||||
* Forces rundown to be recalculated
|
||||
@@ -14,6 +33,7 @@ import { sendRefetch } from '../adapters/websocketAux.js';
|
||||
export function forceReset() {
|
||||
eventLoader.reset();
|
||||
sendRefetch();
|
||||
runtimeCacheStore.invalidate(delayedRundownCacheKey);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -73,7 +93,7 @@ const isNewNext = () => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates timer object
|
||||
* Updates timer service when a relevant piece of data changes
|
||||
*/
|
||||
export function updateTimer(affectedIds?: string[]) {
|
||||
const runningEventId = eventLoader.loaded.selectedEventId;
|
||||
@@ -130,44 +150,54 @@ export async function addEvent(eventData: Partial<OntimeEvent> | Partial<OntimeD
|
||||
let newEvent: Partial<OntimeBaseEvent> = {};
|
||||
const id = generateId();
|
||||
|
||||
// TODO: filter the parameters that exist in the event, use the parserUtils
|
||||
switch (eventData.type) {
|
||||
case 'event':
|
||||
newEvent = { ...eventDef, ...eventData, id } as Partial<OntimeEvent>;
|
||||
case SupportedEvent.Event:
|
||||
newEvent = { ...eventDef, ...eventData, id };
|
||||
break;
|
||||
case 'delay':
|
||||
newEvent = { ...delayDef, ...eventData, id } as Partial<OntimeDelay>;
|
||||
case SupportedEvent.Delay:
|
||||
newEvent = { ...delayDef, ...eventData, id };
|
||||
break;
|
||||
case 'block':
|
||||
newEvent = { ...blockDef, ...eventData, id } as Partial<OntimeBlock>;
|
||||
case SupportedEvent.Block:
|
||||
newEvent = { ...blockDef, ...eventData, id };
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
const afterId = newEvent?.after;
|
||||
if (typeof afterId === 'undefined') {
|
||||
await DataProvider.insertEventAt(newEvent, 0);
|
||||
let insertIndex = 0;
|
||||
if (typeof newEvent?.after !== 'undefined') {
|
||||
const index = DataProvider.getIndexOf(newEvent.after);
|
||||
if (index < 0) {
|
||||
logger.warning(LogOrigin.Server, `Could not find event with id ${newEvent.after}`);
|
||||
} else {
|
||||
delete newEvent.after;
|
||||
await DataProvider.insertEventAfterId(newEvent, afterId);
|
||||
insertIndex = index + 1;
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(error);
|
||||
delete newEvent.after;
|
||||
}
|
||||
|
||||
// modify rundown
|
||||
await cachedAdd(insertIndex, newEvent as OntimeEvent | OntimeDelay | OntimeBlock);
|
||||
|
||||
// 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) {
|
||||
const eventId = eventData.id;
|
||||
const eventInMemory = DataProvider.getEventById(eventId);
|
||||
if (typeof eventInMemory === 'undefined') {
|
||||
throw new Error('No event with ID found');
|
||||
}
|
||||
const newEvent = await DataProvider.updateEventById(eventId, eventData);
|
||||
updateTimer([eventId]);
|
||||
export async function editEvent(eventData: Partial<OntimeEvent> | Partial<OntimeBlock> | Partial<OntimeDelay>) {
|
||||
const newEvent = await cachedEdit(eventData.id, eventData);
|
||||
|
||||
// notify timer service of changed events
|
||||
updateTimer([newEvent.id]);
|
||||
|
||||
// advice socket subscribers of change
|
||||
sendRefetch();
|
||||
|
||||
return newEvent;
|
||||
}
|
||||
|
||||
@@ -177,9 +207,18 @@ export async function editEvent(eventData) {
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function deleteEvent(eventId) {
|
||||
await DataProvider.deleteEvent(eventId);
|
||||
await cachedDelete(eventId);
|
||||
|
||||
// notify timer service of changed events
|
||||
updateTimer([eventId]);
|
||||
|
||||
// notify event loader that rundown size has changed
|
||||
updateChangeNumEvents();
|
||||
|
||||
// invalidate cache
|
||||
runtimeCacheStore.invalidate(delayedRundownCacheKey);
|
||||
|
||||
// advice socket subscribers of change
|
||||
sendRefetch();
|
||||
}
|
||||
|
||||
@@ -190,78 +229,83 @@ export async function deleteEvent(eventId) {
|
||||
export async function deleteAllEvents() {
|
||||
await DataProvider.clearRundown();
|
||||
updateTimer();
|
||||
updateChangeNumEvents();
|
||||
sendRefetch();
|
||||
forceReset();
|
||||
}
|
||||
|
||||
/**
|
||||
* reorders a given event
|
||||
* @param {string} eventId
|
||||
* @param {number} from
|
||||
* @param {number} to
|
||||
* @param {string} eventId - ID of event from, for sanity check
|
||||
* @param {number} from - index of event from
|
||||
* @param {number} to - index of event to
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function reorderEvent(eventId, from, to) {
|
||||
const rundown = DataProvider.getRundown();
|
||||
const index = rundown.findIndex((event) => event.id === eventId);
|
||||
export async function reorderEvent(eventId: string, from: number, to: number) {
|
||||
const reorderedItem = await cachedReorder(eventId, from, to);
|
||||
|
||||
if (index !== from) {
|
||||
throw new Error('ID not found at index');
|
||||
}
|
||||
const [reorderedItem] = rundown.splice(from, 1);
|
||||
|
||||
// reinsert item at to
|
||||
rundown.splice(to, 0, reorderedItem);
|
||||
|
||||
// save rundown
|
||||
await DataProvider.setRundown(rundown);
|
||||
// notify timer service of changed events
|
||||
updateTimer();
|
||||
|
||||
// advice socket subscribers of change
|
||||
sendRefetch();
|
||||
return reorderedItem;
|
||||
}
|
||||
|
||||
export function _applyDelay(
|
||||
eventId: string,
|
||||
rundown: OntimeRundown,
|
||||
): {
|
||||
delayIndex: number | null;
|
||||
updatedRundown: OntimeRundown;
|
||||
} {
|
||||
const updatedRundown = [...rundown];
|
||||
let delayIndex = null;
|
||||
let delayValue = 0;
|
||||
|
||||
for (const [index, event] of updatedRundown.entries()) {
|
||||
// look for delay
|
||||
if (delayIndex === null) {
|
||||
if (event.type === SupportedEvent.Delay && event.id === eventId) {
|
||||
delayValue = event.duration;
|
||||
delayIndex = index;
|
||||
|
||||
if (delayValue === 0) {
|
||||
// nothing to apply
|
||||
break;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// once delay is found, apply delay value to all items until block or end
|
||||
if (event.type === SupportedEvent.Event) {
|
||||
updatedRundown[index] = {
|
||||
...event,
|
||||
timeStart: Math.max(0, event.timeStart + delayValue),
|
||||
timeEnd: Math.max(event.duration, event.timeEnd + delayValue),
|
||||
revision: event.revision + 1,
|
||||
};
|
||||
} else if (event.type === SupportedEvent.Block) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { delayIndex, updatedRundown };
|
||||
}
|
||||
|
||||
/**
|
||||
* applies delay value for given event
|
||||
* @param eventId
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function applyDelay(eventId: string) {
|
||||
const rundown = DataProvider.getRundown();
|
||||
let delayIndex = null;
|
||||
let delayValue = 0;
|
||||
|
||||
for (const [index, event] of rundown.entries()) {
|
||||
// look for delay
|
||||
if (delayIndex === null) {
|
||||
if (event.id === eventId && event.type === SupportedEvent.Delay) {
|
||||
delayValue = event.duration;
|
||||
delayIndex = index;
|
||||
}
|
||||
}
|
||||
|
||||
// apply delay value to all items until block or end
|
||||
else {
|
||||
if (event.type === SupportedEvent.Event) {
|
||||
event.timeStart = Math.max(0, event.timeStart + delayValue);
|
||||
event.timeEnd = Math.max(event.duration, event.timeStart + delayValue);
|
||||
event.revision += 1;
|
||||
} else if (event.type === SupportedEvent.Block) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rundown: OntimeRundown = DataProvider.getRundown();
|
||||
const { delayIndex, updatedRundown } = _applyDelay(eventId, rundown);
|
||||
if (delayIndex === null) {
|
||||
throw new Error(`Delay event with ID ${eventId} not found`);
|
||||
}
|
||||
|
||||
// delete delay
|
||||
rundown.splice(delayIndex, 1);
|
||||
|
||||
// update rundown
|
||||
await DataProvider.setRundown(rundown);
|
||||
updateTimer();
|
||||
sendRefetch();
|
||||
await DataProvider.setRundown(updatedRundown);
|
||||
await deleteEvent(eventId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -0,0 +1,319 @@
|
||||
import { EndAction, OntimeRundown, SupportedEvent, TimerType } from 'ontime-types';
|
||||
import { _applyDelay } from '../RundownService.js';
|
||||
|
||||
describe('applyDelay()', () => {
|
||||
it('applies its duration to following events', () => {
|
||||
const rundown: OntimeRundown = [
|
||||
{
|
||||
title: '',
|
||||
subtitle: '',
|
||||
presenter: '',
|
||||
note: '',
|
||||
endAction: EndAction.None,
|
||||
timerType: TimerType.CountDown,
|
||||
timeStart: 600000,
|
||||
timeEnd: 1200000,
|
||||
duration: 600000,
|
||||
isPublic: true,
|
||||
skip: false,
|
||||
colour: '',
|
||||
user0: '',
|
||||
user1: '',
|
||||
user2: '',
|
||||
user3: '',
|
||||
user4: '',
|
||||
user5: '',
|
||||
user6: '',
|
||||
user7: '',
|
||||
user8: '',
|
||||
user9: '',
|
||||
type: SupportedEvent.Event,
|
||||
revision: 4,
|
||||
id: '659e1',
|
||||
},
|
||||
{
|
||||
duration: 600000,
|
||||
type: SupportedEvent.Delay,
|
||||
revision: 0,
|
||||
id: '07986',
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
subtitle: '',
|
||||
presenter: '',
|
||||
note: '',
|
||||
endAction: EndAction.None,
|
||||
timerType: TimerType.CountDown,
|
||||
timeStart: 600000,
|
||||
timeEnd: 1200000,
|
||||
duration: 600000,
|
||||
isPublic: true,
|
||||
skip: false,
|
||||
colour: '',
|
||||
user0: '',
|
||||
user1: '',
|
||||
user2: '',
|
||||
user3: '',
|
||||
user4: '',
|
||||
user5: '',
|
||||
user6: '',
|
||||
user7: '',
|
||||
user8: '',
|
||||
user9: '',
|
||||
type: SupportedEvent.Event,
|
||||
revision: 4,
|
||||
id: 'd48c2',
|
||||
},
|
||||
];
|
||||
|
||||
const eventId = rundown[1].id;
|
||||
const { delayIndex, updatedRundown } = _applyDelay(eventId, rundown);
|
||||
|
||||
expect(delayIndex).toBe(1);
|
||||
// we do not delay delays anymore
|
||||
expect(updatedRundown.length).toBe(3);
|
||||
expect(rundown.length).toBe(3);
|
||||
expect(updatedRundown[0].timeStart).toBe(rundown[0].timeStart);
|
||||
expect(updatedRundown[2].timeStart).toBe(rundown[1].duration + rundown[2].timeStart);
|
||||
expect(updatedRundown[2].timeEnd).toBe(rundown[1].duration + rundown[2].timeEnd);
|
||||
});
|
||||
it('stops propagating on blocks', () => {
|
||||
const rundown: OntimeRundown = [
|
||||
{
|
||||
title: '',
|
||||
subtitle: '',
|
||||
presenter: '',
|
||||
note: '',
|
||||
endAction: EndAction.None,
|
||||
timerType: TimerType.CountDown,
|
||||
timeStart: 600000,
|
||||
timeEnd: 1200000,
|
||||
duration: 600000,
|
||||
isPublic: true,
|
||||
skip: false,
|
||||
colour: '',
|
||||
user0: '',
|
||||
user1: '',
|
||||
user2: '',
|
||||
user3: '',
|
||||
user4: '',
|
||||
user5: '',
|
||||
user6: '',
|
||||
user7: '',
|
||||
user8: '',
|
||||
user9: '',
|
||||
type: SupportedEvent.Event,
|
||||
revision: 0,
|
||||
id: '659e1',
|
||||
},
|
||||
{
|
||||
duration: 600000,
|
||||
type: SupportedEvent.Delay,
|
||||
revision: 0,
|
||||
id: '07986',
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
subtitle: '',
|
||||
presenter: '',
|
||||
note: '',
|
||||
endAction: EndAction.None,
|
||||
timerType: TimerType.CountDown,
|
||||
timeStart: 600000,
|
||||
timeEnd: 1200000,
|
||||
duration: 600000,
|
||||
isPublic: true,
|
||||
skip: false,
|
||||
colour: '',
|
||||
user0: '',
|
||||
user1: '',
|
||||
user2: '',
|
||||
user3: '',
|
||||
user4: '',
|
||||
user5: '',
|
||||
user6: '',
|
||||
user7: '',
|
||||
user8: '',
|
||||
user9: '',
|
||||
type: SupportedEvent.Event,
|
||||
revision: 0,
|
||||
id: 'd48c2',
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
type: SupportedEvent.Block,
|
||||
id: '9870d',
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
subtitle: '',
|
||||
presenter: '',
|
||||
note: '',
|
||||
endAction: EndAction.None,
|
||||
timerType: TimerType.CountDown,
|
||||
timeStart: 1200000,
|
||||
timeEnd: 1800000,
|
||||
duration: 600000,
|
||||
isPublic: true,
|
||||
skip: false,
|
||||
colour: '',
|
||||
user0: '',
|
||||
user1: '',
|
||||
user2: '',
|
||||
user3: '',
|
||||
user4: '',
|
||||
user5: '',
|
||||
user6: '',
|
||||
user7: '',
|
||||
user8: '',
|
||||
user9: '',
|
||||
type: SupportedEvent.Event,
|
||||
revision: 2,
|
||||
id: '2f185',
|
||||
},
|
||||
];
|
||||
|
||||
const eventId = rundown[1].id;
|
||||
const { updatedRundown } = _applyDelay(eventId, rundown);
|
||||
|
||||
expect(updatedRundown[0].timeStart).toBe(rundown[0].timeStart);
|
||||
expect(updatedRundown[2].timeStart).toBe(rundown[1].duration + rundown[2].timeStart);
|
||||
expect(updatedRundown[4].timeStart).toBe(rundown[4].timeStart);
|
||||
});
|
||||
it('only applies given delay', () => {
|
||||
const rundown: OntimeRundown = [
|
||||
{
|
||||
title: '',
|
||||
subtitle: '',
|
||||
presenter: '',
|
||||
note: '',
|
||||
endAction: EndAction.None,
|
||||
timerType: TimerType.CountDown,
|
||||
timeStart: 600000,
|
||||
timeEnd: 1200000,
|
||||
duration: 600000,
|
||||
isPublic: true,
|
||||
skip: false,
|
||||
colour: '',
|
||||
user0: '',
|
||||
user1: '',
|
||||
user2: '',
|
||||
user3: '',
|
||||
user4: '',
|
||||
user5: '',
|
||||
user6: '',
|
||||
user7: '',
|
||||
user8: '',
|
||||
user9: '',
|
||||
type: SupportedEvent.Event,
|
||||
revision: 0,
|
||||
id: '659e1',
|
||||
},
|
||||
{
|
||||
duration: 600000,
|
||||
type: SupportedEvent.Delay,
|
||||
revision: 0,
|
||||
id: '07986',
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
subtitle: '',
|
||||
presenter: '',
|
||||
note: '',
|
||||
endAction: EndAction.None,
|
||||
timerType: TimerType.CountDown,
|
||||
timeStart: 1200000,
|
||||
timeEnd: 1200000,
|
||||
duration: 0,
|
||||
isPublic: true,
|
||||
skip: false,
|
||||
colour: '',
|
||||
user0: '',
|
||||
user1: '',
|
||||
user2: '',
|
||||
user3: '',
|
||||
user4: '',
|
||||
user5: '',
|
||||
user6: '',
|
||||
user7: '',
|
||||
user8: '',
|
||||
user9: '',
|
||||
type: SupportedEvent.Event,
|
||||
revision: 0,
|
||||
id: '1c48f',
|
||||
},
|
||||
{
|
||||
duration: 1200000,
|
||||
type: SupportedEvent.Delay,
|
||||
revision: 0,
|
||||
id: '7db42',
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
subtitle: '',
|
||||
presenter: '',
|
||||
note: '',
|
||||
endAction: EndAction.None,
|
||||
timerType: TimerType.CountDown,
|
||||
timeStart: 600000,
|
||||
timeEnd: 1200000,
|
||||
duration: 600000,
|
||||
isPublic: true,
|
||||
skip: false,
|
||||
colour: '',
|
||||
user0: '',
|
||||
user1: '',
|
||||
user2: '',
|
||||
user3: '',
|
||||
user4: '',
|
||||
user5: '',
|
||||
user6: '',
|
||||
user7: '',
|
||||
user8: '',
|
||||
user9: '',
|
||||
type: SupportedEvent.Event,
|
||||
revision: 0,
|
||||
id: 'd48c2',
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
type: SupportedEvent.Block,
|
||||
id: '9870d',
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
subtitle: '',
|
||||
presenter: '',
|
||||
note: '',
|
||||
endAction: EndAction.None,
|
||||
timerType: TimerType.CountDown,
|
||||
timeStart: 1200000,
|
||||
timeEnd: 1800000,
|
||||
duration: 600000,
|
||||
isPublic: true,
|
||||
skip: false,
|
||||
colour: '',
|
||||
user0: '',
|
||||
user1: '',
|
||||
user2: '',
|
||||
user3: '',
|
||||
user4: '',
|
||||
user5: '',
|
||||
user6: '',
|
||||
user7: '',
|
||||
user8: '',
|
||||
user9: '',
|
||||
type: SupportedEvent.Event,
|
||||
revision: 0,
|
||||
id: '2f185',
|
||||
},
|
||||
];
|
||||
|
||||
const eventId = rundown[1].id;
|
||||
const { updatedRundown } = _applyDelay(eventId, rundown);
|
||||
|
||||
expect(updatedRundown[0].timeStart).toBe(rundown[0].timeStart);
|
||||
expect(updatedRundown[2].timeStart).toBe(rundown[1].duration + rundown[2].timeStart);
|
||||
expect(updatedRundown[4].timeStart).toBe(rundown[1].duration + rundown[4].timeStart);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,445 @@
|
||||
import { EndAction, OntimeRundown, SupportedEvent, TimerType } from 'ontime-types';
|
||||
|
||||
import { calculateRuntimeDelays, calculateRuntimeDelaysFrom, getDelayAt } from '../delayedRundown.utils.js';
|
||||
|
||||
describe('calculateRuntimeDelays', () => {
|
||||
it('calculates all delays in a given rundown', () => {
|
||||
const rundown: OntimeRundown = [
|
||||
{
|
||||
title: '',
|
||||
subtitle: '',
|
||||
presenter: '',
|
||||
note: '',
|
||||
endAction: EndAction.None,
|
||||
timerType: TimerType.CountDown,
|
||||
timeStart: 600000,
|
||||
timeEnd: 1200000,
|
||||
duration: 600000,
|
||||
isPublic: true,
|
||||
skip: false,
|
||||
colour: '',
|
||||
user0: '',
|
||||
user1: '',
|
||||
user2: '',
|
||||
user3: '',
|
||||
user4: '',
|
||||
user5: '',
|
||||
user6: '',
|
||||
user7: '',
|
||||
user8: '',
|
||||
user9: '',
|
||||
type: SupportedEvent.Event,
|
||||
revision: 0,
|
||||
id: '659e1',
|
||||
},
|
||||
{
|
||||
duration: 600000,
|
||||
type: SupportedEvent.Delay,
|
||||
revision: 0,
|
||||
id: '07986',
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
subtitle: '',
|
||||
presenter: '',
|
||||
note: '',
|
||||
endAction: EndAction.None,
|
||||
timerType: TimerType.CountDown,
|
||||
timeStart: 1200000,
|
||||
timeEnd: 1200000,
|
||||
duration: 0,
|
||||
isPublic: true,
|
||||
skip: false,
|
||||
colour: '',
|
||||
user0: '',
|
||||
user1: '',
|
||||
user2: '',
|
||||
user3: '',
|
||||
user4: '',
|
||||
user5: '',
|
||||
user6: '',
|
||||
user7: '',
|
||||
user8: '',
|
||||
user9: '',
|
||||
type: SupportedEvent.Event,
|
||||
revision: 0,
|
||||
id: '1c48f',
|
||||
},
|
||||
{
|
||||
duration: 1200000,
|
||||
type: SupportedEvent.Delay,
|
||||
revision: 0,
|
||||
id: '7db42',
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
subtitle: '',
|
||||
presenter: '',
|
||||
note: '',
|
||||
endAction: EndAction.None,
|
||||
timerType: TimerType.CountDown,
|
||||
timeStart: 600000,
|
||||
timeEnd: 1200000,
|
||||
duration: 600000,
|
||||
isPublic: true,
|
||||
skip: false,
|
||||
colour: '',
|
||||
user0: '',
|
||||
user1: '',
|
||||
user2: '',
|
||||
user3: '',
|
||||
user4: '',
|
||||
user5: '',
|
||||
user6: '',
|
||||
user7: '',
|
||||
user8: '',
|
||||
user9: '',
|
||||
type: SupportedEvent.Event,
|
||||
revision: 0,
|
||||
id: 'd48c2',
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
type: SupportedEvent.Block,
|
||||
id: '9870d',
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
subtitle: '',
|
||||
presenter: '',
|
||||
note: '',
|
||||
endAction: EndAction.None,
|
||||
timerType: TimerType.CountDown,
|
||||
timeStart: 1200000,
|
||||
timeEnd: 1800000,
|
||||
duration: 600000,
|
||||
isPublic: true,
|
||||
skip: false,
|
||||
colour: '',
|
||||
user0: '',
|
||||
user1: '',
|
||||
user2: '',
|
||||
user3: '',
|
||||
user4: '',
|
||||
user5: '',
|
||||
user6: '',
|
||||
user7: '',
|
||||
user8: '',
|
||||
user9: '',
|
||||
type: SupportedEvent.Event,
|
||||
revision: 0,
|
||||
id: '2f185',
|
||||
},
|
||||
];
|
||||
|
||||
const updatedRundown = calculateRuntimeDelays(rundown);
|
||||
|
||||
expect(rundown.length).toBe(updatedRundown.length);
|
||||
expect(updatedRundown[0].delay).toBe(0);
|
||||
expect(updatedRundown[2].delay).toBe(600000);
|
||||
expect(updatedRundown[4].delay).toBe(600000 + 1200000);
|
||||
expect(updatedRundown[6].delay).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDelayAt()', () => {
|
||||
const delayedRundown: OntimeRundown = [
|
||||
{
|
||||
title: '',
|
||||
subtitle: '',
|
||||
presenter: '',
|
||||
note: '',
|
||||
endAction: EndAction.None,
|
||||
timerType: TimerType.CountDown,
|
||||
timeStart: 600000,
|
||||
timeEnd: 1200000,
|
||||
duration: 600000,
|
||||
isPublic: true,
|
||||
skip: false,
|
||||
colour: '',
|
||||
user0: '',
|
||||
user1: '',
|
||||
user2: '',
|
||||
user3: '',
|
||||
user4: '',
|
||||
user5: '',
|
||||
user6: '',
|
||||
user7: '',
|
||||
user8: '',
|
||||
user9: '',
|
||||
type: SupportedEvent.Event,
|
||||
revision: 0,
|
||||
id: '659e1',
|
||||
delay: 0,
|
||||
},
|
||||
{
|
||||
duration: 600000,
|
||||
type: SupportedEvent.Delay,
|
||||
revision: 0,
|
||||
id: '07986',
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
subtitle: '',
|
||||
presenter: '',
|
||||
note: '',
|
||||
endAction: EndAction.None,
|
||||
timerType: TimerType.CountDown,
|
||||
timeStart: 1200000,
|
||||
timeEnd: 1200000,
|
||||
duration: 0,
|
||||
isPublic: true,
|
||||
skip: false,
|
||||
colour: '',
|
||||
user0: '',
|
||||
user1: '',
|
||||
user2: '',
|
||||
user3: '',
|
||||
user4: '',
|
||||
user5: '',
|
||||
user6: '',
|
||||
user7: '',
|
||||
user8: '',
|
||||
user9: '',
|
||||
type: SupportedEvent.Event,
|
||||
revision: 0,
|
||||
id: '1c48f',
|
||||
delay: 600000,
|
||||
},
|
||||
{
|
||||
duration: 1200000,
|
||||
type: SupportedEvent.Delay,
|
||||
revision: 0,
|
||||
id: '7db42',
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
subtitle: '',
|
||||
presenter: '',
|
||||
note: '',
|
||||
endAction: EndAction.None,
|
||||
timerType: TimerType.CountDown,
|
||||
timeStart: 600000,
|
||||
timeEnd: 1200000,
|
||||
duration: 600000,
|
||||
isPublic: true,
|
||||
skip: false,
|
||||
colour: '',
|
||||
user0: '',
|
||||
user1: '',
|
||||
user2: '',
|
||||
user3: '',
|
||||
user4: '',
|
||||
user5: '',
|
||||
user6: '',
|
||||
user7: '',
|
||||
user8: '',
|
||||
user9: '',
|
||||
type: SupportedEvent.Event,
|
||||
revision: 0,
|
||||
id: 'd48c2',
|
||||
delay: 1800000,
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
type: SupportedEvent.Block,
|
||||
id: '9870d',
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
subtitle: '',
|
||||
presenter: '',
|
||||
note: '',
|
||||
endAction: EndAction.None,
|
||||
timerType: TimerType.CountDown,
|
||||
timeStart: 1200000,
|
||||
timeEnd: 1800000,
|
||||
duration: 600000,
|
||||
isPublic: true,
|
||||
skip: false,
|
||||
colour: '',
|
||||
user0: '',
|
||||
user1: '',
|
||||
user2: '',
|
||||
user3: '',
|
||||
user4: '',
|
||||
user5: '',
|
||||
user6: '',
|
||||
user7: '',
|
||||
user8: '',
|
||||
user9: '',
|
||||
type: SupportedEvent.Event,
|
||||
revision: 0,
|
||||
id: '2f185',
|
||||
delay: 0,
|
||||
},
|
||||
];
|
||||
|
||||
it('calculates delay in a rundown', () => {
|
||||
const delayAtStart = getDelayAt(0, delayedRundown);
|
||||
const delayOnFirstEvent = getDelayAt(2, delayedRundown);
|
||||
const delayOnSecondEvent = getDelayAt(4, delayedRundown);
|
||||
const delayOnBlockedEvent = getDelayAt(0, delayedRundown);
|
||||
|
||||
expect(delayAtStart).toBe(0);
|
||||
expect(delayOnFirstEvent).toBe(600000);
|
||||
expect(delayOnSecondEvent).toBe(600000 + 1200000);
|
||||
expect(delayOnBlockedEvent).toBe(0);
|
||||
});
|
||||
it('finds delay before a delay block', () => {
|
||||
const valueOnFirstDelayBlock = getDelayAt(1, delayedRundown);
|
||||
const valueOnSecondDelayBlock = getDelayAt(3, delayedRundown);
|
||||
const valueAfterSecondDelayBlock = getDelayAt(4, delayedRundown);
|
||||
|
||||
expect(valueOnFirstDelayBlock).toBe(0);
|
||||
expect(valueOnSecondDelayBlock).toBe(600000);
|
||||
expect(valueAfterSecondDelayBlock).toBe(600000 + 1200000);
|
||||
});
|
||||
it('returns 0 after blocks', () => {
|
||||
const valueOnBlock = getDelayAt(6, delayedRundown);
|
||||
expect(valueOnBlock).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateRuntimeDelaysFrom()', () => {
|
||||
it('updates delays from given id', () => {
|
||||
const delayedRundown: OntimeRundown = [
|
||||
{
|
||||
title: '',
|
||||
subtitle: '',
|
||||
presenter: '',
|
||||
note: '',
|
||||
endAction: EndAction.None,
|
||||
timerType: TimerType.CountDown,
|
||||
timeStart: 600000,
|
||||
timeEnd: 1200000,
|
||||
duration: 600000,
|
||||
isPublic: true,
|
||||
skip: false,
|
||||
colour: '',
|
||||
user0: '',
|
||||
user1: '',
|
||||
user2: '',
|
||||
user3: '',
|
||||
user4: '',
|
||||
user5: '',
|
||||
user6: '',
|
||||
user7: '',
|
||||
user8: '',
|
||||
user9: '',
|
||||
type: SupportedEvent.Event,
|
||||
revision: 0,
|
||||
id: '659e1',
|
||||
delay: 0,
|
||||
},
|
||||
{
|
||||
duration: 600000,
|
||||
type: SupportedEvent.Delay,
|
||||
revision: 0,
|
||||
id: '07986',
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
subtitle: '',
|
||||
presenter: '',
|
||||
note: '',
|
||||
endAction: EndAction.None,
|
||||
timerType: TimerType.CountDown,
|
||||
timeStart: 1200000,
|
||||
timeEnd: 1200000,
|
||||
duration: 0,
|
||||
isPublic: true,
|
||||
skip: false,
|
||||
colour: '',
|
||||
user0: '',
|
||||
user1: '',
|
||||
user2: '',
|
||||
user3: '',
|
||||
user4: '',
|
||||
user5: '',
|
||||
user6: '',
|
||||
user7: '',
|
||||
user8: '',
|
||||
user9: '',
|
||||
type: SupportedEvent.Event,
|
||||
revision: 0,
|
||||
id: '1c48f',
|
||||
delay: 0,
|
||||
},
|
||||
{
|
||||
duration: 1200000,
|
||||
type: SupportedEvent.Delay,
|
||||
revision: 0,
|
||||
id: '7db42',
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
subtitle: '',
|
||||
presenter: '',
|
||||
note: '',
|
||||
endAction: EndAction.None,
|
||||
timerType: TimerType.CountDown,
|
||||
timeStart: 600000,
|
||||
timeEnd: 1200000,
|
||||
duration: 600000,
|
||||
isPublic: true,
|
||||
skip: false,
|
||||
colour: '',
|
||||
user0: '',
|
||||
user1: '',
|
||||
user2: '',
|
||||
user3: '',
|
||||
user4: '',
|
||||
user5: '',
|
||||
user6: '',
|
||||
user7: '',
|
||||
user8: '',
|
||||
user9: '',
|
||||
type: SupportedEvent.Event,
|
||||
revision: 0,
|
||||
id: 'd48c2',
|
||||
delay: 1800000,
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
type: SupportedEvent.Block,
|
||||
id: '9870d',
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
subtitle: '',
|
||||
presenter: '',
|
||||
note: '',
|
||||
endAction: EndAction.None,
|
||||
timerType: TimerType.CountDown,
|
||||
timeStart: 1200000,
|
||||
timeEnd: 1800000,
|
||||
duration: 600000,
|
||||
isPublic: true,
|
||||
skip: false,
|
||||
colour: '',
|
||||
user0: '',
|
||||
user1: '',
|
||||
user2: '',
|
||||
user3: '',
|
||||
user4: '',
|
||||
user5: '',
|
||||
user6: '',
|
||||
user7: '',
|
||||
user8: '',
|
||||
user9: '',
|
||||
type: SupportedEvent.Event,
|
||||
revision: 0,
|
||||
id: '2f185',
|
||||
delay: 0,
|
||||
},
|
||||
];
|
||||
|
||||
const updatedRundown = calculateRuntimeDelaysFrom('07986', delayedRundown);
|
||||
|
||||
// we only update from the 4th on
|
||||
expect(updatedRundown[0].delay).toBe(0);
|
||||
// 1 + 3
|
||||
expect(updatedRundown[4].delay).toBe(600000 + 1200000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,261 @@
|
||||
import { OntimeBlock, OntimeDelay, OntimeEvent, OntimeRundown, SupportedEvent } from 'ontime-types';
|
||||
import { DataProvider } from '../../classes/data-provider/DataProvider.js';
|
||||
import { getCached, runtimeCacheStore } from '../../stores/cachingStore.js';
|
||||
import { isProduction } from '../../setup.js';
|
||||
import { deleteAtIndex, insertAtIndex, reorderArray } from '../../utils/arrayUtils.js';
|
||||
|
||||
/**
|
||||
* Key of rundown in cache
|
||||
*/
|
||||
export const delayedRundownCacheKey = 'delayed-rundown';
|
||||
|
||||
/**
|
||||
* Invalidates the cached rundown when an inconsistency is found
|
||||
* will throw when not in production
|
||||
* @param errorMessage
|
||||
*/
|
||||
export function invalidateFromError(errorMessage = 'Found mismatch between store and cache') {
|
||||
if (isProduction) {
|
||||
runtimeCacheStore.invalidate(delayedRundownCacheKey);
|
||||
} else {
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns rundown with calculated delays
|
||||
* Ensures request goes through the caching layer
|
||||
*/
|
||||
export function getDelayedRundown(): OntimeRundown {
|
||||
function calculateRundown() {
|
||||
const rundown = DataProvider.getRundown();
|
||||
return calculateRuntimeDelays(rundown);
|
||||
}
|
||||
|
||||
return getCached(delayedRundownCacheKey, calculateRundown);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds an event in the rundown at given index, ensuring replication to delayed rundown cache
|
||||
* @param eventIndex
|
||||
* @param event
|
||||
*/
|
||||
export async function cachedAdd(eventIndex: number, event: OntimeEvent | OntimeDelay | OntimeBlock) {
|
||||
// TODO: create wrapper function
|
||||
const rundown = DataProvider.getRundown();
|
||||
const newRundown = insertAtIndex(eventIndex, event, rundown);
|
||||
|
||||
const delayedRundown = getDelayedRundown();
|
||||
let newDelayedRundown = insertAtIndex(eventIndex, event, delayedRundown);
|
||||
|
||||
// update delay cache
|
||||
if (event.type === SupportedEvent.Event) {
|
||||
// if it is an event, we need its delay
|
||||
(newDelayedRundown[eventIndex] as OntimeEvent).delay = getDelayAt(eventIndex, newDelayedRundown);
|
||||
} else {
|
||||
// if it is a block or delay, we invalidate from here
|
||||
newDelayedRundown = calculateRuntimeDelaysFromIndex(eventIndex, newDelayedRundown);
|
||||
}
|
||||
|
||||
runtimeCacheStore.setCached(delayedRundownCacheKey, newDelayedRundown);
|
||||
// we need to delay updating this to ensure add operation happens on same dataset
|
||||
await DataProvider.setRundown(newRundown);
|
||||
}
|
||||
|
||||
/**
|
||||
* Edits an event in rundown, ensuring replication to delayed rundown cache
|
||||
* @param eventId
|
||||
* @param patchObject
|
||||
*/
|
||||
export async function cachedEdit(
|
||||
eventId: string,
|
||||
patchObject: Partial<OntimeEvent> | Partial<OntimeBlock> | Partial<OntimeDelay>,
|
||||
) {
|
||||
const indexInMemory = DataProvider.getIndexOf(eventId);
|
||||
if (indexInMemory < 0) {
|
||||
throw new Error('No event with ID found');
|
||||
}
|
||||
|
||||
const updatedRundown = DataProvider.getRundown();
|
||||
const newEvent = { ...updatedRundown[indexInMemory], ...patchObject };
|
||||
if (newEvent.type === SupportedEvent.Event) {
|
||||
newEvent.revision++;
|
||||
}
|
||||
// @ts-expect-error -- this merge is safe
|
||||
updatedRundown[indexInMemory] = newEvent;
|
||||
|
||||
let newDelayedRundown = getDelayedRundown();
|
||||
if (newDelayedRundown?.[indexInMemory].id !== newEvent.id) {
|
||||
invalidateFromError();
|
||||
} else {
|
||||
// @ts-expect-error -- this merge is safe
|
||||
newDelayedRundown[indexInMemory] = newEvent;
|
||||
if (newEvent.type === SupportedEvent.Event) {
|
||||
(newDelayedRundown[indexInMemory] as OntimeEvent).delay = getDelayAt(indexInMemory, newDelayedRundown);
|
||||
} else if (newEvent.type === SupportedEvent.Delay) {
|
||||
// blocks have no reason to change the rundown, from delays we need to recalculate
|
||||
newDelayedRundown = calculateRuntimeDelaysFromIndex(indexInMemory, newDelayedRundown);
|
||||
}
|
||||
|
||||
runtimeCacheStore.setCached(delayedRundownCacheKey, newDelayedRundown);
|
||||
}
|
||||
|
||||
// we need to delay updating this to ensure edit operation happens on same dataset
|
||||
await DataProvider.setRundown(updatedRundown);
|
||||
|
||||
return newEvent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes an event with given id from rundown, ensuring replication to delayed rundown cache
|
||||
* @param eventId
|
||||
*/
|
||||
export async function cachedDelete(eventId: string) {
|
||||
const eventIndex = DataProvider.getIndexOf(eventId);
|
||||
let delayedRundown = getDelayedRundown();
|
||||
|
||||
if (eventIndex < 0) {
|
||||
if (delayedRundown.findIndex((event) => event.id === eventId) >= 0) {
|
||||
invalidateFromError();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let updatedRundown = DataProvider.getRundown();
|
||||
const eventType = updatedRundown[eventIndex].type;
|
||||
updatedRundown = deleteAtIndex(eventIndex, updatedRundown);
|
||||
if (eventId !== delayedRundown[eventIndex].id) {
|
||||
invalidateFromError();
|
||||
} else {
|
||||
delayedRundown = deleteAtIndex(eventIndex, delayedRundown);
|
||||
if (eventType === SupportedEvent.Delay || eventType === SupportedEvent.Block) {
|
||||
// for events, we do not have to worry
|
||||
// the following event, would have taken the place of the deleted event by now
|
||||
delayedRundown = calculateRuntimeDelaysFromIndex(eventIndex, delayedRundown);
|
||||
}
|
||||
runtimeCacheStore.setCached(delayedRundownCacheKey, delayedRundown);
|
||||
}
|
||||
// we need to delay updating this to ensure edit operation happens on same dataset
|
||||
await DataProvider.setRundown(updatedRundown);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorders an event in the rundown, ensuring replication to delayed rundown cache
|
||||
* @param eventId
|
||||
* @param from
|
||||
* @param to
|
||||
*/
|
||||
export async function cachedReorder(eventId: string, from: number, to: number) {
|
||||
const indexCheck = DataProvider.getIndexOf(eventId);
|
||||
if (indexCheck !== from) {
|
||||
invalidateFromError();
|
||||
throw new Error('ID not found at index');
|
||||
}
|
||||
|
||||
let updatedRundown = DataProvider.getRundown();
|
||||
const reorderedEvent = updatedRundown[from];
|
||||
updatedRundown = reorderArray(updatedRundown, from, to);
|
||||
|
||||
const delayedRundown = getDelayedRundown();
|
||||
if (eventId !== delayedRundown[from].id) {
|
||||
invalidateFromError();
|
||||
} else {
|
||||
// TODO: could we be more granular about updates
|
||||
// I fear we need to update both from and to, which could signify more iterations
|
||||
runtimeCacheStore.invalidate(delayedRundownCacheKey);
|
||||
}
|
||||
|
||||
// we need to delay updating this to ensure edit operation happens on same dataset
|
||||
await DataProvider.setRundown(updatedRundown);
|
||||
|
||||
return reorderedEvent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates all delays in a given rundown
|
||||
* @param rundown
|
||||
*/
|
||||
export function calculateRuntimeDelays(rundown: OntimeRundown) {
|
||||
let accumulatedDelay = 0;
|
||||
const updatedRundown = [...rundown];
|
||||
|
||||
for (const [index, event] of updatedRundown.entries()) {
|
||||
if (event.type === SupportedEvent.Delay) {
|
||||
accumulatedDelay += event.duration;
|
||||
} else if (event.type === SupportedEvent.Block) {
|
||||
accumulatedDelay = 0;
|
||||
} else if (event.type === SupportedEvent.Event) {
|
||||
updatedRundown[index] = {
|
||||
...event,
|
||||
delay: accumulatedDelay,
|
||||
};
|
||||
}
|
||||
}
|
||||
return updatedRundown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate delays in rundown from a given index
|
||||
* @param eventIndex
|
||||
* @param rundown
|
||||
*/
|
||||
export function calculateRuntimeDelaysFromIndex(eventIndex: number, rundown: OntimeRundown) {
|
||||
if (eventIndex === -1) {
|
||||
throw new Error('ID not found at index');
|
||||
}
|
||||
|
||||
let accumulatedDelay = getDelayAt(eventIndex, rundown);
|
||||
const updatedRundown = [...rundown];
|
||||
|
||||
for (let i = eventIndex; i < rundown.length; i++) {
|
||||
const event = rundown[i];
|
||||
if (event.type === SupportedEvent.Delay) {
|
||||
accumulatedDelay += event.duration;
|
||||
} else if (event.type === SupportedEvent.Block) {
|
||||
if (i === eventIndex) {
|
||||
accumulatedDelay = 0;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
} else if (event.type === SupportedEvent.Event) {
|
||||
updatedRundown[i] = {
|
||||
...event,
|
||||
delay: accumulatedDelay,
|
||||
};
|
||||
}
|
||||
}
|
||||
return updatedRundown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate delays in rundown from an event with given id
|
||||
* @param eventId
|
||||
* @param rundown
|
||||
*/
|
||||
export function calculateRuntimeDelaysFrom(eventId: string, rundown: OntimeRundown) {
|
||||
const index = rundown.findIndex((event) => event.id === eventId);
|
||||
return calculateRuntimeDelaysFromIndex(index, rundown);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates delay to an event at a given index
|
||||
* @param eventIndex
|
||||
* @param rundown
|
||||
*/
|
||||
export function getDelayAt(eventIndex: number, rundown: OntimeRundown): number {
|
||||
if (eventIndex < 1) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// we need to check the event before
|
||||
const event = rundown[eventIndex - 1];
|
||||
|
||||
if (event.type === SupportedEvent.Delay) {
|
||||
return event.duration + getDelayAt(eventIndex - 1, rundown);
|
||||
} else if (event.type === SupportedEvent.Block) {
|
||||
return 0;
|
||||
} else if (event.type === SupportedEvent.Event) {
|
||||
return event.delay ?? 0;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { runtimeCacheStore } from '../cachingStore.js';
|
||||
|
||||
describe('cachingStore()', () => {
|
||||
beforeEach(() => {
|
||||
runtimeCacheStore.clear(); // Clear the cache before each test
|
||||
});
|
||||
|
||||
it('should check if an item is cached', () => {
|
||||
// Add an item to the cache
|
||||
runtimeCacheStore.setCached('key', 'value');
|
||||
|
||||
// Check if the item is cached
|
||||
expect(runtimeCacheStore.checkCached('key')).toBe(true);
|
||||
expect(runtimeCacheStore.checkCached('non-existent-key')).toBe(false);
|
||||
});
|
||||
|
||||
it('should get an item from the cache', () => {
|
||||
// Add an item to the cache
|
||||
runtimeCacheStore.setCached('key', 'value');
|
||||
|
||||
// Get the item from the cache
|
||||
const result = runtimeCacheStore.getCached('key', () => 'default-value');
|
||||
|
||||
// Check the returned value
|
||||
expect(result).toBe('value');
|
||||
});
|
||||
|
||||
it('should retrieve default value when item is not cached', () => {
|
||||
// Get an item that is not in the cache
|
||||
const result = runtimeCacheStore.getCached('non-existent-key', () => 'default-value');
|
||||
|
||||
// Check the returned value
|
||||
expect(result).toBe('default-value');
|
||||
});
|
||||
|
||||
it('should set an item in the cache', () => {
|
||||
// Set an item in the cache
|
||||
runtimeCacheStore.setCached('key', 'value');
|
||||
|
||||
// Check if the item is cached
|
||||
expect(runtimeCacheStore.checkCached('key')).toBe(true);
|
||||
});
|
||||
|
||||
it('should invalidate an item in the cache', () => {
|
||||
// Add an item to the cache
|
||||
runtimeCacheStore.setCached('key', 'value');
|
||||
|
||||
// Invalidate the item
|
||||
runtimeCacheStore.invalidate('key');
|
||||
|
||||
// Check if the item is no longer cached
|
||||
expect(runtimeCacheStore.checkCached('key')).toBe(false);
|
||||
});
|
||||
|
||||
it('should clear the cache', () => {
|
||||
// Add items to the cache
|
||||
runtimeCacheStore.setCached('key1', 'value1');
|
||||
runtimeCacheStore.setCached('key2', 'value2');
|
||||
|
||||
// Clear the cache
|
||||
runtimeCacheStore.clear();
|
||||
|
||||
// Check if the cache is empty
|
||||
expect(runtimeCacheStore.checkCached('key1')).toBe(false);
|
||||
expect(runtimeCacheStore.checkCached('key2')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
interface CacheData {
|
||||
data: unknown;
|
||||
}
|
||||
|
||||
const runtimeCache: Map<string, CacheData> = new Map();
|
||||
|
||||
export function checkCached(key: string): boolean {
|
||||
return runtimeCache.has(key);
|
||||
}
|
||||
|
||||
export function getCached<T>(key: string, callback: () => T): T {
|
||||
if (!runtimeCache.has(key)) {
|
||||
try {
|
||||
const data = callback();
|
||||
runtimeCache.set(key, { data });
|
||||
} catch (error) {
|
||||
console.log(`Failed retrieving data from callback: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
return runtimeCache.get(key).data as T;
|
||||
}
|
||||
|
||||
export function setCached<T>(key: string, value: T): T {
|
||||
runtimeCache.set(key, { data: value });
|
||||
return runtimeCache.get(key).data as T;
|
||||
}
|
||||
|
||||
export function invalidate(key) {
|
||||
runtimeCache.delete(key);
|
||||
}
|
||||
|
||||
export function clear() {
|
||||
runtimeCache.clear();
|
||||
}
|
||||
|
||||
function createCacheStore() {
|
||||
return {
|
||||
checkCached,
|
||||
getCached,
|
||||
setCached,
|
||||
invalidate,
|
||||
clear,
|
||||
};
|
||||
}
|
||||
|
||||
export const runtimeCacheStore = createCacheStore();
|
||||
@@ -0,0 +1,54 @@
|
||||
import { insertAtIndex, reorderArray } from '../arrayUtils.js';
|
||||
|
||||
describe('insertAtIndex', () => {
|
||||
it('should insert an item at the beginning of the array', () => {
|
||||
const array = [2, 3, 4];
|
||||
const result = insertAtIndex(0, 1, array);
|
||||
expect(result).toEqual([1, 2, 3, 4]);
|
||||
});
|
||||
|
||||
it('should insert an item at the end of the array', () => {
|
||||
const array = [1, 2, 3];
|
||||
const result = insertAtIndex(3, 4, array);
|
||||
expect(result).toEqual([1, 2, 3, 4]);
|
||||
});
|
||||
|
||||
it('should insert an item in the middle of the array', () => {
|
||||
const array = [1, 2, 4];
|
||||
const result = insertAtIndex(2, 3, array);
|
||||
expect(result).toEqual([1, 2, 3, 4]);
|
||||
});
|
||||
|
||||
it('should return a new array and not modify the original array', () => {
|
||||
const array = [1, 2, 3];
|
||||
const result = insertAtIndex(1, 5, array);
|
||||
expect(result).toEqual([1, 5, 2, 3]);
|
||||
expect(array).toEqual([1, 2, 3]); // Original array should remain unchanged
|
||||
});
|
||||
});
|
||||
|
||||
describe('reorderArray', () => {
|
||||
it('should reorder an item in the array', () => {
|
||||
const array = ['a', 'b', 'c', 'd'];
|
||||
const result = reorderArray(array, 1, 3);
|
||||
expect(result).toEqual(['a', 'c', 'd', 'b']);
|
||||
});
|
||||
|
||||
it('should return the original array if fromIndex and toIndex are the same', () => {
|
||||
const array = ['a', 'b', 'c'];
|
||||
const result = reorderArray(array, 1, 1);
|
||||
expect(result).toEqual(array);
|
||||
});
|
||||
|
||||
it('should handle reordering to the beginning of the array', () => {
|
||||
const array = ['a', 'b', 'c'];
|
||||
const result = reorderArray(array, 2, 0);
|
||||
expect(result).toEqual(['c', 'a', 'b']);
|
||||
});
|
||||
|
||||
it('should handle reordering to the end of the array', () => {
|
||||
const array = ['a', 'b', 'c'];
|
||||
const result = reorderArray(array, 0, 2);
|
||||
expect(result).toEqual(['b', 'c', 'a']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Inserts an item in an array at a given index
|
||||
* @param index
|
||||
* @param item
|
||||
* @param array
|
||||
*/
|
||||
export function insertAtIndex<T>(index: number, item: T, array: T[]): T[] {
|
||||
const modifiedArray = [...array];
|
||||
|
||||
// Insert at beginning
|
||||
if (index === 0) {
|
||||
modifiedArray.unshift(item);
|
||||
}
|
||||
|
||||
// insert at end
|
||||
else if (index >= modifiedArray.length) {
|
||||
modifiedArray.push(item);
|
||||
}
|
||||
|
||||
// insert in the middle
|
||||
else {
|
||||
modifiedArray.splice(index, 0, item);
|
||||
}
|
||||
|
||||
return modifiedArray;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes array element at a given index
|
||||
* @param index
|
||||
* @param array
|
||||
*/
|
||||
export function deleteAtIndex<T>(index: number, array: T[]) {
|
||||
return array.filter((_, i) => i !== index);
|
||||
}
|
||||
|
||||
export function reorderArray<T>(array: T[], fromIndex: number, toIndex: number) {
|
||||
if (fromIndex === toIndex) {
|
||||
return array; // No change needed, return the original array
|
||||
}
|
||||
|
||||
const modifiedArray = [...array];
|
||||
|
||||
// delete in from
|
||||
const [reorderedItem] = modifiedArray.splice(fromIndex, 1);
|
||||
|
||||
// reinsert item at to
|
||||
modifiedArray.splice(toIndex, 0, reorderedItem);
|
||||
return modifiedArray;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import fs from 'fs';
|
||||
|
||||
test('cuesheet displays events and exports csv', async ({ page }) => {
|
||||
// ensure elements exist in editor
|
||||
await page.goto('http://localhost:4001/editor');
|
||||
await page.getByText('First test event').click();
|
||||
await page.getByText('Second test event').click();
|
||||
await page.getByText('Third test event').click();
|
||||
await page.getByText('Add timeSubtract timeApplyCancel').click();
|
||||
await page.getByText('Lunch').click();
|
||||
|
||||
// same elements in cuesheet
|
||||
await page.goto('http://localhost:4001/cuesheet');
|
||||
await page.getByText('All about Carlos demo event').click();
|
||||
await page.getByRole('cell', { name: 'First test event' }).click();
|
||||
await page.getByRole('cell', { name: 'Second test event' }).click();
|
||||
await page.getByRole('cell', { name: 'Third test event' }).click();
|
||||
await page.getByRole('cell', { name: '+10 min' }).click();
|
||||
await page.getByRole('cell', { name: 'Lunch' }).click();
|
||||
const downloadPromise = page.waitForEvent('download');
|
||||
await page.getByTestId('cuesheet').getByText('CSV').click();
|
||||
|
||||
// From here we test the CSV download feature
|
||||
|
||||
function validateCSV(contents) {
|
||||
// We should try to keep this in sync with the implementation over at cuesheetUtils.ts
|
||||
const expectedHeader = ['All about Carlos demo event', 'www.getontime.no'];
|
||||
const expectedColumns = [
|
||||
'Time Start',
|
||||
'Time End',
|
||||
'Event Title',
|
||||
'Presenter Name',
|
||||
'Event Subtitle',
|
||||
'Public',
|
||||
'Note',
|
||||
'Colour',
|
||||
'End Action',
|
||||
'Timer Type',
|
||||
'Skip',
|
||||
'user0',
|
||||
'user1',
|
||||
'user2',
|
||||
'user3',
|
||||
'user4',
|
||||
'user5',
|
||||
'user6',
|
||||
'user7',
|
||||
'user8',
|
||||
'user9',
|
||||
];
|
||||
const expectedValues = ['First test event', 'Second test event', 'Third test event', 'Lunch'];
|
||||
|
||||
const allExpected = [...expectedHeader, ...expectedColumns, ...expectedValues];
|
||||
return allExpected.every((value) => contents.includes(value));
|
||||
}
|
||||
|
||||
const download = await downloadPromise;
|
||||
const contents = await fs.promises.readFile(await download.path(), 'utf-8');
|
||||
expect(contents).toContain('All about Carlos demo event');
|
||||
expect(validateCSV(contents)).toBe(true);
|
||||
});
|
||||
@@ -30,8 +30,8 @@ export type OntimeEvent = OntimeBaseEvent & {
|
||||
subtitle: string;
|
||||
presenter: string;
|
||||
note: string;
|
||||
endAction: EndAction,
|
||||
timerType: TimerType,
|
||||
endAction: EndAction;
|
||||
timerType: TimerType;
|
||||
timeStart: number;
|
||||
timeEnd: number;
|
||||
duration: number;
|
||||
@@ -49,4 +49,5 @@ export type OntimeEvent = OntimeBaseEvent & {
|
||||
user8: string;
|
||||
user9: string;
|
||||
revision: number;
|
||||
delay?: number; // calculated at runtime
|
||||
};
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { OntimeBlock, OntimeDelay, OntimeEvent } from './OntimeEvent.type';
|
||||
import { OntimeBlock, OntimeDelay, OntimeEvent } from './OntimeEvent.type.js';
|
||||
|
||||
export type OntimeRundownEntry = OntimeDelay | OntimeBlock | OntimeEvent;
|
||||
export type OntimeRundown = OntimeRundownEntry[];
|
||||
|
||||
// we need to create a manual union type since keys cannot be used in type unions
|
||||
export type OntimeEntryCommonKeys = keyof OntimeEvent | keyof OntimeDelay | keyof OntimeBlock;
|
||||
|
||||
@@ -16,3 +16,12 @@ export type LogMessage = {
|
||||
type: 'ontime-log';
|
||||
payload: Log;
|
||||
};
|
||||
|
||||
export enum LogOrigin {
|
||||
Client = 'CLIENT',
|
||||
Playback = 'PLAYBACK',
|
||||
Rx = 'RX',
|
||||
Server = 'SERVER',
|
||||
Tx = 'TX',
|
||||
User = 'USER'
|
||||
}
|
||||
|
||||
@@ -10,11 +10,11 @@ import {
|
||||
OntimeEvent,
|
||||
SupportedEvent,
|
||||
} from './definitions/core/OntimeEvent.type.js';
|
||||
import { OntimeRundown, OntimeRundownEntry } from './definitions/core/Rundown.type.js';
|
||||
import { OntimeEntryCommonKeys, OntimeRundown, OntimeRundownEntry } from './definitions/core/Rundown.type.js';
|
||||
import { OSCSettings, OscSubscription, OscSubscriptionOptions } from './definitions/core/OscSettings.type.js';
|
||||
import { Playback } from './definitions/runtime/Playback.type.js';
|
||||
import { Loaded } from './definitions/runtime/Playlist.type.js';
|
||||
import { Log, LogLevel, LogMessage } from './definitions/runtime/Logger.type.js';
|
||||
import { Log, LogLevel, LogMessage, LogOrigin } from './definitions/runtime/Logger.type.js';
|
||||
import { RuntimeStore } from './definitions/runtime/RuntimeStore.type.js';
|
||||
import { Settings } from './definitions/core/Settings.type.js';
|
||||
import { TimerLifeCycle } from './definitions/core/TimerLifecycle.type.js';
|
||||
@@ -32,7 +32,7 @@ export { TimerType };
|
||||
export { EndAction };
|
||||
export { SupportedEvent };
|
||||
export type { OntimeBaseEvent, OntimeBlock, OntimeDelay, OntimeEvent };
|
||||
export type { OntimeRundown, OntimeRundownEntry };
|
||||
export type { OntimeEntryCommonKeys, OntimeRundown, OntimeRundownEntry };
|
||||
|
||||
// ---> Event
|
||||
export type { EventData };
|
||||
@@ -57,6 +57,7 @@ export type { OscSubscription, OSCSettings, OscSubscriptionOptions };
|
||||
// SERVER RUNTIME
|
||||
export { LogLevel };
|
||||
export type { Log, LogMessage };
|
||||
export { LogOrigin };
|
||||
export { Playback };
|
||||
export { TimerLifeCycle };
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { DateTime } from 'luxon';
|
||||
* @returns {string} String representing time 00:12:02
|
||||
*/
|
||||
export function millisToString(millis: number | null, showSeconds = true, fallback = '...') {
|
||||
if (millis === null) {
|
||||
if (millis == null) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
|
||||
Generated
+291
-807
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user