Compare commits

..

18 Commits

Author SHA1 Message Date
cv e16913cb42 wip: upload review 2023-09-10 19:23:13 +02:00
cv 03cbc8544d wip: create preview components 2023-09-02 21:25:30 +02:00
cv 49c44a831c wip: create preview components 2023-09-02 15:46:16 +02:00
cv b51f3c966d Merge remote-tracking branch 'origin/refactor-project-data' into excel-import-preview 2023-09-02 14:58:20 +02:00
cv a344ad2f72 refactor: remove unused import 2023-09-02 14:53:58 +02:00
cv 8c6ef9bfc3 refactor!: rename eventData > project 2023-09-02 14:52:44 +02:00
cv 55ce38f4cd wip: get preview data from excel import 2023-09-02 14:08:48 +02:00
Carlos Valente 8d427a6bbe fix: allow spaces in title input (#504) 2023-09-01 23:26:59 +02:00
Carlos Valente 23dcca5527 fix: issue with resolving dev environment (#501)
* fix: issue with resolving dev environment

* fix: revert accidental commtit

* chore: version bump
2023-08-31 22:08:18 +02:00
Carlos Valente a3bbd74db7 chore: reduce sentry error logging (#498)
* chore: reduce sentry error logging

* chore: version bump

* chore: improve test selectors
2023-08-27 22:00:18 +02:00
Carlos Valente 5323e627fb feat: count to end (#500) 2023-08-27 19:25:40 +02:00
Carlos Valente cd9dbcdc68 fix: add event (#499)
* refactor: remove guards on event creation

* style: minimum safe block size

* fix: insert after finds previous valid cue
2023-08-26 21:45:51 +02:00
Carlos Valente af99882919 release test fixes (#496)
* refactor: handle missing type

* fix: prevent stale data on cancel delay
2023-08-24 22:53:23 +02:00
Carlos Valente 1d4dc3a26f feat: unlock changing port in UI (#476)
* refactor: unlock changing port in UI

* Change server port (#464)

* Ability to save custom port

* Launch server on custom port & pass that port to electron

* Add serverPort validation

* Prevent changing port in docker

---------

Co-authored-by: Snehanshu Phukon <snehanshu@mamboemm.com>

---------

Co-authored-by: স্নেহাংশু ফুকন <hello@snehanshu.com>
Co-authored-by: Snehanshu Phukon <snehanshu@mamboemm.com>
2023-08-23 22:31:57 +02:00
Carlos Valente e5fdf27f6c fix: apply delay (#494)
* fix: bug with applying delays

* fix: show delay data only in production screens

* chore: cover delay with feature test

* refactor: simplify type assertion
2023-08-23 22:31:15 +02:00
Carlos Valente 657cc22b44 feat: event cue (#473)
* feat: parse cue

* refactor: get delay from backend

* feat: add cue to UI

* refactor: remove deprecated delay logic

* refactor: extract studio clock specific logic

* refactor: extract utilities

* refactor: fix issue with missing key

* style: prevent cue overflow

* style: prevent whitespace wrap

* feat: add support for cues in integrations
2023-08-17 21:43:11 +02:00
asharonbaltazar 51c31adaf1 Swap Event Data (#446)
* use zustand store in `ContextMenuContext`

* add `withDivider` prop to context menu `Option`

* add `isDisabled` prop to `Option`

* create `eventIdSwapping` store

* create swapping context menu options

* fix `key` in ContextMenu

* address `tanstack-eslint` errors

* create initial `requestEventSwap` event

* create initial `swapEvents` action

* use `swapEvents` in `EventBlock`

* move from `emitError` to `logAxiosError`

* remove extra curly brace from Copy ID

* add `clearEventId` func

* finalize context menu swapping logic

* write optimistic swapping logic

* remove unused import and type out handler in `rundownController`

* create `rundownSwapValidator`

* move index increase to `EventBlock`

* move swapping logic from `id` to `index`

* create `swapEvents` endpoint

* move `useEventIdSwapping` hook into its own file

* revert to using event `id`

* logic now uses indexes to swap events

* add `todo` to `swapEvents`

* revert index increment

* create `swapOntimeEvents` and export in `index.ts`

* use `swapOntimeEvents` in frontend & server

* update import path

* remove extra `setCached`
2023-08-15 16:04:53 -04:00
Carlos Valente a46a0e1631 fix: typo in macos release (#488) 2023-08-12 11:52:05 +02:00
169 changed files with 3886 additions and 2258 deletions
+2 -2
View File
@@ -36,8 +36,8 @@ jobs:
uses: softprops/action-gh-release@v1
with:
files: |
'./apps/electron/dist/ontime-macOS-x64.dmg'
'./apps/electron/dist/ontime-macOS-arm64.dmg'
./apps/electron/dist/ontime-macOS-x64.dmg
./apps/electron/dist/ontime-macOS-arm64.dmg
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+29 -8
View File
@@ -1,38 +1,48 @@
# GETTING STARTED
Ontime consists of 3 distinct parts
- __client__: A React app for Ontime's UI and web clients
- __client__: A React app for Ontime's UI and web clients
- __electron__: An electron app which facilitates the cross-platform distribution of Ontime
- __server__: A node application which handles the domains services and integrations
The steps below will assume you have locally installed the necessary dependencies.
The steps below will assume you have locally installed the necessary dependencies.
Other dependencies will be installed as part of the setup
- __node__ (>=16.16)
- __pnpm__ (>=7)
- __docker__ (only necessary to run and build docker images)
## LOCAL DEVELOPMENT
The electron app is only necessary to distribute an installable version of the app and is not required for local development.
The electron app is only necessary to distribute an installable version of the app and is not required for local
development.
Locally, we would need to run both the React client and the node.js server in development mode
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`.
- 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.
Generally we have 2 types of tests.
- Unit tests for functions that contain business logic
- End-to-end tests for core features
### Unit tests
Unit tests are contained in mostly all the apps and packages (client, server and utils)
You can run unit tests by running turbo `turbo test:pipeline` from the project root.
@@ -41,12 +51,20 @@ This will run all tests and close test runner.
Alternatively you can navigate to an app or project and run `pnpm test` to run those tests in watch mode
### E2E tests
E2E tests are in a separate package. On running, [playwright](https://playwright.dev/) will spin up an instance of the webserver to test against
E2E tests are in a separate package. On running, [playwright](https://playwright.dev/) will spin up an instance of the
webserver to test against
These tests also run against a separate version of the DB (test-db)
You can run playwright tests from project root with `pnpm e2e`
When writing tests, it can be handy to run playwright in interactive mode with `pnpm e2e:i`. You would need to manually start the webserver with `pnpm dev:server`
When writing tests, it can be handy to run playwright in interactive mode with `pnpm e2e:i`. You would need to manually
start the webserver with `pnpm dev:server`
Some other useful commands
- `pnpm e2e --ui` open playwright UI
- `pnpm e2e --headed` run tests with a visible browser window
## CREATE AN INSTALLABLE FILE (Windows | MacOS | Linux)
@@ -54,6 +72,7 @@ Ontime uses Electron to distribute the application.
You can generate a distribution for your OS by running the following steps.
From the project root, run the following commands
- __Install the project dependencies__ by running `pnpm i`
- __Build the UI and server__ by running `turbo build:local`
- __Create the package__ by running `turbo dist-win`, `turbo dist-mac` or `turbo dist-linux`
@@ -66,10 +85,12 @@ Ontime provides a docker-compose file to aid with building and running docker im
While it should allow for a generic setup, it might need to be modified to fit your infrastructure.
From the project root, run the following commands
- __Install the project dependencies__ by running `pnpm i`
- __Build docker image from__ by running `docker build -t getontime/ontime`
- __Run docker image from compose__ by running `docker-compose up -d`
Other useful commands
- __List running processes__ by running `docker ps`
- __Kill running process__ by running `docker kill <process-id>`
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-ui",
"version": "2.3.8",
"version": "2.8.1-rc-table",
"private": true,
"dependencies": {
"@chakra-ui/react": "^2.7.0",
+12 -12
View File
@@ -4,9 +4,9 @@ import { ChakraProvider } from '@chakra-ui/react';
import { QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { ContextMenu } from './common/components/context-menu/ContextMenu';
import ErrorBoundary from './common/components/error-boundary/ErrorBoundary';
import { AppContextProvider } from './common/context/AppContext';
import { ContextMenuProvider } from './common/context/ContextMenuContext';
import useElectronEvent from './common/hooks/useElectronEvent';
import { ontimeQueryClient } from './common/queryClient';
import { socketClientName } from './common/stores/connectionName';
@@ -52,18 +52,18 @@ function App() {
<ChakraProvider resetCSS theme={theme}>
<QueryClientProvider client={ontimeQueryClient}>
<AppContextProvider>
<ContextMenuProvider>
<BrowserRouter>
<div className='App'>
<ErrorBoundary>
<TranslationProvider>
<BrowserRouter>
<div className='App'>
<ErrorBoundary>
<TranslationProvider>
<ContextMenu>
<AppRouter />
</TranslationProvider>
</ErrorBoundary>
<ReactQueryDevtools initialIsOpen={false} />
</div>
</BrowserRouter>
</ContextMenuProvider>
</ContextMenu>
</TranslationProvider>
</ErrorBoundary>
<ReactQueryDevtools initialIsOpen={false} />
</div>
</BrowserRouter>
</AppContextProvider>
</QueryClientProvider>
</ChakraProvider>
+2 -1
View File
@@ -1,7 +1,8 @@
import { lazy, Suspense } from 'react';
import { Navigate, Route, Routes } from 'react-router-dom';
import withData from './features/viewers/ViewWrapper';
import withAlias from './features/AliasWrapper';
import withData from './features/viewers/ViewWrapper';
const Editor = lazy(() => import('./features/editors/ProtectedEditor'));
const Cuesheet = lazy(() => import('./features/cuesheet/ProtectedCuesheet'));
+6 -6
View File
@@ -1,7 +1,5 @@
export const STATIC_PORT = 4001;
// REST stuff
export const EVENT_DATA = ['eventdata'];
export const PROJECT_DATA = ['project'];
export const ALIASES = ['aliases'];
export const USERFIELDS = ['userFields'];
export const RUNDOWN_TABLE_KEY = 'rundown';
@@ -14,12 +12,14 @@ export const RUNTIME = ['runtimeStore'];
const location = window.location;
const socketProtocol = location.protocol === 'https:' ? 'wss' : 'ws';
export const isProduction = import.meta.env.MODE === 'production';
export const serverPort = import.meta.env.DEV ? STATIC_PORT : location.port;
export const serverURL = import.meta.env.DEV ? `http://${location.hostname}:${serverPort}` : location.origin;
const STATIC_PORT = 4001;
export const serverPort = isProduction ? location.port : STATIC_PORT;
export const serverURL = `${location.protocol}//${location.hostname}:${serverPort}`;
export const websocketUrl = `${socketProtocol}://${location.hostname}:${serverPort}/ws`;
export const eventURL = `${serverURL}/eventdata`;
export const projectDataURL = `${serverURL}/project`;
export const rundownURL = `${serverURL}/events`;
export const ontimeURL = `${serverURL}/ontime`;
+8 -3
View File
@@ -6,9 +6,14 @@ import { addLog } from '../stores/logger';
import { nowInMillis } from '../utils/time';
export function logAxiosError(prepend: string, error: unknown) {
const message = axios.isAxiosError(error)
? `${prepend} ${(error as AxiosError).response?.statusText ?? ''}: ${(error as AxiosError).response?.data ?? ''}`
: `${prepend}: ${error}`;
let message;
if (axios.isAxiosError(error)) {
const statusText = (error as AxiosError).response?.statusText ?? '';
const data = (error as AxiosError).response?.data ?? '';
message = `${prepend} ${statusText}: ${data}`;
} else {
message = `${prepend}: ${error}`;
}
addLog({
id: generateId(),
@@ -1,21 +0,0 @@
import axios from 'axios';
import { EventData } from 'ontime-types';
import { eventURL } from './apiConstants';
/**
* @description HTTP request to fetch event data
* @return {Promise}
*/
export async function fetchEventData(): Promise<EventData> {
const res = await axios.get(eventURL);
return res.data;
}
/**
* @description HTTP request to mutate event data
* @return {Promise}
*/
export async function postEventData(data: EventData) {
return axios.post(eventURL, data);
}
+13
View File
@@ -50,6 +50,19 @@ export async function requestApplyDelay(eventId: string) {
return axios.patch(`${rundownURL}/applydelay/${eventId}`);
}
export type SwapEntry = {
from: string;
to: string;
};
/**
* @description HTTP request to swap two events
* @return {Promise}
*/
export async function requestEventSwap(data: SwapEntry) {
return axios.patch(`${rundownURL}/swap`, data);
}
/**
* @description HTTP request to delete given event
* @return {Promise}
+29 -3
View File
@@ -1,9 +1,10 @@
import axios from 'axios';
import axios, { AxiosResponse } from 'axios';
import {
Alias,
EventData,
OntimeRundown,
OSCSettings,
OscSubscription,
ProjectData,
Settings,
UserFields,
ViewSettings,
@@ -169,6 +170,31 @@ export const uploadData = async (file: File, setProgress: (value: number) => voi
.then((response) => response.data.id);
};
type Backend = {
rundown: OntimeRundown;
project: ProjectData;
userFields: UserFields;
};
export async function postPreviewExcel(file: File, setProgress: (value: number) => void, options?: UploadDataOptions) {
const formData = new FormData();
formData.append('userFile', file);
formData.append('options', JSON.stringify(options));
console.log('appending options', options);
const response: AxiosResponse<Backend> = await axios.post(`${ontimeURL}/previewExcel`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
onUploadProgress: (progressEvent) => {
const complete = progressEvent?.total ? Math.round((progressEvent.loaded * 100) / progressEvent.total) : 0;
setProgress(complete);
},
});
return response;
}
export type HasUpdate = {
url: string;
version: string;
@@ -186,6 +212,6 @@ export async function getLatestVersion(): Promise<HasUpdate> {
};
}
export async function postNew(initialData: Partial<EventData>) {
export async function postNew(initialData: Partial<ProjectData>) {
return axios.post(`${ontimeURL}/new`, initialData);
}
@@ -0,0 +1,21 @@
import axios from 'axios';
import { ProjectData } from 'ontime-types';
import { projectDataURL } from './apiConstants';
/**
* @description HTTP request to fetch project data
* @return {Promise}
*/
export async function getProjectData(): Promise<ProjectData> {
const res = await axios.get(projectDataURL);
return res.data;
}
/**
* @description HTTP request to mutate project data
* @return {Promise}
*/
export async function postProjectData(data: ProjectData) {
return axios.post(projectDataURL, data);
}
@@ -0,0 +1,84 @@
// logic (with some modifications) culled from:
// https://github.com/lukasbach/chakra-ui-contextmenu/blob/main/src/ContextMenu.tsx
import { Fragment, ReactElement } from 'react';
import { Menu, MenuButton, MenuDivider, MenuItem, MenuList } from '@chakra-ui/react';
import { IconType } from '@react-icons/all-files';
import { create } from 'zustand';
import style from './ContextMenu.module.scss';
type ContextMenuCoords = {
x: number;
y: number;
};
export type Option = {
label: string;
icon: IconType;
onClick: () => void;
withDivider?: boolean;
isDisabled?: boolean;
};
type ContextMenuStore = {
coords: ContextMenuCoords;
options: Option[];
isOpen: boolean;
setContextMenu: (coords: ContextMenuCoords, options: Option[]) => void;
setIsOpen: (newIsOpen: boolean) => void;
};
export const useContextMenuStore = create<ContextMenuStore>((set) => ({
coords: { x: 0, y: 0 },
options: [],
isOpen: false,
setContextMenu: (coords, options) => set(() => ({ coords, options, isOpen: true })),
setIsOpen: (newIsOpen) => set(() => ({ isOpen: newIsOpen })),
}));
interface ContextMenuProps {
// ReactElement type required due to early `return` (line 51) returning {children}
children: ReactElement;
}
export const ContextMenu = ({ children }: ContextMenuProps) => {
const { coords, options, isOpen, setIsOpen } = useContextMenuStore();
const onClose = () => {
return setIsOpen(false);
};
if (!isOpen) {
return children;
}
return (
<>
{children}
<div className={style.contextMenuBackdrop} />
<Menu isOpen gutter={0} onClose={onClose} isLazy lazyBehavior='unmount' variant='ontime-on-dark'>
<MenuButton
className={style.contextMenuButton}
aria-hidden
w={1}
h={1}
style={{
left: coords.x,
top: coords.y,
}}
/>
<MenuList>
{options.map(({ label, icon: Icon, onClick, withDivider, isDisabled }, i) => (
<Fragment key={label}>
{withDivider && <MenuDivider />}
<MenuItem key={i} icon={<Icon />} onClick={onClick} isDisabled={isDisabled}>
{label}
</MenuItem>
</Fragment>
))}
</MenuList>
</Menu>
</>
);
};
@@ -0,0 +1,22 @@
@use "../../../theme/_ontimeColours" as *;
@mixin pad-item {
padding-left: 0.5rem;
padding-right: 1rem;
}
.previewTable {
display: grid;
grid-template-columns: auto 1fr;
grid-template-rows: repeat(6, auto);
font-size: calc(1rem - 2px);
}
.field {
font-weight: 200;
@include pad-item;
}
.value {
@include pad-item;
}
@@ -0,0 +1,26 @@
import { ProjectData } from 'ontime-types';
import style from './PreviewColumn.module.scss';
interface PreviewProjectDataProps {
project: ProjectData;
}
export default function PreviewProjectData({ project }: PreviewProjectDataProps) {
return (
<div className={style.previewTable}>
<span className={style.field}>Title</span>
<span className={style.value}>{project.title}</span>
<span className={style.field}>Description</span>
<span className={style.value}>{project.description}</span>
<span className={style.field}>Public URL</span>
<span className={style.value}>{project.publicUrl}</span>
<span className={style.field}>Public info</span>
<span className={style.value}>{project.publicInfo}</span>
<span className={style.field}>Backstage URL</span>
<span className={style.value}>{project.backstageUrl}</span>
<span className={style.field}>Backstage info</span>
<span className={style.value}>{project.backstageInfo}</span>
</div>
);
}
@@ -0,0 +1,126 @@
import { ReactNode } from 'react';
import { isOntimeEvent, OntimeRundown, UserFields } from 'ontime-types';
import { millisToString } from 'ontime-utils';
import { getAccessibleColour } from '../../utils/styleUtils';
import style from './PreviewTable.module.scss';
interface PreviewRundownProps {
rundown: OntimeRundown;
userFields: UserFields;
}
function booleanToText(value?: boolean) {
return value ? 'Yes' : undefined;
}
export default function PreviewRundown({ rundown, userFields }: PreviewRundownProps) {
return (
<div className={style.container}>
<div className={style.scrollContainer}>
<table className={style.rundownPreview}>
<thead className={style.header}>
<tr>
<th>#</th>
<th>Type</th>
<th>Cue</th>
<th>Title</th>
<th>Subtitle</th>
<th>Presenter</th>
<th>Note</th>
<th>Time Start</th>
<th>Time End</th>
<th>Duration</th>
<th>Is Public</th>
<th>Skip</th>
<th>Colour</th>
<th>Timer Type</th>
<th>End Action</th>
<th>
user0 <Tag>{userFields.user0}</Tag>
</th>
<th>
user1 <Tag>{userFields.user1}</Tag>
</th>
<th>
user2 <Tag>{userFields.user2}</Tag>
</th>
<th>
user3 <Tag>{userFields.user3}</Tag>
</th>
<th>
user4 <Tag>{userFields.user4}</Tag>
</th>
<th>
user5 <Tag>{userFields.user5}</Tag>
</th>
<th>
user6 <Tag>{userFields.user6}</Tag>
</th>
<th>
user7 <Tag>{userFields.user7}</Tag>
</th>
<th>
user8 <Tag>{userFields.user8}</Tag>
</th>
<th>
user9 <Tag>{userFields.user9}</Tag>
</th>
</tr>
</thead>
<tbody className={style.body}>
{rundown.map((event, index) => {
const key = event.id;
if (isOntimeEvent(event)) {
const colour = event.colour ? getAccessibleColour(event.colour) : {};
const isPublic = booleanToText(event.isPublic);
const skip = booleanToText(event.skip);
return (
<tr key={key}>
<th>
<Tag>{index + 1}</Tag>
</th>
<th>Event</th>
<th>{event.cue}</th>
<th>{event.title}</th>
<th>{event.subtitle}</th>
<th>{event.presenter}</th>
<th>{event.note}</th>
<th>{millisToString(event.timeStart)}</th>
<th>{millisToString(event.timeEnd)}</th>
<th>{millisToString(event.duration)}</th>
<th>{isPublic && <Tag>{isPublic}</Tag>}</th>
<th>{skip && <Tag>{skip}</Tag>}</th>
<th style={{ ...colour }}>{event.colour}</th>
<th>
<Tag>{event.timerType}</Tag>
</th>
<th>
<Tag>{event.endAction}</Tag>
</th>
<th>{event.user0}</th>
<th>{event.user1}</th>
<th>{event.user2}</th>
<th>{event.user3}</th>
<th>{event.user4}</th>
<th>{event.user5}</th>
<th>{event.user6}</th>
<th>{event.user7}</th>
<th>{event.user8}</th>
<th>{event.user9}</th>
</tr>
);
}
return null;
})}
</tbody>
</table>
</div>
</div>
);
}
function Tag({ children }: { children: ReactNode }) {
return <span className={style.tag}>{children}</span>;
}
@@ -0,0 +1,55 @@
@use "../../../theme/_ontimeColours" as *;
.container {
max-width: 100%;
max-height: max(300px, 30vh);
overflow: scroll;
}
.scrollContainer {
overflow-x: scroll;
}
.rundownPreview {
font-size: calc(1rem - 2px);
border-collapse: separate;
}
.header,
.body {
th {
font-weight: 400;
height: unset;
line-height: calc(1rem - 2px);
white-space: nowrap;
padding-left: 0.25rem;
padding-right: 1rem;
}
}
.header {
th {
font-weight: 200;
text-align: left;
}
tr {
word-wrap: unset;
}
}
.body {
tr:nth-child(odd) {
background-color: $gray-50;
}
}
.tag {
font-size: 10px;
background-color: $blue-500;
color: $pure-white;
border-radius: 2px;
padding: 0 0.25rem;
white-space: nowrap;
}
@@ -0,0 +1,34 @@
import { UserFields } from 'ontime-types';
import style from './PreviewColumn.module.scss';
interface PreviewUserFieldProps {
userFields: UserFields;
}
export default function PreviewUserField({ userFields }: PreviewUserFieldProps) {
return (
<div className={style.previewTable}>
<span className={style.field}>user0</span>
<span className={style.value}>{userFields.user0}</span>
<span className={style.field}>user1</span>
<span className={style.value}>{userFields.user1}</span>
<span className={style.field}>user2</span>
<span className={style.value}>{userFields.user2}</span>
<span className={style.field}>user3</span>
<span className={style.value}>{userFields.user3}</span>
<span className={style.field}>user4</span>
<span className={style.value}>{userFields.user4}</span>
<span className={style.field}>user5</span>
<span className={style.value}>{userFields.user5}</span>
<span className={style.field}>user6</span>
<span className={style.value}>{userFields.user6}</span>
<span className={style.field}>user7</span>
<span className={style.value}>{userFields.user7}</span>
<span className={style.field}>user8</span>
<span className={style.value}>{userFields.user8}</span>
<span className={style.field}>user9</span>
<span className={style.value}>{userFields.user9}</span>
</div>
);
}
@@ -1,6 +1,6 @@
import { useCallback } from 'react';
import { TitleActions } from '../../../../features/event-editor/composite/EventEditorTitles';
import { TitleActions } from '../../../../features/event-editor/composite/EventEditorDataLeft';
import Swatch from './Swatch';
@@ -37,7 +37,17 @@ function ButtonTooltip(name: TimeEntryField, warning?: string) {
}
export default function TimeInput(props: TimeInputProps) {
const { id, name, submitHandler, time = 0, delay = 0, placeholder, validationHandler, previousEnd = 0, warning } = props;
const {
id,
name,
submitHandler,
time = 0,
delay = 0,
placeholder,
validationHandler,
previousEnd = 0,
warning,
} = props;
const { emitError } = useEmitLog();
const inputRef = useRef<HTMLInputElement | null>(null);
const [value, setValue] = useState<string>('');
@@ -191,7 +201,7 @@ export default function TimeInput(props: TimeInputProps) {
<Input
ref={inputRef}
id={id}
data-testid='time-input'
data-testid={`time-input-${name}`}
className={style.inputField}
type='text'
placeholder={placeholder}
@@ -6,10 +6,11 @@ import ScheduleItem from './ScheduleItem';
import './Schedule.scss';
interface ScheduleProps {
isProduction?: boolean;
className?: string;
}
export default function Schedule({ className }: ScheduleProps) {
export default function Schedule({ isProduction, className }: ScheduleProps) {
const { paginatedEvents, selectedEventId, isBackstage, scheduleType } = useSchedule();
if (paginatedEvents?.length < 1) {
@@ -30,12 +31,16 @@ export default function Schedule({ className }: ScheduleProps) {
selectedState = 'future';
}
}
const timeStart = isProduction ? event.timeStart + (event?.delay ?? 0) : event.timeStart;
const timeEnd = isProduction ? event.timeEnd + (event?.delay ?? 0) : event.timeEnd;
return (
<ScheduleItem
key={event.id}
selected={selectedState}
timeStart={event.timeStart}
timeEnd={event.timeEnd}
timeStart={timeStart}
timeEnd={timeEnd}
title={event.title}
colour={isBackstage ? event.colour : ''}
backstageEvent={!event.isPublic}
@@ -1,76 +0,0 @@
// logic (with some modifications) culled from:
// https://github.com/lukasbach/chakra-ui-contextmenu/blob/main/src/ContextMenu.tsx
import { createContext, ReactNode, useState } from 'react';
import { Menu, MenuButton, MenuItem, MenuList } from '@chakra-ui/react';
import { IconType } from '@react-icons/all-files';
import style from './ContextMenuContext.module.scss';
type ContextMenuCoords = {
x: number;
y: number;
};
type ContextMenuContextType = {
createContextMenu: (options: Option[], menuCoordinates: ContextMenuCoords) => void;
};
export const ContextMenuContext = createContext<ContextMenuContextType | null>(null);
export type Option = {
label: string;
icon: IconType;
onClick: () => void;
};
interface ContextMenuProviderProps {
children: ReactNode;
}
export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
const [isOpen, setIsOpen] = useState(false);
const [coords, setCoords] = useState<ContextMenuCoords>({ x: 0, y: 0 });
const [options, setOptions] = useState<Option[]>([]);
const onClose = () => {
return setIsOpen(false);
};
const createContextMenu = (options: Option[], menuCoords: ContextMenuCoords) => {
setCoords(menuCoords);
setOptions(options);
setIsOpen(true);
};
return (
<ContextMenuContext.Provider value={{ createContextMenu }}>
{children}
{isOpen && (
<>
<div className={style.contextMenuBackdrop} />
<Menu isOpen gutter={0} onClose={onClose} isLazy lazyBehavior='unmount' variant='ontime-on-dark'>
<MenuButton
className={style.contextMenuButton}
aria-hidden
w={1}
h={1}
style={{
left: coords.x,
top: coords.y,
}}
/>
<MenuList>
{options.map(({ label, icon: Icon, onClick }, i) => (
<MenuItem key={i} icon={<Icon />} onClick={onClick}>
{label}
</MenuItem>
))}
</MenuList>
</Menu>
</>
)}
</ContextMenuContext.Provider>
);
};
@@ -1,15 +1,15 @@
import { useQuery } from '@tanstack/react-query';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { EVENT_DATA } from '../api/apiConstants';
import { fetchEventData } from '../api/eventDataApi';
import { eventDataPlaceholder } from '../models/EventData';
import { PROJECT_DATA } from '../api/apiConstants';
import { getProjectData } from '../api/projectDataApi';
import { projectDataPlaceholder } from '../models/ProjectData';
export default function useEventData() {
export default function useProjectData() {
const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: EVENT_DATA,
queryFn: fetchEventData,
placeholderData: eventDataPlaceholder,
queryKey: PROJECT_DATA,
queryFn: getProjectData,
placeholderData: projectDataPlaceholder,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
@@ -1,22 +1,16 @@
import { MouseEvent, useContext } from 'react';
import { MouseEvent } from 'react';
import { ContextMenuContext, Option } from '../context/ContextMenuContext';
import { Option, useContextMenuStore } from '../components/context-menu/ContextMenu';
export const useContextMenu = <T extends HTMLElement>(options: Option[]) => {
const contextMenuContext = useContext(ContextMenuContext);
if (contextMenuContext === null) {
throw new Error('useContextMenu should be wrapped by ContextMenuProvider');
}
const { createContextMenu } = contextMenuContext;
const { setContextMenu } = useContextMenuStore();
const localCreateContextMenu = (contextMenuEvent: MouseEvent<T, globalThis.MouseEvent>) => {
// prevent browser default context menu from showing up
contextMenuEvent.preventDefault();
const { pageX, pageY } = contextMenuEvent;
return createContextMenu(options, { x: pageX, y: pageY });
return setContextMenu({ x: pageX, y: pageY }, options);
};
return [localCreateContextMenu];
+86 -12
View File
@@ -1,6 +1,7 @@
import { useCallback } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { OntimeRundown, OntimeRundownEntry, SupportedEvent } from 'ontime-types';
import { isOntimeEvent, OntimeRundown, OntimeRundownEntry } from 'ontime-types';
import { getCueCandidate, swapOntimeEvents } from 'ontime-utils';
import { RUNDOWN_TABLE, RUNDOWN_TABLE_KEY } from '../api/apiConstants';
import { logAxiosError } from '../api/apiUtils';
@@ -9,9 +10,11 @@ import {
requestApplyDelay,
requestDelete,
requestDeleteAll,
requestEventSwap,
requestPostEvent,
requestPutEvent,
requestReorderEvent,
SwapEntry,
} from '../api/eventsApi';
import { useEditorSettings } from '../stores/editorSettings';
@@ -28,9 +31,10 @@ export const useEventAction = () => {
* Calls mutation to add new event
* @private
*/
const _addEventMutation = useMutation(requestPostEvent, {
const _addEventMutation = useMutation({
// Mutation finished, failed or successful
// Fetch anyway, just to be sure
mutationFn: requestPostEvent,
onSettled: () => {
queryClient.invalidateQueries(RUNDOWN_TABLE);
},
@@ -55,7 +59,7 @@ export const useEventAction = () => {
const newEvent: Partial<OntimeRundownEntry> = { ...event };
// ************* CHECK OPTIONS specific to events
if (newEvent.type === SupportedEvent.Event) {
if (isOntimeEvent(newEvent)) {
const applicationOptions = {
defaultPublic: options?.defaultPublic ?? defaultPublic,
startTimeIsLastEnd: options?.startTimeIsLastEnd ?? startTimeIsLastEnd,
@@ -63,16 +67,20 @@ export const useEventAction = () => {
after: options?.after,
};
if (newEvent?.cue === undefined) {
newEvent.cue = getCueCandidate(queryClient.getQueryData(RUNDOWN_TABLE) || [], options?.after);
}
// hard coding duration value to be as expected for now
// this until timeOptions gets implemented
if (typeof newEvent?.timeStart !== 'undefined' && typeof newEvent.timeEnd !== 'undefined') {
if (newEvent?.timeStart !== undefined && newEvent.timeEnd !== undefined) {
newEvent.duration = Math.max(0, newEvent?.timeEnd - newEvent?.timeStart) || 0;
}
if (applicationOptions.startTimeIsLastEnd && applicationOptions?.lastEventId) {
const rundown = queryClient.getQueryData(RUNDOWN_TABLE) as OntimeRundown;
const previousEvent = rundown.find((event) => event.id === applicationOptions.lastEventId);
if (typeof previousEvent !== 'undefined' && previousEvent.type === 'event') {
if (previousEvent !== undefined && previousEvent.type === 'event') {
newEvent.timeStart = previousEvent.timeEnd;
newEvent.timeEnd = previousEvent.timeEnd;
}
@@ -102,7 +110,8 @@ export const useEventAction = () => {
* Calls mutation to update existing event
* @private
*/
const _updateEventMutation = useMutation(requestPutEvent, {
const _updateEventMutation = useMutation({
mutationFn: requestPutEvent,
// we optimistically update here
onMutate: async (newEvent) => {
// cancel ongoing queries
@@ -117,7 +126,6 @@ export const useEventAction = () => {
// Return a context with the previous and new events
return { previousEvent, newEvent };
},
// Mutation fails, rollback undoes optimist update
onError: (_error, _newEvent, context) => {
queryClient.setQueryData([RUNDOWN_TABLE_KEY, context?.newEvent.id], context?.previousEvent);
@@ -148,7 +156,8 @@ export const useEventAction = () => {
* Calls mutation to delete an event
* @private
*/
const _deleteEventMutation = useMutation(requestDelete, {
const _deleteEventMutation = useMutation({
mutationFn: requestDelete,
// we optimistically update here
onMutate: async (eventId) => {
// cancel ongoing queries
@@ -196,7 +205,8 @@ export const useEventAction = () => {
* Calls mutation to delete all events
* @private
*/
const _deleteAllEventsMutation = useMutation(requestDeleteAll, {
const _deleteAllEventsMutation = useMutation({
mutationFn: requestDeleteAll,
// we optimistically update here
onMutate: async () => {
// cancel ongoing queries
@@ -239,7 +249,8 @@ export const useEventAction = () => {
* Calls mutation to apply a delay
* @private
*/
const _applyDelayMutation = useMutation(requestApplyDelay, {
const _applyDelayMutation = useMutation({
mutationFn: requestApplyDelay,
// Mutation finished, failed or successful
onSettled: () => {
queryClient.invalidateQueries(RUNDOWN_TABLE);
@@ -265,7 +276,8 @@ export const useEventAction = () => {
* Calls mutation to reorder an event
* @private
*/
const _reorderEventMutation = useMutation(requestReorderEvent, {
const _reorderEventMutation = useMutation({
mutationFn: requestReorderEvent,
// we optimistically update here
onMutate: async (data) => {
// cancel ongoing queries
@@ -316,5 +328,67 @@ export const useEventAction = () => {
[_reorderEventMutation],
);
return { addEvent, updateEvent, deleteEvent, deleteAllEvents, applyDelay, reorderEvent };
/**
* Calls mutation to swap events
* @private
*/
const _swapEvents = useMutation({
mutationFn: requestEventSwap,
// we optimistically update here
onMutate: async ({ from, to }) => {
// cancel ongoing queries
await queryClient.cancelQueries(RUNDOWN_TABLE, { exact: true });
// Snapshot the previous value
const rundown = queryClient.getQueryData(RUNDOWN_TABLE) as OntimeRundown;
const fromEventIndex = rundown.findIndex((event) => event.id === from);
const toEventIndex = rundown.findIndex((event) => event.id === to);
const previousEvents = swapOntimeEvents(rundown, fromEventIndex, toEventIndex);
// optimistically update object
queryClient.setQueryData(RUNDOWN_TABLE, previousEvents);
// Return a context with the previous events
return { previousEvents };
},
// Mutation fails, rollback undoes optimist update
onError: (_error, _eventId, context) => {
queryClient.setQueryData(RUNDOWN_TABLE, context?.previousEvents);
},
// Mutation finished, failed or successful
// Fetch anyway, just to be sure
onSettled: () => {
queryClient.invalidateQueries(RUNDOWN_TABLE);
},
networkMode: 'always',
});
/**
* Swaps the schedule of two events
*/
const swapEvents = useCallback(
async ({ from, to }: SwapEntry) => {
// TODO: before calling `/swapEvents`,
// we should determine the events are of type `OntimeEvent`
try {
await _swapEvents.mutateAsync({ from, to });
} catch (error) {
logAxiosError('Error re-ordering event', error);
}
},
[_swapEvents],
);
return {
addEvent,
updateEvent,
deleteEvent,
deleteAllEvents,
applyDelay,
reorderEvent,
swapEvents,
};
};
@@ -1,6 +1,6 @@
import { EventData } from 'ontime-types';
import { ProjectData } from 'ontime-types';
export const eventDataPlaceholder: EventData = {
export const projectDataPlaceholder: ProjectData = {
title: '',
description: '',
publicUrl: '',
@@ -1,536 +0,0 @@
import { formatEventList, getEventsWithDelay, trimRundown } from '../eventsManager';
describe('getEventsWithDelay function', () => {
test('with positive delays', () => {
const testData = [
{
title: 'Welcome to Ontime',
timeStart: 28800000,
timeEnd: 30600000,
colour: '',
type: 'event',
id: '5946',
},
{
duration: 60000,
type: 'delay',
id: '24240',
},
{
title: 'Unless recalled by the OSC address',
timeStart: 34920000,
timeEnd: 35520000,
colour: '',
type: 'event',
id: '8ee5',
},
{
title: 'Use simpler times to create a timer',
timeStart: 120000,
timeEnd: 720000,
colour: '',
type: 'event',
id: '8222',
},
{
duration: 900000,
type: 'delay',
revision: 0,
id: 'a386',
},
{
title: 'Add delay blocks to affect all events',
timeStart: 37320000,
timeEnd: 38520000,
colour: '',
type: 'event',
id: '6dce',
},
{
title: 'Add and remove events with [+] and [-]',
timeStart: 38520000,
timeEnd: 45120000,
colour: '',
type: 'event',
id: '2651',
},
{
type: 'block',
id: 'e6a1',
},
{
title: 'And control whether they are public',
timeStart: 46800000,
timeEnd: 57600000,
colour: '',
type: 'event',
id: '1358',
},
];
const expected = [
{
title: 'Welcome to Ontime',
timeStart: 28800000,
timeEnd: 30600000,
colour: '',
type: 'event',
id: '5946',
},
{
title: 'Unless recalled by the OSC address',
timeStart: 34920000 + 60000,
timeEnd: 35520000 + 60000,
colour: '',
type: 'event',
id: '8ee5',
},
{
title: 'Use simpler times to create a timer',
timeStart: 120000 + 60000,
timeEnd: 720000 + 60000,
colour: '',
type: 'event',
id: '8222',
},
{
title: 'Add delay blocks to affect all events',
timeStart: 37320000 + 60000 + 900000,
timeEnd: 38520000 + 60000 + 900000,
colour: '',
type: 'event',
id: '6dce',
},
{
title: 'Add and remove events with [+] and [-]',
timeStart: 38520000 + 60000 + 900000,
timeEnd: 45120000 + 60000 + 900000,
colour: '',
type: 'event',
id: '2651',
},
{
title: 'And control whether they are public',
timeStart: 46800000,
timeEnd: 57600000,
colour: '',
type: 'event',
id: '1358',
},
];
expect(getEventsWithDelay(testData)).toStrictEqual(expected);
});
test('with negative delays', () => {
const testData = [
{
duration: -20,
type: 'delay',
id: '24240',
},
{
title: 'Welcome to Ontime',
timeStart: 100,
timeEnd: 200,
colour: '',
type: 'event',
id: '5946',
},
];
const expected = [
{
title: 'Welcome to Ontime',
timeStart: 80,
timeEnd: 180,
colour: '',
type: 'event',
id: '5946',
},
];
expect(getEventsWithDelay(testData)).toStrictEqual(expected);
});
});
describe('getEventsWithDelay edge cases', () => {
it('ensures time start cannot be below 0', () => {
const testData = [
{
duration: -200,
type: 'delay',
id: '24240',
},
{
title: 'Welcome to Ontime',
timeStart: 10,
timeEnd: 20,
colour: '',
type: 'event',
id: '5946',
},
];
const expected = [
{
title: 'Welcome to Ontime',
timeStart: 0,
timeEnd: 0,
colour: '',
type: 'event',
id: '5946',
},
];
expect(getEventsWithDelay(testData)).toStrictEqual(expected);
});
it('does not modify original array', () => {
const testData = [
{
duration: 10,
type: 'delay',
id: '24240',
},
{
title: 'Welcome to Ontime',
timeStart: 10,
timeEnd: 20,
colour: '',
type: 'event',
id: '5946',
},
];
const expected = [
{
title: 'Welcome to Ontime',
timeStart: 20,
timeEnd: 30,
colour: '',
type: 'event',
id: '5946',
},
];
const expectedSafe = [
{
title: 'Welcome to Ontime',
timeStart: 20,
timeEnd: 30,
colour: '',
type: 'event',
id: '5946',
},
];
expect(getEventsWithDelay(testData)).toStrictEqual(expected);
expect(getEventsWithDelay(expectedSafe)).toStrictEqual(expected);
});
it('given an empty array', () => {
const emptyArray = {
test: [],
expect: [],
};
expect(getEventsWithDelay(emptyArray.test)).toStrictEqual(emptyArray.expect);
});
it('given an undefined object', () => {
const withUndefined = {
test: undefined,
expect: [],
};
expect(getEventsWithDelay(withUndefined.test)).toStrictEqual(withUndefined.expect);
});
it('given a corrupted event object', () => {
const testData = [
{
title: 'Welcome to Ontime',
timeEnd: 30600000,
colour: '',
type: 'event',
id: '5946',
},
{
duration: 60000,
type: 'delay',
id: '24240',
},
{
title: 'Unless recalled by the OSC address',
timeStart: 34920000,
timeEnd: 35520000,
colour: '',
type: 'event',
id: '8ee5',
},
];
const expected = [
{
title: 'Welcome to Ontime',
timeEnd: 30600000,
colour: '',
type: 'event',
id: '5946',
},
{
title: 'Unless recalled by the OSC address',
timeStart: 34920000 + 60000,
timeEnd: 35520000 + 60000,
colour: '',
type: 'event',
id: '8ee5',
},
];
expect(getEventsWithDelay(testData)).toStrictEqual(expected);
});
it('given a corrupted delay object', () => {
const testData = [
{
title: 'Welcome to Ontime',
timeStart: 28800000,
timeEnd: 30600000,
colour: '',
type: 'event',
id: '5946',
},
{
type: 'delay',
id: '24240',
},
{
title: 'Unless recalled by the OSC address',
timeStart: 34920000,
timeEnd: 35520000,
colour: '',
type: 'event',
id: '8ee5',
},
];
const expected = [
{
title: 'Welcome to Ontime',
timeStart: 28800000,
timeEnd: 30600000,
colour: '',
type: 'event',
id: '5946',
},
{
title: 'Unless recalled by the OSC address',
timeStart: 34920000,
timeEnd: 35520000,
colour: '',
type: 'event',
id: '8ee5',
},
];
expect(getEventsWithDelay(testData)).toStrictEqual(expected);
});
});
describe('test trimEventlist function', () => {
const limit = 8;
const testData = [
{ id: '1' },
{ id: '2' },
{ id: '3' },
{ id: '4' },
{ id: '5' },
{ id: '6' },
{ id: '7' },
{ id: '8' },
{ id: '9' },
{ id: '10' },
{ id: '11' },
{ id: '12' },
];
it('when we use the first item', () => {
const selectedId = '1';
const expected = [
{ id: '1' },
{ id: '2' },
{ id: '3' },
{ id: '4' },
{ id: '5' },
{ id: '6' },
{ id: '7' },
{ id: '8' },
];
const l = trimRundown(testData, selectedId, limit);
expect(l.length).toBe(limit);
expect(l).toStrictEqual(expected);
});
it('when we use the third item', () => {
const selectedId = '3';
const expected = [
{ id: '1' },
{ id: '2' },
{ id: '3' },
{ id: '4' },
{ id: '5' },
{ id: '6' },
{ id: '7' },
{ id: '8' },
];
const l = trimRundown(testData, selectedId, limit);
expect(l.length).toBe(limit);
expect(l).toStrictEqual(expected);
});
it('when we use the fourth item', () => {
const selectedId = '4';
const expected = [
{ id: '2' },
{ id: '3' },
{ id: '4' },
{ id: '5' },
{ id: '6' },
{ id: '7' },
{ id: '8' },
{ id: '9' },
];
const l = trimRundown(testData, selectedId, limit);
expect(l.length).toBe(limit);
expect(l).toStrictEqual(expected);
});
it('if selected is not found', () => {
const selectedId = '15';
const expected = [
{ id: '1' },
{ id: '2' },
{ id: '3' },
{ id: '4' },
{ id: '5' },
{ id: '6' },
{ id: '7' },
{ id: '8' },
];
const l = trimRundown(testData, selectedId, limit);
expect(l.length).toBe(limit);
expect(l).toStrictEqual(expected);
});
});
describe('test formatEvents function', () => {
const testEvent = [
{
title: 'Welcome to Ontime',
subtitle: 'Subtitles are useful',
presenter: 'cpvalente',
note: 'Maybe a running note for the operator?',
timeStart: 28800000,
timeEnd: 30600000,
isPublic: false,
colour: '',
type: 'event',
revision: 0,
id: '5946',
},
{
title: 'Unless recalled by the OSC address',
subtitle: '',
presenter: '',
note: 'In green, below',
timeStart: 34800000,
timeEnd: 35400000,
isPublic: false,
colour: '',
type: 'event',
revision: 0,
id: '8ee5',
},
];
it('it parses correctly', () => {
const selectedId = 'otherEvent';
const nextId = 'notHere';
const expected = [
{
id: '5946',
time: '08:00 - 08:30',
title: 'Welcome to Ontime',
isNow: false,
isNext: false,
colour: '',
},
{
id: '8ee5',
time: '09:40 - 09:50',
title: 'Unless recalled by the OSC address',
isNow: false,
isNext: false,
colour: '',
},
];
const parsed = formatEventList(testEvent, selectedId, nextId, { showEnd: true });
expect(parsed).toStrictEqual(expected);
});
it('it handles selected correctly', () => {
const selectedId = '5946';
const nextId = '8ee5';
const expected = [
{
id: '5946',
time: '08:00 - 08:30',
title: 'Welcome to Ontime',
isNow: true,
isNext: false,
colour: '',
},
{
id: '8ee5',
time: '09:40 - 09:50',
title: 'Unless recalled by the OSC address',
isNow: false,
isNext: true,
colour: '',
},
];
const parsed = formatEventList(testEvent, selectedId, nextId, { showEnd: true });
expect(parsed).toStrictEqual(expected);
});
it('it handles next correctly', () => {
const selectedId = '8ee5';
const nextId = 'notHere';
const expected = [
{
id: '5946',
time: '08:00 - 08:30',
title: 'Welcome to Ontime',
isNow: false,
isNext: false,
colour: '',
},
{
id: '8ee5',
time: '09:40 - 09:50',
title: 'Unless recalled by the OSC address',
isNow: true,
isNext: false,
colour: '',
},
];
const parsed = formatEventList(testEvent, selectedId, nextId, { showEnd: true });
expect(parsed).toStrictEqual(expected);
});
});
@@ -0,0 +1,55 @@
import { EndAction, OntimeEvent, SupportedEvent, TimerType } from 'ontime-types';
import { cloneEvent } from '../eventsManager';
describe('cloneEvent()', () => {
it('creates a stem from a given event', () => {
const original = {
id: 'unique',
type: SupportedEvent.Event,
title: 'title',
cue: 'cue',
subtitle: 'subtitle',
presenter: 'presenter',
note: 'note',
timeStart: 0,
duration: 10,
timeEnd: 10,
timerType: TimerType.CountDown,
endAction: EndAction.None,
isPublic: false,
skip: false,
colour: 'F00',
revision: 10,
user0: 'user0',
user1: 'user1',
user2: 'user2',
user3: 'user3',
user4: 'user4',
user5: 'user5',
user6: 'user6',
user7: 'user7',
user8: 'user8',
user9: 'user9',
} as OntimeEvent;
const cloned = cloneEvent(original);
expect(cloned).not.toBe(original);
// @ts-expect-error -- safeguarding this
expect(cloned?.id).toBe(undefined);
expect(cloned.title).toBe(original.title);
expect(cloned.subtitle).toBe(original.subtitle);
expect(cloned.presenter).toBe(original.presenter);
expect(cloned.note).toBe(original.note);
expect(cloned.endAction).toBe(original.endAction);
expect(cloned.timerType).toBe(original.timerType);
expect(cloned.timeStart).toBe(original.timeStart);
expect(cloned.timeEnd).toBe(original.timeEnd);
expect(cloned.duration).toBe(original.duration);
expect(cloned.isPublic).toBe(original.isPublic);
expect(cloned.skip).toBe(original.skip);
expect(cloned.colour).toBe(original.colour);
expect(cloned.type).toBe(SupportedEvent.Event);
expect(cloned.revision).toBe(0);
});
});
@@ -1,56 +0,0 @@
import getDelayTo from '../getDelayTo';
describe('getDelayTo function', () => {
it('handles list with delays', () => {
const delayDuration = 100;
const events = [
{ type: 'event' },
{ type: 'delay', duration: delayDuration },
{ type: 'event' },
];
const notDelayed = getDelayTo(events, 0);
expect(notDelayed).toBe(0);
const delayedEvent = getDelayTo(events, 2);
expect(delayedEvent).toBe(delayDuration);
});
it('handles list without delays', () => {
const events = [{ type: 'event' }, { type: 'event' }];
const notDelayed = getDelayTo(events, 1);
expect(notDelayed).toBe(0);
});
it('handles list with multiple delays', () => {
const delayDuration = 100;
const events = [
{ type: 'event' },
{ type: 'delay', duration: delayDuration },
{ type: 'event' },
{ type: 'delay', duration: delayDuration },
{ type: 'event' },
];
const doubleDelay = getDelayTo(events, 4);
expect(doubleDelay).toBe(delayDuration * 2);
});
it('handles list with blocks', () => {
const events = [
{ type: 'event' },
{ type: 'delay', duration: 100 },
{ type: 'event' },
{ type: 'block' },
{ type: 'event' },
];
const notDelayed = getDelayTo(events, 4);
expect(notDelayed).toBe(0);
});
it('handles index greater than list', () => {
const events = [{ type: 'event' }, { type: 'delay', duration: 100 }, { type: 'event' }];
const notDelayed = getDelayTo(events, 3);
expect(notDelayed).toBe(0);
});
it('handles negative index (not found)', () => {
const events = [{ type: 'event' }, { type: 'delay', duration: 100 }, { type: 'event' }];
const notDelayed = getDelayTo(events, -1);
expect(notDelayed).toBe(0);
});
});
+1 -1
View File
@@ -1,6 +1,6 @@
import { Alias } from 'ontime-types';
import isEqual from 'react-fast-compare';
import { Location, resolvePath } from 'react-router-dom';
import { Alias } from 'ontime-types';
/**
* Validates an alias against defined parameters
+13 -155
View File
@@ -1,174 +1,32 @@
import { OntimeEvent, OntimeRundownEntry, SupportedEvent } from 'ontime-types';
import { formatTime } from './time';
/**
* @description From a list of events, returns only events of type event with calculated delays
* @param {Object[]} rundown - given rundown
* @returns {Object[]} Filtered events with calculated delays
*/
export const getEventsWithDelay = (rundown: OntimeRundownEntry[]): OntimeEvent[] => {
if (rundown == null) return [];
const delayedEvents: OntimeEvent[] = [];
// Add running delay
let delay = 0;
for (const event of rundown) {
if (event.type === SupportedEvent.Block) delay = 0;
else if (event.type === SupportedEvent.Delay) {
if (typeof event.duration === 'number') {
delay += event.duration;
}
} else if (event.type === SupportedEvent.Event) {
const delayedEvent = { ...event };
if (delay !== 0) {
delayedEvent.timeStart = Math.max(delayedEvent.timeStart + delay, 0);
delayedEvent.timeEnd = Math.max(delayedEvent.timeEnd + delay, 0);
}
delayedEvents.push(delayedEvent);
}
}
return delayedEvents;
};
/**
* @description Returns trimmed event list array
* @param {Object[]} rundown - given rundown
* @param {string} selectedId - id of currently selected event
* @param {number} limit - max number of events to return
* @returns {Object[]} Event list with maximum <limit> objects
*/
export const trimRundown = (rundown: OntimeEvent[], selectedId: string, limit: number): OntimeEvent[] => {
if (rundown == null) return [];
const BEFORE = 2;
const trimmedRundown = [...rundown];
// limit events length if necessary
if (limit != null) {
while (trimmedRundown.length > limit) {
const idx = trimmedRundown.findIndex((e) => e.id === selectedId);
if (idx <= BEFORE) {
trimmedRundown.pop();
} else {
trimmedRundown.shift();
}
}
}
return trimmedRundown;
};
type FormatEventListOptionsProp = {
showEnd?: boolean;
};
/**
* @description Returns list of events formatted to be displayed
* @param {Object[]} rundown - given rundown
* @param {string} selectedId - id of currently selected event
* @param {string} nextId - id of next event
* @param {object} [options]
* @param {boolean} [options.showEnd] - whether to show the end time
* @returns {Object[]} Formatted list of events [{time: -, title: -, isNow, isNext}]
*/
export const formatEventList = (
rundown: OntimeEvent[],
selectedId: string,
nextId: string,
options: FormatEventListOptionsProp,
): ScheduleEvent[] => {
if (rundown == null) return [];
const { showEnd = false } = options;
const givenEvents = [...rundown];
// format list
const formattedEvents = [];
for (const event of givenEvents) {
const start = formatTime(event.timeStart);
const end = formatTime(event.timeEnd);
formattedEvents.push({
id: event.id,
time: showEnd ? `${start} - ${end}` : start,
title: event.title,
isNow: event.id === selectedId,
isNext: event.id === nextId,
colour: event.colour,
});
}
return formattedEvents;
};
export type ScheduleEvent = {
id: string;
time: string;
title: string;
isNow: boolean;
isNext: boolean;
colour: string;
};
import { OntimeEvent, SupportedEvent } from 'ontime-types';
/**
* @description Creates a safe duplicate of an event
* @param {object} event
* @return {object} clean event
* @param {OntimeEvent} event
* @param {string} [after]
* @return {OntimeEvent} clean event
*/
type ClonedEvent = OntimeEvent | { after?: string };
type ClonedEvent = Omit<
OntimeEvent,
'id' | 'user0' | 'user1' | 'user2' | 'user3' | 'user4' | 'user5' | 'user6' | 'user7' | 'user8' | 'user9'
>;
export const cloneEvent = (event: OntimeEvent, after?: string): ClonedEvent => {
return {
type: SupportedEvent.Event,
title: event.title,
cue: event.cue,
subtitle: event.subtitle,
presenter: event.presenter,
note: event.note,
timeStart: event.timeStart,
duration: event.duration,
timeEnd: event.timeEnd,
timerType: event.timerType,
endAction: event.endAction,
isPublic: event.isPublic,
skip: event.skip,
colour: event.colour,
after: after,
revision: 0,
};
};
/**
* Gets first event in rundown, if it exists
* @param {OntimeRundownEntry[]} rundown
* @return {OntimeEvent | null}
*/
export function getFirstEvent(rundown: OntimeRundownEntry[]) {
return rundown.length ? rundown[0] : null;
}
/**
* Gets next event in rundown, if it exists
* @param {OntimeRundownEntry[]} rundown
* @param {string} currentId
* @return {OntimeEvent | null}
*/
export function getNextEvent(rundown: OntimeRundownEntry[], currentId: string) {
const index = rundown.findIndex((event) => event.id === currentId);
if (index !== -1 && index + 1 < rundown.length) {
return rundown[index + 1];
} else {
return null;
}
}
/**
* Gets previous event in rundown, if it exists
* @param {OntimeRundownEntry[]} rundown
* @param {string} currentId
* @return {OntimeEvent | null}
*/
export function getPreviousEvent(rundown: OntimeRundownEntry[], currentId: string) {
const index = rundown.findIndex((event) => event.id === currentId);
if (index !== -1 && index - 1 >= 0) {
return rundown[index - 1];
} else {
return null;
}
}
@@ -1,25 +0,0 @@
/**
* @description calculates delay to a given event
* @param {array} events
* @param {number} eventIndex
* @return {number} - delay value of given event
*/
export default function getDelayTo(events, eventIndex) {
let delay = 0;
let index = 0;
if (eventIndex >= 0) {
for (const event of events) {
if (eventIndex === index) {
return delay;
}
if (event.type === 'delay') {
delay += event.duration;
} else if (event.type === 'block') {
delay = 0;
}
index++;
}
}
return 0;
}
+2 -2
View File
@@ -1,6 +1,6 @@
import { Log, RuntimeStore } from 'ontime-types';
import { RUNTIME, websocketUrl } from '../api/apiConstants';
import { isProduction, RUNTIME, websocketUrl } from '../api/apiConstants';
import { ontimeQueryClient } from '../queryClient';
import { socketClientName } from '../stores/connectionName';
import { addLog } from '../stores/logger';
@@ -64,7 +64,7 @@ export const connectSocket = (preferredClientName?: string) => {
}
case 'ontime': {
runtime.setState(payload as RuntimeStore);
if (import.meta.env.DEV) {
if (!isProduction) {
ontimeQueryClient.setQueryData(RUNTIME, data.payload);
}
break;
+3 -2
View File
@@ -1,8 +1,9 @@
/* eslint-disable react/display-name */
import { ComponentType, useEffect } from 'react';
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
import useAliases from '../common/hooks-query/useAliases';
import { getAliasRoute } from '../common/utils/aliases';
import { ComponentType, useEffect } from 'react';
import { useSearchParams, useNavigate, useLocation } from 'react-router-dom';
const withAlias = <P extends object>(Component: ComponentType<P>) => {
return (props: Partial<P>) => {
@@ -12,7 +12,7 @@ import {
} 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 { isOntimeBlock, isOntimeDelay, isOntimeEvent, OntimeRundown, OntimeRundownEntry } from 'ontime-types';
import { useLocalStorage } from '../../common/hooks/useLocalStorage';
import { millisToDelayString } from '../../common/utils/dateConfig';
@@ -196,15 +196,14 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
<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;
if (isOntimeBlock(row.original)) {
const title = row.original.title;
return (
<tr key={key} className={style.blockRow}>
@@ -212,8 +211,8 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
</tr>
);
}
if (entryType === SupportedEvent.Delay) {
const delayVal = (row.original as OntimeDelay).duration;
if (isOntimeDelay(row.original)) {
const delayVal = row.original.duration;
if (!showDelayBlock || delayVal === 0) {
return null;
@@ -226,7 +225,7 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
</tr>
);
}
if (entryType === SupportedEvent.Event) {
if (isOntimeEvent(row.original)) {
eventIndex++;
const isSelected = key === selectedId;
if (isSelected) {
@@ -238,9 +237,9 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
}
const bgFallback = 'transparent';
const bgColour = (row.original as OntimeEvent).colour || bgFallback;
const bgColour = row.original.colour || bgFallback;
const textColour = bgColour === bgFallback ? undefined : getAccessibleColour(bgColour);
const isSkipped = (row.original as OntimeEvent).skip;
const isSkipped = row.original.skip;
let rowBgColour: string | undefined;
if (row.original.id === selectedId) {
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo } from 'react';
import { EventData, OntimeRundownEntry } from 'ontime-types';
import { OntimeRundownEntry, ProjectData } from 'ontime-types';
import Empty from '../../common/components/state/Empty';
import { useEventAction } from '../../common/hooks/useEventAction';
@@ -69,7 +69,7 @@ export default function CuesheetWrapper() {
);
const exportHandler = useCallback(
(headerData: EventData) => {
(headerData: ProjectData) => {
if (!headerData || !rundown || !userFields) {
return;
}
@@ -3,12 +3,12 @@ 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 { Playback, ProjectData } 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 useProjectData from '../../../common/hooks-query/useProjectData';
import { formatTime } from '../../../common/utils/time';
import { tooltipDelayFast } from '../../../ontimeConfig';
import { useCuesheetSettings } from '../store/CuesheetSettings';
@@ -17,7 +17,7 @@ import PlaybackIcon from '../tableElements/PlaybackIcon';
import style from './CuesheetTableHeader.module.scss';
interface CuesheetTableHeaderProps {
handleCSVExport: (headerData: EventData) => void;
handleCSVExport: (headerData: ProjectData) => void;
featureData: {
playback: Playback;
selectedEventIndex: number | null;
@@ -33,11 +33,11 @@ export default function CuesheetTableHeader({ handleCSVExport, featureData }: Cu
const toggleFollow = useCuesheetSettings((state) => state.toggleFollow);
const timer = useTimer();
const { isFullScreen, toggleFullScreen } = useFullscreen();
const { data: event } = useEventData();
const { data: project } = useProjectData();
const exportCsv = () => {
if (event) {
handleCSVExport(event);
if (project) {
handleCSVExport(project);
}
};
@@ -58,7 +58,7 @@ export default function CuesheetTableHeader({ handleCSVExport, featureData }: Cu
return (
<div className={style.header}>
<div className={style.event}>
<div className={style.title}>{event?.title || '-'}</div>
<div className={style.title}>{project?.title || '-'}</div>
<div className={style.eventNow}>{featureData?.titleNow || '-'}</div>
</div>
<div className={style.playback}>
@@ -77,6 +77,13 @@ function MakeUserField({ getValue, row: { index }, column: { id }, table }: Cell
export function makeCuesheetColumns(userFields?: UserFields): ColumnDef<OntimeRundownEntry>[] {
return [
{
accessorKey: 'cue',
id: 'cue',
header: 'Cue',
cell: (row) => row.getValue(),
size: 75,
},
{
accessorKey: 'isPublic',
id: 'isPublic',
@@ -1,5 +1,5 @@
import { stringify } from 'csv-stringify/browser/esm/sync';
import { EventData, OntimeEntryCommonKeys, OntimeRundown, UserFields } from 'ontime-types';
import { OntimeEntryCommonKeys, OntimeRundown, ProjectData, UserFields } from 'ontime-types';
import { millisToString } from 'ontime-utils';
/**
@@ -38,10 +38,11 @@ export const parseField = (field: keyof OntimeRundown, data: unknown): string =>
* @param {object} userFields
* @return {(string[])[]}
*/
export const makeTable = (headerData: EventData, rundown: OntimeRundown, userFields: UserFields): string[][] => {
export const makeTable = (headerData: ProjectData, rundown: OntimeRundown, userFields: UserFields): string[][] => {
const data = [
['Ontime · Schedule Template'],
['Event Name', headerData?.title || ''],
['Project Title', headerData?.title || ''],
['Project Description', headerData?.description || ''],
['Public URL', headerData?.publicUrl || ''],
['Backstage URL', headerData?.backstageUrl || ''],
[],
@@ -5,6 +5,7 @@ import { OntimeEntryCommonKeys, OntimeEvent } from 'ontime-types';
*/
export const defaultColumnOrder: OntimeEntryCommonKeys[] = [
'isPublic',
'cue',
'timeStart',
'timeEnd',
'duration',
@@ -215,6 +215,11 @@ $playback-width: 26rem;
flex-direction: column;
}
.mainContainer > .rundown {
padding: 1rem 0;
}
.content {
padding-top: 1.5rem;
}
@@ -6,10 +6,14 @@
gap: max(1rem, 2vh);
display: grid;
grid-template-areas:
'eventInfo eventActions'
'timeOptions titles';
grid-template-columns: auto 1fr;
grid-template-areas: 'time left right';
grid-template-columns: auto 1fr 1fr;
}
.timeOptions {
grid-area: time;
display: flex;
gap: 1.5rem;
.timers,
.timeSettings {
@@ -19,51 +23,23 @@
}
}
.eventInfo {
grid-area: eventInfo;
.left,
.right {
display: flex;
align-items: center;
.eventId {
margin-left:$element-spacing;
}
flex-direction: column;
gap: 0.5rem;
}
.eventActions {
grid-area: eventActions;
margin-left: auto;
.left {
grid-area: left;
padding: 0 1rem;
border-left: 1px solid $border-color-ondark;
}
.timeOptions {
grid-area: timeOptions;
display: flex;
gap: 1.5rem;
}
.titles {
grid-area: titles;
display: grid;
grid-template-areas: 'left right';
grid-template-columns: 1fr 1fr;
.left,
.right {
display: flex;
flex-direction: column;
gap: 8px;
}
.left {
grid-area: left;
padding: 0 1rem;
border-left: 1px solid $border-color-ondark;
}
.right {
padding-left: 1rem;
grid-area: right;
border-left: 1px solid $border-color-ondark;
}
.right {
padding-left: 1rem;
grid-area: right;
border-left: 1px solid $border-color-ondark;
}
@mixin input-label() {
@@ -94,6 +70,12 @@
}
}
.eventActions {
margin-left: auto;
display: flex;
gap: 0.5rem;
}
.spacer {
height: 1.25rem;
}
@@ -104,6 +86,12 @@
gap: 1rem;
}
.splitTwo {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
}
.column {
display: flex;
flex-direction: column;
@@ -116,4 +104,4 @@
.fullHeight {
height: 100%
}
}
@@ -1,38 +1,45 @@
import { useEffect, useState } from 'react';
import { OntimeEvent } from 'ontime-types';
import { useCallback, useEffect, useState } from 'react';
import { isOntimeEvent, OntimeEvent } from 'ontime-types';
import CopyTag from '../../common/components/copy-tag/CopyTag';
import { useEventAction } from '../../common/hooks/useEventAction';
import useRundown from '../../common/hooks-query/useRundown';
import { useAppMode } from '../../common/stores/appModeStore';
import getDelayTo from '../../common/utils/getDelayTo';
import EventEditorDataLeft from './composite/EventEditorDataLeft';
import EventEditorDataRight from './composite/EventEditorDataRight';
import EventEditorTimes from './composite/EventEditorTimes';
import EventEditorTitles from './composite/EventEditorTitles';
import style from './EventEditor.module.scss';
export type EventEditorSubmitActions = keyof OntimeEvent;
export type EditorUpdateFields = 'cue' | 'title' | 'presenter' | 'subtitle' | 'note' | 'colour';
export default function EventEditor() {
const openId = useAppMode((state) => state.editId);
const { data } = useRundown();
const { updateEvent } = useEventAction();
const [event, setEvent] = useState<OntimeEvent | null>(null);
const [delay, setDelay] = useState(0);
useEffect(() => {
if (!data || !openId) {
setEvent(null);
return;
}
const eventIndex = data.findIndex((event) => event.id === openId);
if (eventIndex > -1) {
const event = data[eventIndex];
if (event.type === 'event') {
setDelay(getDelayTo(data, eventIndex));
setEvent(data[eventIndex] as OntimeEvent);
}
const event = data.find((event) => event.id === openId);
if (event && isOntimeEvent(event)) {
setEvent(event);
}
}, [data, event, openId]);
}, [data, openId]);
const handleSubmit = useCallback(
(field: EditorUpdateFields, value: string) => {
updateEvent({ id: event?.id, [field]: value });
},
[event?.id, updateEvent],
);
if (!event) {
return <span>Loading...</span>;
@@ -40,34 +47,35 @@ export default function EventEditor() {
return (
<div className={style.eventEditor}>
<div className={style.eventInfo}>
Event ID
<span className={style.eventId}>
<CopyTag label={event.id}>{event.id}</CopyTag>
</span>
</div>
<div className={style.eventActions}>
<CopyTag label='OSC trigger'>{`/ontime/gotoid/${event.id}`}</CopyTag>
</div>
<EventEditorTimes
eventId={event.id}
timeStart={event.timeStart}
timeEnd={event.timeEnd}
duration={event.duration}
delay={delay}
delay={event.delay ?? 0}
isPublic={event.isPublic}
endAction={event.endAction}
timerType={event.timerType}
/>
<EventEditorTitles
key={event.id}
<EventEditorDataLeft
key={`${event.id}-left`}
eventId={event.id}
cue={event.cue}
title={event.title}
presenter={event.presenter}
subtitle={event.subtitle}
handleSubmit={handleSubmit}
/>
<EventEditorDataRight
key={`${event.id}-right`}
note={event.note}
colour={event.colour}
/>
handleSubmit={handleSubmit}
>
<CopyTag label='Event ID'>{event.id}</CopyTag>
<CopyTag label='OSC trigger by id'>{`/ontime/gotoid/${event.id}`}</CopyTag>
<CopyTag label='OSC trigger by cue'>{`/ontime/gotocue/${event.cue}`}</CopyTag>
</EventEditorDataRight>
</div>
);
}
@@ -3,7 +3,7 @@ import { Textarea } from '@chakra-ui/react';
import useReactiveTextInput from '../../../common/components/input/text-input/useReactiveTextInput';
import { TitleActions } from './EventEditorTitles';
import { TitleActions } from './EventEditorDataLeft';
import style from '../EventEditor.module.scss';
@@ -24,7 +24,9 @@ export default function CountedTextArea(props: CountedTextAreaProps) {
return (
<div className={`${style.column} ${style.fullHeight}`}>
<div className={style.countedInput}>
<label className={style.inputLabel} htmlFor={field}>{label}</label>
<label className={style.inputLabel} htmlFor={field}>
{label}
</label>
<span className={style.charCount}>{`${value.length} characters`}</span>
</div>
<Textarea
@@ -1,21 +1,20 @@
import { useCallback } from 'react';
import { Input } from '@chakra-ui/react';
import { Input, InputProps } from '@chakra-ui/react';
import useReactiveTextInput from '../../../common/components/input/text-input/useReactiveTextInput';
import { TitleActions } from './EventEditorTitles';
import { EditorUpdateFields } from '../EventEditor';
import style from '../EventEditor.module.scss';
interface CountedTextInputProps {
field: TitleActions;
interface CountedTextInputProps extends InputProps {
field: EditorUpdateFields;
label: string;
initialValue: string;
submitHandler: (field: TitleActions, value: string) => void;
submitHandler: (field: EditorUpdateFields, value: string) => void;
}
export default function CountedTextInput(props: CountedTextInputProps) {
const { field, label, initialValue, submitHandler } = props;
const { field, label, initialValue, submitHandler, maxLength } = props;
const submitCallback = useCallback((newValue: string) => submitHandler(field, newValue), [field, submitHandler]);
@@ -26,7 +25,9 @@ export default function CountedTextInput(props: CountedTextInputProps) {
return (
<div className={style.column}>
<div className={style.countedInput}>
<label className={style.inputLabel} htmlFor={field}>{label}</label>
<label className={style.inputLabel} htmlFor={field}>
{label}
</label>
<span className={style.charCount}>{`${value.length} characters`}</span>
</div>
<Input
@@ -35,6 +36,7 @@ export default function CountedTextInput(props: CountedTextInputProps) {
variant='ontime-filled'
data-testid='input-textfield'
value={value}
maxLength={maxLength || 50}
onChange={onChange}
onBlur={onBlur}
onKeyDown={onKeyDown}
@@ -0,0 +1,54 @@
import { memo } from 'react';
import { Input } from '@chakra-ui/react';
import { sanitiseCue } from 'ontime-utils';
import { type EditorUpdateFields } from '../EventEditor';
import CountedTextInput from './CountedTextInput';
import style from '../EventEditor.module.scss';
interface EventEditorLeftProps {
eventId: string;
cue: string;
title: string;
presenter: string;
subtitle: string;
handleSubmit: (field: EditorUpdateFields, value: string) => void;
}
const EventEditorDataLeft = (props: EventEditorLeftProps) => {
const { eventId, cue, title, presenter, subtitle, handleSubmit } = props;
const cueSubmitHandler = (_field: string, newValue: string) => {
handleSubmit('cue', sanitiseCue(newValue));
};
return (
<div className={style.left}>
<div className={style.splitTwo}>
<div className={style.column}>
<div className={style.countedInput}>
<label className={style.inputLabel} htmlFor='eventId'>
Event ID (read only)
</label>
</div>
<Input
id='eventId'
size='sm'
variant='ontime-filled'
data-testid='input-textfield'
value={eventId}
readOnly
/>
</div>
<CountedTextInput field='cue' label='Cue' initialValue={cue} submitHandler={cueSubmitHandler} maxLength={10} />
</div>
<CountedTextInput field='title' label='Title' initialValue={title} submitHandler={handleSubmit} />
<CountedTextInput field='presenter' label='Presenter' initialValue={presenter} submitHandler={handleSubmit} />
<CountedTextInput field='subtitle' label='Subtitle' initialValue={subtitle} submitHandler={handleSubmit} />
</div>
);
};
export default memo(EventEditorDataLeft);
@@ -0,0 +1,33 @@
import { memo, PropsWithChildren } from 'react';
import SwatchSelect from '../../../common/components/input/colour-input/SwatchSelect';
import { EditorUpdateFields } from '../EventEditor';
import CountedTextArea from './CountedTextArea';
import style from '../EventEditor.module.scss';
interface EventEditorRightProps {
note: string;
colour: string;
handleSubmit: (field: EditorUpdateFields, value: string) => void;
}
const EventEditorDataRight = (props: PropsWithChildren<EventEditorRightProps>) => {
const { children, note, colour, handleSubmit } = props;
return (
<div className={style.right}>
<div className={style.column}>
<label className={style.inputLabel}>Colour</label>
<div className={style.inline}>
<SwatchSelect name='colour' value={colour} handleChange={handleSubmit} />
</div>
</div>
<CountedTextArea field='note' label='Note' initialValue={note} submitHandler={handleSubmit} />
<div className={style.eventActions}>{children}</div>
</div>
);
};
export default memo(EventEditorDataRight);
@@ -130,6 +130,7 @@ const EventEditorTimes = (props: EventEditorTimesProps) => {
>
<option value={TimerType.CountDown}>Count down</option>
<option value={TimerType.CountUp}>Count up</option>
<option value={TimerType.TimeToEnd}>Time to end</option>
<option value={TimerType.Clock}>Clock</option>
</Select>
<label className={style.inputLabel}>End Action</label>
@@ -1,50 +0,0 @@
import { memo } from 'react';
import SwatchSelect from '../../../common/components/input/colour-input/SwatchSelect';
import { useEventAction } from '../../../common/hooks/useEventAction';
import CountedTextArea from './CountedTextArea';
import CountedTextInput from './CountedTextInput';
import style from '../EventEditor.module.scss';
interface EventEditorTitlesProps {
eventId: string;
title: string;
presenter: string;
subtitle: string;
note: string;
colour: string;
}
export type TitleActions = 'title' | 'presenter' | 'subtitle' | 'note' | 'colour';
const EventEditorTitles = (props: EventEditorTitlesProps) => {
const { eventId, title, presenter, subtitle, note, colour } = props;
const { updateEvent } = useEventAction();
const handleSubmit = (field: TitleActions, value: string) => {
updateEvent({ id: eventId, [field]: value });
};
return (
<div className={style.titles}>
<div className={style.left}>
<CountedTextInput field='title' label='Title' initialValue={title} submitHandler={handleSubmit} />
<CountedTextInput field='presenter' label='Presenter' initialValue={presenter} submitHandler={handleSubmit} />
<CountedTextInput field='subtitle' label='Subtitle' initialValue={subtitle} submitHandler={handleSubmit} />
</div>
<div className={style.right}>
<div className={style.column}>
<label className={style.inputLabel}>Colour</label>
<div className={style.inline}>
<SwatchSelect name='colour' value={colour} handleChange={handleSubmit} />
</div>
</div>
<CountedTextArea field='note' label='Note' initialValue={note} submitHandler={handleSubmit} />
</div>
</div>
);
};
export default memo(EventEditorTitles);
+2 -1
View File
@@ -1,5 +1,6 @@
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
import { serverPort } from '../../common/api/apiConstants';
import useInfo from '../../common/hooks-query/useInfo';
import { openLink } from '../../common/utils/linkUtils';
@@ -9,7 +10,7 @@ export default function InfoNif() {
const { data } = useInfo();
const handleClick = (address: string) => {
const baseURL = 'http://__IP__:4001';
const baseURL = `http://__IP__:${serverPort}`;
openLink(baseURL.replace('__IP__', address));
};
@@ -1,9 +1,9 @@
import useEventData from '../../../common/hooks-query/useEventData';
import useProjectData from '../../../common/hooks-query/useProjectData';
import style from '../Info.module.scss';
export default function InfoHeader({ selected }: { selected: string }) {
const { data } = useEventData();
const { data } = useProjectData();
return (
<>
@@ -1,4 +1,4 @@
.headerButtons {
text-align: right;
padding-top: 24px;
padding: 1.5rem 1rem 0 1rem;
}
@@ -41,8 +41,8 @@ $el-padding-with-compensation: 24px; // 16 + 8
.title {
font-size: $inner-section-text-size;
color: $gray-500;
padding-left: 8px;
margin: 8px 0;
padding-left: 0.5rem;
margin: 0.5rem 0;
text-transform: uppercase;
}
@@ -16,12 +16,12 @@ import {
ModalOverlay,
Textarea,
} from '@chakra-ui/react';
import type { EventData } from 'ontime-types';
import type { ProjectData } from 'ontime-types';
import { EVENT_DATA, RUNDOWN_TABLE } from '../../../common/api/apiConstants';
import { PROJECT_DATA, RUNDOWN_TABLE } from '../../../common/api/apiConstants';
import { postNew } from '../../../common/api/ontimeApi';
import useEventData from '../../../common/hooks-query/useEventData';
import { eventDataPlaceholder } from '../../../common/models/EventData';
import useProjectData from '../../../common/hooks-query/useProjectData';
import { projectDataPlaceholder } from '../../../common/models/ProjectData';
import { ontimeQueryClient } from '../../../common/queryClient';
import styles from '../Modal.module.scss';
@@ -32,7 +32,7 @@ interface QuickStartProps {
}
export default function QuickStart({ onClose, isOpen }: QuickStartProps) {
const { data, status } = useEventData();
const { data, status } = useProjectData();
const {
handleSubmit,
register,
@@ -49,10 +49,10 @@ export default function QuickStart({ onClose, isOpen }: QuickStartProps) {
if (data) reset(data);
}, [data, reset]);
const onSubmit = async (data: Partial<EventData>) => {
const onSubmit = async (data: Partial<ProjectData>) => {
try {
await postNew(data);
await ontimeQueryClient.invalidateQueries(EVENT_DATA);
await ontimeQueryClient.invalidateQueries(PROJECT_DATA);
await ontimeQueryClient.invalidateQueries(RUNDOWN_TABLE);
onClose();
@@ -61,7 +61,7 @@ export default function QuickStart({ onClose, isOpen }: QuickStartProps) {
}
};
const onReset = () => reset(eventDataPlaceholder);
const onReset = () => reset(projectDataPlaceholder);
const disableButtons = status !== 'success' || isSubmitting;
return (
@@ -86,13 +86,13 @@ export default function QuickStart({ onClose, isOpen }: QuickStartProps) {
<div className={styles.column}>
<AlertTitle>Note</AlertTitle>
<AlertDescription>
On submit, application options will be kept but rundown and event data will be reset
On submit, application options will be kept but rundown and project data will be reset
</AlertDescription>
</div>
</Alert>
<div className={styles.entryRow}>
<label className={styles.sectionTitle}>
Event title
Project title
<Input
variant='ontime-filled-on-light'
size='sm'
@@ -104,7 +104,7 @@ export default function QuickStart({ onClose, isOpen }: QuickStartProps) {
</div>
<div className={styles.entryRow}>
<label className={styles.sectionTitle}>
Event description
Project description
<Input
variant='ontime-filled-on-light'
size='sm'
@@ -116,7 +116,7 @@ export default function QuickStart({ onClose, isOpen }: QuickStartProps) {
</div>
<div className={styles.entryRow}>
<label className={styles.sectionTitle}>
Public Info
Public info
<Textarea
variant='ontime-filled-on-light'
size='sm'
@@ -128,7 +128,7 @@ export default function QuickStart({ onClose, isOpen }: QuickStartProps) {
</div>
<div className={styles.entryRow}>
<label className={styles.sectionTitle}>
Public QR Code Url
Public QR code Url
<Input
variant='ontime-filled-on-light'
size='sm'
@@ -139,7 +139,7 @@ export default function QuickStart({ onClose, isOpen }: QuickStartProps) {
</div>
<div className={styles.entryRow}>
<label className={styles.sectionTitle}>
Backstage Info
Backstage info
<Textarea
variant='ontime-filled-on-light'
size='sm'
@@ -151,7 +151,7 @@ export default function QuickStart({ onClose, isOpen }: QuickStartProps) {
</div>
<div className={styles.entryRow}>
<label className={styles.sectionTitle}>
Backstage QR Code Url
Backstage QR code Url
<Input
variant='ontime-filled-on-light'
size='sm'
@@ -61,7 +61,7 @@ export default function AppSettingsModal() {
<ModalSplitInput
field='serverPort'
title='Ontime is available on port'
description='Default 4001'
description='Default 4001 (needs app restart to change)'
error={errors.serverPort?.message}
>
<Input
@@ -69,7 +69,6 @@ export default function AppSettingsModal() {
size='sm'
textAlign='right'
maxLength={5}
disabled
variant='ontime-filled-on-light'
{...register('serverPort', {
required: { value: true, message: 'Required field' },
@@ -1,11 +1,11 @@
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { Input, Textarea } from '@chakra-ui/react';
import { EventData } from 'ontime-types';
import { ProjectData } from 'ontime-types';
import { logAxiosError } from '../../../common/api/apiUtils';
import { postEventData } from '../../../common/api/eventDataApi';
import useEventData from '../../../common/hooks-query/useEventData';
import { postProjectData } from '../../../common/api/projectDataApi';
import useProjectData from '../../../common/hooks-query/useProjectData';
import ModalLoader from '../modal-loader/ModalLoader';
import { inputProps } from '../modalHelper';
import ModalInput from '../ModalInput';
@@ -13,14 +13,14 @@ import OntimeModalFooter from '../OntimeModalFooter';
import style from './SettingsModal.module.scss';
export default function EventDataForm() {
const { data, status, isFetching, refetch } = useEventData();
export default function ProjectDataForm() {
const { data, status, isFetching, refetch } = useProjectData();
const {
handleSubmit,
register,
reset,
formState: { errors, isSubmitting, isDirty, isValid },
} = useForm<EventData>({
} = useForm<ProjectData>({
defaultValues: data,
values: data,
resetOptions: {
@@ -34,11 +34,11 @@ export default function EventDataForm() {
}
}, [data, reset]);
const onSubmit = async (formData: EventData) => {
const onSubmit = async (formData: ProjectData) => {
try {
await postEventData(formData);
await postProjectData(formData);
} catch (error) {
logAxiosError('Error saving event settings', error);
logAxiosError('Error saving project data', error);
} finally {
await refetch();
}
@@ -55,10 +55,10 @@ export default function EventDataForm() {
}
return (
<form onSubmit={handleSubmit(onSubmit)} id='event-data' className={style.sectionContainer}>
<form onSubmit={handleSubmit(onSubmit)} id='project-data' className={style.sectionContainer}>
<ModalInput
field='title'
title='Event title'
title='Project title'
description='Shown in overview screens'
error={errors.title?.message}
>
@@ -73,7 +73,7 @@ export default function EventDataForm() {
</ModalInput>
<ModalInput
field='description'
title='Event description'
title='Project description'
description='Free field, shown in editor'
error={errors.description?.message}
>
@@ -87,7 +87,7 @@ export default function EventDataForm() {
/>
</ModalInput>
<div style={{ height: '16px' }} />
<ModalInput field='publicInfo' title='Public Info' description='Information shown in public screens'>
<ModalInput field='publicInfo' title='Public info' description='Information shown in public screens'>
<Textarea
{...inputProps}
variant='ontime-filled-on-light'
@@ -107,7 +107,7 @@ export default function EventDataForm() {
/>
</ModalInput>
<div style={{ height: '16px' }} />
<ModalInput field='backstageInfo' title='Backstage Info' description='Information shown in public screens'>
<ModalInput field='backstageInfo' title='Backstage info' description='Information shown in public screens'>
<Textarea
{...inputProps}
variant='ontime-filled-on-light'
@@ -128,7 +128,7 @@ export default function EventDataForm() {
/>
</ModalInput>
<OntimeModalFooter
formId='event-data'
formId='project-data'
handleRevert={onReset}
isDirty={isDirty}
isValid={isValid}
@@ -6,7 +6,7 @@ import AliasesForm from './AliasesForm';
import AppSettingsModal from './AppSettings';
import CuesheetSettingsForm from './CuesheetSettingsForm';
import EditorSettings from './EditorSettings';
import EventDataForm from './EventDataForm';
import ProjectDataForm from './ProjectDataForm';
import ViewSettingsForm from './ViewSettingsForm';
interface ModalManagerProps {
@@ -22,7 +22,7 @@ export default function SettingsModal(props: ModalManagerProps) {
<Tabs variant='ontime' size='sm' isLazy>
<TabList>
<Tab>App</Tab>
<Tab>Event Data</Tab>
<Tab>Project Data</Tab>
<Tab>Editor</Tab>
<Tab>Cuesheet</Tab>
<Tab>Views</Tab>
@@ -33,7 +33,7 @@ export default function SettingsModal(props: ModalManagerProps) {
<AppSettingsModal />
</TabPanel>
<TabPanel>
<EventDataForm />
<ProjectDataForm />
</TabPanel>
<TabPanel>
<EditorSettings />
@@ -0,0 +1,22 @@
@use '../../../theme/v2Styles' as *;
@use '../../../theme/ontimeColours' as *;
.header {
font-size: $inner-section-text-size;
color: $gray-500;
padding-left: 0.5rem;
margin: 0.5rem 0;
text-transform: uppercase;
display: flex;
}
.moreExpanded {
transform: scaleY(-1);
transition: transform $transition-time-feedback;
}
.moreCollapsed {
transform: scaleY(1);
transition: transform $transition-time-feedback;
}
@@ -0,0 +1,23 @@
import { PropsWithChildren, useState } from 'react';
import { IoChevronUp } from '@react-icons/all-files/io5/IoChevronUp';
import style from './CollapsableSection.module.scss';
interface CollapsableSectionProps {
title: string;
}
export default function CollapsableSection(props: PropsWithChildren<CollapsableSectionProps>) {
const { title, children } = props;
const [collapsed, setCollapsed] = useState(false);
return (
<>
<div className={style.title} onClick={() => setCollapsed((prev) => !prev)}>
{title}
<IoChevronUp className={collapsed ? style.moreCollapsed : style.moreExpanded} />
</div>
{!collapsed && children}
</>
);
}
@@ -0,0 +1,25 @@
import { OntimeRundown, ProjectData, UserFields } from 'ontime-types';
import PreviewProjectData from '../../../common/components/import-preview/PreviewProjectData';
import PreviewRundown from '../../../common/components/import-preview/PreviewRundown';
import style from '../Modal.module.scss';
interface ReviewFileProps {
rundown: OntimeRundown;
project: ProjectData;
userFields: UserFields;
}
export default function ReviewFile(props: ReviewFileProps) {
const { rundown, project, userFields } = props;
return (
<div className={style.columnSection}>
<div className={style.title}>Review Project Data</div>
<PreviewProjectData project={project} />
<div className={style.title}>Review Rundown</div>
<PreviewRundown rundown={rundown} userFields={userFields} />
</div>
);
}
@@ -0,0 +1,58 @@
import { ChangeEvent, useRef, useState } from 'react';
import { Input } from '@chakra-ui/react';
import UploadEntry from './upload-entry/UploadEntry';
import { useUploadModalContextStore } from './uploadModalContext';
import { validateFile } from './uploadUtils';
import style from './UploadModal.module.scss';
export default function UploadFile() {
const fileInputRef = useRef<HTMLInputElement>(null);
const { file, setFile, progress } = useUploadModalContextStore();
const [errors, setErrors] = useState<string | undefined>();
const success = false;
const clearFile = () => {
setFile(null);
};
const handleFile = (event: ChangeEvent<HTMLInputElement>) => {
const fileSelected = event?.target?.files?.[0];
if (!fileSelected) return;
const validate = validateFile(fileSelected);
setErrors(validate.errors?.[0]);
if (validate.isValid) {
setFile(fileSelected);
} else {
setFile(null);
}
};
const handleClick = () => {
fileInputRef.current?.click();
};
return (
<>
<Input
ref={fileInputRef}
style={{ display: 'none' }}
type='file'
onChange={handleFile}
accept='.json, .xlsx'
data-testid='file-input'
/>
<div className={style.uploadArea} onClick={handleClick}>
Click to upload Ontime project or xlsx file
</div>
{(file || errors) && (
<UploadEntry file={file} errors={errors} progress={progress} success={success} handleClear={clearFile} />
)}
</>
);
}
@@ -5,79 +5,31 @@
.uploadBody {
display: flex;
flex-direction: column;
gap: 16px;
gap: 1rem;
}
.uploadArea {
margin: 0 auto;
width: 100%;
min-height: 200px;
border: 2px dashed $gray-50;
max-width: 550px;
min-height: 150px;
border: 2px dashed $gray-200;
border-radius: 3px;
display: grid;
place-content: center;
transition-property: background-color;
transition-duration: $transition-time-action;
font-size: calc(1rem - 1px);
&:hover {
border: 2px solid $blue-500;
background-color: $blue-50;
cursor: pointer;
}
&.comment {
color: gray;
}
}
.uploadedItem {
background-color: $gray-50;
padding: 8px;
display: grid;
grid-template-areas:
"icon title close"
"icon info ."
"progress progress progress";
grid-template-columns: auto 1fr auto;
column-gap: 16px;
border-radius: 3px;
.icon {
align-self: center;
grid-area: icon;
font-size: 32px;
color: $gray-700;
}
.fileTitle {
grid-area: title;
font-size: 14px;
color: $gray-1350;
}
.fileInfo {
grid-area: info;
font-size: 12px;
color: $gray-1100;
}
.fileProgress {
grid-area: progress;
}
.cancelUpload {
grid-area: close;
cursor: pointer;
}
&.error {
.icon {
color: $error-red;
}
}
&.success {
.icon {
color: $green-500;
}
color: $modal-note-color;
}
}
@@ -89,3 +41,9 @@
.pad {
margin: 8px;
}
.twoColumn {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
}
@@ -1,7 +1,6 @@
import { ChangeEvent, useCallback, useRef, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import {
Button,
Input,
Modal,
ModalBody,
ModalCloseButton,
@@ -9,23 +8,36 @@ import {
ModalFooter,
ModalHeader,
ModalOverlay,
Progress,
Switch,
} from '@chakra-ui/react';
import { IoClose } from '@react-icons/all-files/io5/IoClose';
import { IoDocumentTextOutline } from '@react-icons/all-files/io5/IoDocumentTextOutline';
import { IoWarningOutline } from '@react-icons/all-files/io5/IoWarningOutline';
import { useQueryClient } from '@tanstack/react-query';
import { OntimeRundown } from 'ontime-types';
import { RUNDOWN_TABLE } from '../../../common/api/apiConstants';
import { uploadData } from '../../../common/api/ontimeApi';
import { useEmitLog } from '../../../common/stores/logger';
import ModalSplitInput from '../ModalSplitInput';
import { postPreviewExcel, uploadData } from '../../../common/api/ontimeApi';
import { projectDataPlaceholder } from '../../../common/models/ProjectData';
import { userFieldsPlaceholder } from '../../../common/models/UserFields';
import { cx } from '../../../common/utils/styleUtils';
import { validateFile } from './utils';
import ExcelFileOptions from './upload-options/ExcelFileOptions';
import OntimeFileOptions from './upload-options/OntimeFileOptions';
import UploadStepTracker from './upload-step/UploadStep';
import ReviewFile from './ReviewExcel';
import UploadFile from './UploadFile';
import { useUploadModalContextStore } from './uploadModalContext';
import { defaultExcelImportMap, ExcelImportMapKeys, isExcelFile, isOntimeFile } from './uploadUtils';
import style from './UploadModal.module.scss';
export type UploadStep = 'upload' | 'review';
export interface OntimeInputOptions {
onlyImportRundown?: boolean;
}
export type ExcelInputOptions = {
[K in ExcelImportMapKeys]: string;
};
interface UploadModalProps {
onClose: () => void;
isOpen: boolean;
@@ -33,65 +45,106 @@ interface UploadModalProps {
export default function UploadModal({ onClose, isOpen }: UploadModalProps) {
const queryClient = useQueryClient();
const { emitError } = useEmitLog();
const [errors, setErrors] = useState<string | undefined>();
const [isSubmitting, setSubmitting] = useState(false);
const [file, setFile] = useState<File | null>(null);
const [progress, setProgress] = useState(0);
const overrideOptionRef = useRef<HTMLInputElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const [success, setSuccess] = useState(false);
const handleFile = useCallback((event: ChangeEvent<HTMLInputElement>) => {
const fileUploaded = event?.target?.files?.[0];
if (!fileUploaded) return;
const { file, setProgress, clear } = useUploadModalContextStore();
const validate = validateFile(fileUploaded);
setErrors(validate.errors?.[0]);
const [uploadStep, setUploadStep] = useState<UploadStep>('upload');
const [submitting, setSubmitting] = useState(false);
const [rundown, setRundown] = useState<OntimeRundown>([]);
const [userFields, setUserFields] = useState(userFieldsPlaceholder);
const [project, setProject] = useState(projectDataPlaceholder);
if (validate.isValid) {
setFile(fileUploaded);
} else {
setFile(null);
}
}, []);
const [errors, setErrors] = useState('');
const handleSubmit = useCallback(async () => {
setSubmitting(true);
const ontimeFileOptions = useRef<Partial<OntimeInputOptions>>({});
const excelFileOptions = useRef<Partial<ExcelInputOptions>>(defaultExcelImportMap);
useEffect(() => {
clear();
setUploadStep('upload');
setSubmitting(false);
setRundown([]);
setUserFields(userFieldsPlaceholder);
setProject(projectDataPlaceholder);
setErrors('');
}, [clear, isOpen]);
const handleParse = async () => {
if (file) {
setSubmitting(true);
try {
const options = {
onlyRundown: overrideOptionRef.current?.checked || false,
};
await uploadData(file, setProgress, options);
if (isOntimeFile(file)) {
await handleOntimeFile(file);
await queryClient.invalidateQueries(RUNDOWN_TABLE);
} else if (isExcelFile(file)) {
await handleExcelFile(file);
}
} catch (error) {
emitError(`Failed uploading file: ${error}`);
setErrors(`Failed uploading file: ${error}`);
} finally {
await queryClient.invalidateQueries(RUNDOWN_TABLE);
setSuccess(true);
setSubmitting(false);
}
}
setSubmitting(false);
}, [emitError, file, queryClient]);
const handleClick = () => {
fileInputRef.current?.click();
};
async function handleExcelFile(file: File) {
const options = excelFileOptions.current;
// TODO: option type should be central, to also be used by backend
const response = await postPreviewExcel(file, setProgress, options);
if (response.status === 200) {
setRundown(response.data.rundown);
setUserFields(response.data.userFields);
setProject(response.data.project);
setUploadStep('review');
}
}
const clearFile = () => {
setFile(null);
async function handleOntimeFile(file: File) {
const options = {
onlyRundown: Boolean(ontimeFileOptions.current.onlyImportRundown),
};
await uploadData(file, setProgress, options);
}
};
const handleClose = () => {
clearFile();
setSuccess(false);
setErrors(undefined);
setProgress(0);
clear();
setRundown([]);
setUserFields(userFieldsPlaceholder);
setProject(projectDataPlaceholder);
onClose();
};
const disableSubmit = !file || isSubmitting;
const handleFinalise = async () => {
if (file) {
setSubmitting(true);
try {
const options = {
//onlyRundown: overrideOptionRef.current?.checked || false,
};
await uploadData(file, setProgress, options);
handleClose();
} catch (error) {
console.error(error);
} finally {
await queryClient.invalidateQueries(RUNDOWN_TABLE);
setSubmitting(false);
}
}
};
const isUpload = uploadStep === 'upload';
const isExcel = isExcelFile(file);
const isOntime = isOntimeFile(file);
const handleGoBack = isUpload ? undefined : () => setUploadStep('upload');
const handleSubmit = isUpload ? handleParse : handleFinalise;
const disableSubmit = isUpload && !file;
const disableGoBack = isUpload;
const submitText = isUpload ? 'Upload' : 'Finish';
const modalClasses = cx([style.modalWidthOverride, isExcel ? style.doExtend : null]);
console.log('debug', isExcel, modalClasses);
return (
<Modal
onClose={handleClose}
@@ -101,65 +154,43 @@ export default function UploadModal({ onClose, isOpen }: UploadModalProps) {
size='xl'
scrollBehavior='inside'
preserveScrollBarGap
variant='ontime-small'
variant='ontime-upload'
>
<ModalOverlay />
<ModalContent>
<ModalHeader>File import</ModalHeader>
<ModalCloseButton />
<ModalBody className={style.uploadBody}>
<Input
ref={fileInputRef}
style={{ display: 'none' }}
type='file'
onChange={handleFile}
accept='.json, .xlsx'
data-testid='file-input'
/>
<div className={style.uploadArea} onClick={handleClick}>
Click to upload Ontime project file
</div>
{file && (
<div className={`${style.uploadedItem} ${success ? style.success : ''}`}>
<IoClose className={style.cancelUpload} onClick={clearFile} />
<IoDocumentTextOutline className={style.icon} />
<span className={style.fileTitle}>{file.name}</span>
<span className={style.fileInfo}>{`${(file.size / 1024).toFixed(2)}kb - ${file.type}`}</span>
<Progress variant='ontime-on-light' className={style.fileProgress} value={progress} />
</div>
{isExcel && <UploadStepTracker uploadStep={uploadStep} />}
{uploadStep === 'upload' ? (
<>
<UploadFile />
{errors && <div className={style.error}>{errors}</div>}
{isOntime && <OntimeFileOptions optionsRef={ontimeFileOptions} />}
{isExcel && <ExcelFileOptions optionsRef={excelFileOptions} />}
</>
) : (
<ReviewFile rundown={rundown} project={project} userFields={userFields} />
)}
{errors && (
<div className={`${style.uploadedItem} ${style.error}`}>
<IoWarningOutline className={style.icon} />
<span className={style.fileTitle}>{errors}</span>
<span className={style.fileInfo}>Please try again</span>
<Progress className={style.fileProgress} value={progress} />
</div>
)}
<div className={style.uploadOptions}>
<span className={style.title}>Import options</span>
<ModalSplitInput
field=''
title='Only import rundown'
description='All other options, including application settings will be discarded'
>
<Switch variant='ontime-on-light' ref={overrideOptionRef} />
</ModalSplitInput>
</div>
</ModalBody>
<ModalFooter className={`${style.buttonSection} ${style.pad}`}>
<Button onClick={handleClose} isDisabled={isSubmitting} variant='ontime-ghost-on-light' size='sm'>
Cancel
<Button
onClick={handleGoBack}
isDisabled={disableGoBack || submitting}
variant='ontime-ghost-on-light'
size='sm'
>
Go Back
</Button>
<Button
onClick={handleSubmit}
isLoading={isSubmitting}
isLoading={submitting}
isDisabled={disableSubmit}
variant='ontime-filled'
padding='0 2em'
size='sm'
>
Import
{submitText}
</Button>
</ModalFooter>
</ModalContent>
@@ -0,0 +1,59 @@
@use '../../../../theme/ontimeColours' as *;
@use '../../../../theme/v2Styles' as *;
.uploadedItem {
margin: 0 auto;
width: 100%;
max-width: 550px;
border: 1px solid $gray-200;
padding: 0.5rem;
display: grid;
grid-template-areas:
"icon title close"
"icon info ."
"progress progress progress";
grid-template-columns: auto 1fr auto;
column-gap: 1rem;
border-radius: 3px;
.icon {
align-self: center;
grid-area: icon;
font-size: 2rem;
color: $gray-700;
}
.fileTitle {
grid-area: title;
font-size: calc(1rem - 2px);
color: $ui-black;
}
.fileInfo {
grid-area: info;
font-size: calc(1rem - 4px);
color: $gray-1100;
}
.fileProgress {
grid-area: progress;
}
.cancelUpload {
grid-area: close;
cursor: pointer;
}
&.error {
.icon {
color: $error-red;
}
}
&.success {
.icon {
color: $green-500;
}
}
}
@@ -0,0 +1,53 @@
import { Progress } from '@chakra-ui/react';
import { IoClose } from '@react-icons/all-files/io5/IoClose';
import { IoDocumentTextOutline } from '@react-icons/all-files/io5/IoDocumentTextOutline';
import { IoWarningOutline } from '@react-icons/all-files/io5/IoWarningOutline';
import { isExcelFile, isOntimeFile } from '../uploadUtils';
import style from './UploadEntry.module.scss';
interface UploadEntryProps {
file: File | null;
errors?: string;
progress: number;
success: boolean;
handleClear: () => void;
}
export default function UploadEntry(props: UploadEntryProps) {
const { file, errors, progress, success, handleClear } = props;
if (errors) {
return (
<div className={`${style.uploadedItem} ${style.error}`}>
<IoWarningOutline className={style.icon} />
<span className={style.fileTitle}>{errors}</span>
<span className={style.fileInfo}>Please try again</span>
<Progress className={style.fileProgress} value={progress} />
</div>
);
}
if (file) {
const fileSize = `${(file.size / 1024).toFixed(2)}kb`;
let fileType = '';
if (isOntimeFile(file)) {
fileType = 'Ontime Project File';
} else if (isExcelFile(file)) {
fileType = 'Excel Rundown';
}
return (
<div className={`${style.uploadedItem} ${success ? style.success : ''}`}>
<IoClose className={style.cancelUpload} onClick={handleClear} />
<IoDocumentTextOutline className={style.icon} />
<span className={style.fileTitle}>{file.name}</span>
<span className={style.fileInfo}>{`${fileSize} - ${fileType}`}</span>
<Progress variant='ontime-on-light' className={style.fileProgress} value={progress} />
</div>
);
}
return null;
}
@@ -0,0 +1,78 @@
import { MutableRefObject } from 'react';
import { Input } from '@chakra-ui/react';
import ModalSplitInput from '../../ModalSplitInput';
import ImportMapTable, { type TableEntry } from './ImportMapTable';
import { ExcelInputOptions } from '../UploadModal';
import style from '../UploadModal.module.scss';
interface ExcelFileOptionsProps {
optionsRef: MutableRefObject<ExcelInputOptions>;
}
export default function ExcelFileOptions(props: ExcelFileOptionsProps) {
const { optionsRef } = props;
const updateRef = <T extends keyof ExcelInputOptions>(field: T, value: ExcelInputOptions[T]) => {
// avoid unnecessary changes
if (optionsRef.current[field] !== value) {
optionsRef.current = { ...optionsRef.current, [field]: value };
}
};
const worksheet: TableEntry[] = [{ label: 'Worksheet', title: 'worksheet', value: optionsRef.current.worksheet }];
const timings: TableEntry[] = [
{ label: 'Start time', title: 'timeStart', value: optionsRef.current.timeStart },
{ label: 'End Time', title: 'timeEnd', value: optionsRef.current.timeEnd },
{ label: 'Duration', title: 'duration', value: optionsRef.current.duration },
];
const titles: TableEntry[] = [
{ label: 'Cue', title: 'cue', value: optionsRef.current.cue },
{ label: 'Colour', title: 'colour', value: optionsRef.current.colour },
{ label: 'Title', title: 'title', value: optionsRef.current.title },
{ label: 'Presenter', title: 'presenter', value: optionsRef.current.presenter },
{ label: 'Subtitle', title: 'subtitle', value: optionsRef.current.subtitle },
{ label: 'Note', title: 'note', value: optionsRef.current.note },
];
const options: TableEntry[] = [
{ label: 'Is Public', title: 'isPublic', value: optionsRef.current.isPublic },
{ label: 'Timer Type', title: 'timerType', value: optionsRef.current.timerType },
{ label: 'End Action', title: 'endAction', value: optionsRef.current.endAction },
];
const userFields: TableEntry[] = [
{ label: 'User 0', title: 'user0', value: optionsRef.current.user0 },
{ label: 'User 1', title: 'user1', value: optionsRef.current.user1 },
{ label: 'User 2', title: 'user2', value: optionsRef.current.user2 },
{ label: 'User 3', title: 'user3', value: optionsRef.current.user3 },
{ label: 'User 4', title: 'user4', value: optionsRef.current.user4 },
{ label: 'User 5', title: 'user5', value: optionsRef.current.user5 },
{ label: 'User 6', title: 'user6', value: optionsRef.current.user6 },
{ label: 'User 7', title: 'user7', value: optionsRef.current.user7 },
{ label: 'User 8', title: 'user8', value: optionsRef.current.user8 },
{ label: 'User 9', title: 'user9', value: optionsRef.current.user9 },
];
return (
<div className={style.uploadOptions}>
<div className={style.twoColumn}>
<ImportMapTable title='Import options' fields={worksheet} handleOnChange={updateRef} />
</div>
<div className={style.twoColumn}>
<ImportMapTable title='Timings' fields={timings} handleOnChange={updateRef} />
<ImportMapTable title='Options' fields={options} handleOnChange={updateRef} />
</div>
<div className={style.twoColumn}>
<ImportMapTable title='Titles' fields={titles} handleOnChange={updateRef} />
<ImportMapTable title='User Fields' fields={userFields} handleOnChange={updateRef} />
</div>
</div>
);
}
@@ -0,0 +1,22 @@
@use '../../../../theme/v2Styles' as *;
@use '../../../../theme/ontimeColours' as *;
.importTable {
margin: 0.5rem;
font-size: $inner-section-text-size;
height: fit-content;
thead {
color: $gray-500;
text-transform: uppercase;
}
tr:hover {
background-color: $gray-50;
}
}
.label {
display: inline-block;
width: 100%;
}
@@ -0,0 +1,50 @@
import { Input } from '@chakra-ui/react';
import { ExcelInputOptions } from '../UploadModal';
import style from './ImportMapTable.module.scss';
// TODO: make this generic
export type TableEntry = { label: string; title: keyof ExcelInputOptions; value: string };
interface ImportMapTableProps {
title: string;
fields: TableEntry[];
handleOnChange: (field: keyof ExcelInputOptions, value: string) => void;
}
export default function ImportMapTable(props: ImportMapTableProps) {
const { title, fields, handleOnChange } = props;
return (
<table className={style.importTable}>
<thead>{title}</thead>
<tbody>
{fields.map((field) => {
return (
<tr key={field.title}>
<td>
<label className={style.label} htmlFor={field.title}>
{field.title}
</label>
</td>
<td>
<Input
id={field.title}
size='xs'
variant='ontime-filled-on-light'
maxLength={25}
defaultValue={field.value}
placeholder='Use default column name'
onBlur={(event) => {
handleOnChange(field.title, event.target.value);
}}
/>
</td>
</tr>
);
})}
</tbody>
</table>
);
}
@@ -0,0 +1,37 @@
import { MutableRefObject } from 'react';
import { Switch } from '@chakra-ui/react';
import ModalSplitInput from '../../ModalSplitInput';
import { OntimeInputOptions } from '../UploadModal';
import style from '../UploadModal.module.scss';
interface OntimeFileOptionsProps {
optionsRef: MutableRefObject<OntimeInputOptions>;
}
export default function OntimeFileOptions(props: OntimeFileOptionsProps) {
const { optionsRef } = props;
const updateRef = <T extends keyof OntimeInputOptions>(field: T, value: OntimeInputOptions[T]) => {
optionsRef.current = { ...optionsRef.current, [field]: value };
};
return (
<div className={style.uploadOptions}>
<span className={style.title}>Import options</span>
<ModalSplitInput
field=''
title='Only import rundown'
description='All other options, including application settings will be discarded'
>
<Switch
variant='ontime-on-light'
onChange={(e) => {
updateRef('onlyImportRundown', e.target.checked);
}}
/>
</ModalSplitInput>
</div>
);
}
@@ -0,0 +1,40 @@
@use '../../../../theme/ontimeColours' as *;
@mixin row {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0 0.25rem;
}
.stepRow {
display: flex;
gap: 2rem;
align-items: center;
margin: 0 auto;
font-size: 1rem;
}
.idle {
@include row;
color: $blue-500;
}
.inactive {
@include row;
color: $gray-700;
}
.active {
@include row;
color: $blue-700;
}
.inactiveIcon {
color: $gray-700;
}
.activeIcon {
color: $blue-500;
}
@@ -0,0 +1,26 @@
import { IoCheckmarkCircle } from '@react-icons/all-files/io5/IoCheckmarkCircle';
import { IoChevronForward } from '@react-icons/all-files/io5/IoChevronForward';
import { IoEllipseOutline } from '@react-icons/all-files/io5/IoEllipseOutline';
import type { UploadStep } from '../UploadModal';
import style from './UploadStep.module.scss';
export default function UploadStepTracker({ uploadStep }: { uploadStep: UploadStep }) {
const isUpload = uploadStep === 'upload';
const isReview = uploadStep === 'review';
return (
<div className={style.stepRow}>
<div className={isUpload ? style.active : style.idle}>
<IoCheckmarkCircle />
Upload
</div>
<IoChevronForward className={isReview ? style.activeIcon : style.inactiveIcon} />
<div className={isReview ? style.active : style.inactive}>
{isReview ? <IoCheckmarkCircle /> : <IoEllipseOutline />}
Review
</div>
</div>
);
}
@@ -0,0 +1,21 @@
import { create } from 'zustand';
type UploadModalContext = {
file: File | null;
setFile: (file: File | null) => void;
progress: number;
setProgress: (progress: number) => void;
clear: () => void;
};
export const useUploadModalContextStore = create<UploadModalContext>((set) => ({
file: null,
setFile: (file: File | null) => set({ file }),
progress: 0,
setProgress: (progress: number) => set({ progress }),
clear: () => set({ file: null, progress: 0 }),
}));
@@ -0,0 +1,70 @@
type ValidationStatus = {
errors: string[];
isValid: boolean;
};
export function validateFile(file: File): ValidationStatus {
const status: ValidationStatus = { errors: [], isValid: true };
if (!file) {
status.errors.push('No file to upload');
status.isValid = false;
}
// Limit file size to 1MB
if (file.size > 1000000) {
status.errors.push('File size limit (1MB) exceeded');
status.isValid = false;
}
// Check file extension
if (!file.name.endsWith('.xlsx') && !file.name.endsWith('.json')) {
status.errors.push('Unhandled file type');
status.isValid = false;
}
return status;
}
export type MaybeFile = null | 'ontime' | 'excel';
export function isExcelFile(file: File | null) {
return file?.name.endsWith('.xlsx');
}
export function isOntimeFile(file: File | null) {
return file?.name.endsWith('.json');
}
export type ExcelImportMapKeys = keyof typeof defaultExcelImportMap;
export const defaultExcelImportMap = {
worksheet: 'ontime',
projectName: 'project name',
projectDescription: 'project description',
publicUrl: 'public url',
publicInfo: 'public info',
backstageUrl: 'backstage url',
backstageInfo: 'backstage info',
timeStart: 'start',
timeEnd: 'end',
duration: 'duration',
cue: 'cue',
title: 'title',
presenter: 'presenter',
subtitle: 'subtitle',
isPublic: 'public',
skip: 'skip',
note: 'note',
colour: 'colour',
endAction: 'end action',
timerType: 'timer type',
user0: 'user0',
user1: 'user1',
user2: 'user2',
user3: 'user3',
user4: 'user4',
user5: 'user5',
user6: 'user6',
user7: 'user7',
user8: 'user8',
user9: 'user9',
};
@@ -1,25 +0,0 @@
type ValidationStatus = {
errors: string[];
isValid: boolean;
};
export function validateFile(file: File): ValidationStatus {
const status: ValidationStatus = { errors: [], isValid: true };
if (!file) {
status.errors.push('No file to upload');
status.isValid = false;
}
// Limit file size to 1MB
if (file.size > 1000000) {
status.errors.push('File size limit (1MB) exceeded');
status.isValid = false;
}
// Check file extension
if (!file.name.endsWith('.xlsx') && !file.name.endsWith('.json')) {
status.errors.push('Unhandled file type');
status.isValid = false;
}
return status;
}
@@ -1,8 +1,12 @@
@use '../../theme/v2Styles' as *;
.eventContainer {
flex: 1;
margin-top: 1em;
display: flex;
flex-direction: column;
padding: 8px 4px 8px 0;
padding: 0 4px;
overflow-y: scroll;
-ms-overflow-style: -ms-autohiding-scrollbar;
height: 100%;
@@ -22,11 +26,29 @@
.alignCenter {
text-align: center;
flex-direction: column;
.spaceTop {
margin-top: 24px;
margin-top: 1.5rem;
}
}
.spacer {
min-height: 50vh;
}
}
.entryWrapper {
display: flex;
gap: 0.5rem;
align-items: center;
}
.entryIndex {
text-align: right;
min-width: 2em;
color: $label-gray;
font-size: calc(1rem - 3px);
}
.entry {
flex: 1;
}
+28 -24
View File
@@ -1,13 +1,14 @@
import { lazy, MutableRefObject, useCallback, useEffect, useRef, useState } from 'react';
import { Fragment, lazy, MutableRefObject, useCallback, useEffect, useRef, useState } from 'react';
import { closestCenter, DndContext, DragEndEvent, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
import { arrayMove, SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { OntimeRundown, Playback, SupportedEvent } from 'ontime-types';
import { getFirst, getNext, getPrevious } from 'ontime-utils';
import { useEventAction } from '../../common/hooks/useEventAction';
import { useRundownEditor } from '../../common/hooks/useSocket';
import { AppMode, useAppMode } from '../../common/stores/appModeStore';
import { useEditorSettings } from '../../common/stores/editorSettings';
import { cloneEvent, getFirstEvent, getNextEvent, getPreviousEvent } from '../../common/utils/eventsManager';
import { cloneEvent } from '../../common/utils/eventsManager';
import QuickAddBlock from './quick-add-block/QuickAddBlock';
import RundownEmpty from './RundownEmpty';
@@ -59,8 +60,7 @@ export default function Rundown(props: RundownProps) {
if (type === 'clone') {
const cursorEvent = entries.find((event) => event.id === cursor);
if (cursorEvent?.type === SupportedEvent.Event) {
const newEvent = cloneEvent(cursorEvent);
newEvent.after = cursorEvent.id;
const newEvent = cloneEvent(cursorEvent, cursorEvent.id);
addEvent(newEvent);
}
} else if (type === SupportedEvent.Event) {
@@ -93,7 +93,7 @@ export default function Rundown(props: RundownProps) {
if (entries.length < 1) {
return;
}
const nextEvent = cursor == null ? getFirstEvent(entries) : getNextEvent(entries, cursor);
const nextEvent = cursor == null ? getFirst(entries) : getNext(entries, cursor);
if (nextEvent) {
moveCursorTo(nextEvent.id, nextEvent.type === SupportedEvent.Event);
}
@@ -104,7 +104,7 @@ export default function Rundown(props: RundownProps) {
return;
}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we check for this before
const previousEvent = cursor == null ? getFirstEvent(entries) : getPreviousEvent(entries, cursor);
const previousEvent = cursor == null ? getFirst(entries) : getPrevious(entries, cursor);
if (previousEvent) {
moveCursorTo(previousEvent.id, previousEvent.type === SupportedEvent.Event);
}
@@ -206,7 +206,7 @@ export default function Rundown(props: RundownProps) {
let previousEnd = 0;
let thisEnd = 0;
let previousEventId: string | undefined;
let eventIndex = -1;
let eventIndex = 0;
let isPast = Boolean(featureData?.selectedEventId);
return (
@@ -216,7 +216,7 @@ export default function Rundown(props: RundownProps) {
<div className={style.list}>
{statefulEntries.map((entry, index) => {
if (index === 0) {
eventIndex = -1;
eventIndex = 0;
}
if (entry.type === SupportedEvent.Event) {
eventIndex++;
@@ -233,21 +233,25 @@ export default function Rundown(props: RundownProps) {
}
return (
<div key={entry.id} ref={hasCursor ? cursorRef : undefined}>
<RundownEntry
type={entry.type}
eventIndex={eventIndex}
isPast={isPast}
data={entry}
selected={isSelected}
hasCursor={hasCursor}
next={isNext}
previousEnd={previousEnd}
previousEventId={previousEventId}
playback={isSelected ? featureData.playback : undefined}
isRolling={featureData.playback === Playback.Roll}
disableEdit={isExtracted}
/>
<Fragment key={entry.id}>
<div className={style.entryWrapper} data-testid={`entry-${eventIndex}`}>
{entry.type === SupportedEvent.Event && <div className={style.entryIndex}>{eventIndex}</div>}
<div className={style.entry} key={entry.id} ref={hasCursor ? cursorRef : undefined}>
<RundownEntry
type={entry.type}
isPast={isPast}
data={entry}
selected={isSelected}
hasCursor={hasCursor}
next={isNext}
previousEnd={previousEnd}
previousEventId={previousEventId}
playback={isSelected ? featureData.playback : undefined}
isRolling={featureData.playback === Playback.Roll}
disableEdit={isExtracted}
/>
</div>
</div>
{((showQuickEntry && hasCursor) || isLast) && (
<QuickAddBlock
showKbd={hasCursor}
@@ -257,7 +261,7 @@ export default function Rundown(props: RundownProps) {
disableAddBlock={entry.type === SupportedEvent.Block}
/>
)}
</div>
</Fragment>
);
})}
<div className={style.spacer} />
@@ -1,8 +1,10 @@
import { useCallback } from 'react';
import { OntimeEvent, OntimeRundownEntry, Playback, SupportedEvent } from 'ontime-types';
import { calculateDuration } from 'ontime-utils';
import { calculateDuration, getCueCandidate } from 'ontime-utils';
import { RUNDOWN_TABLE } from '../../common/api/apiConstants';
import { useEventAction } from '../../common/hooks/useEventAction';
import { ontimeQueryClient } from '../../common/queryClient';
import { useAppMode } from '../../common/stores/appModeStore';
import { useEditorSettings } from '../../common/stores/editorSettings';
import { useEmitLog } from '../../common/stores/logger';
@@ -12,11 +14,10 @@ import BlockBlock from './block-block/BlockBlock';
import DelayBlock from './delay-block/DelayBlock';
import EventBlock from './event-block/EventBlock';
export type EventItemActions = 'set-cursor' | 'event' | 'delay' | 'block' | 'delete' | 'clone' | 'update';
export type EventItemActions = 'set-cursor' | 'event' | 'delay' | 'block' | 'delete' | 'clone' | 'update' | 'swap';
interface RundownEntryProps {
type: SupportedEvent;
eventIndex: number;
isPast: boolean;
data: OntimeRundownEntry;
selected: boolean;
@@ -30,21 +31,10 @@ interface RundownEntryProps {
}
export default function RundownEntry(props: RundownEntryProps) {
const {
eventIndex,
isPast,
data,
selected,
hasCursor,
next,
previousEnd,
previousEventId,
playback,
isRolling,
disableEdit,
} = props;
const { isPast, data, selected, hasCursor, next, previousEnd, previousEventId, playback, isRolling, disableEdit } =
props;
const { emitError } = useEmitLog();
const { addEvent, updateEvent, deleteEvent } = useEventAction();
const { addEvent, updateEvent, deleteEvent, swapEvents } = useEventAction();
const cursor = useAppMode((state) => state.cursor);
const setCursor = useAppMode((state) => state.setCursor);
@@ -95,6 +85,12 @@ export default function RundownEntry(props: RundownEntryProps) {
addEvent({ type: SupportedEvent.Block }, { after: data.id });
break;
}
case 'swap': {
const { value } = payload as FieldValue;
swapEvents({ from: value as string, to: data.id });
break;
}
case 'delete': {
if (openId === data.id) {
removeOpenEvent();
@@ -104,6 +100,7 @@ export default function RundownEntry(props: RundownEntryProps) {
}
case 'clone': {
const newEvent = cloneEvent(data as OntimeEvent, data.id);
newEvent.cue = getCueCandidate(ontimeQueryClient.getQueryData(RUNDOWN_TABLE) || [], data.id);
addEvent(newEvent);
break;
}
@@ -149,16 +146,17 @@ export default function RundownEntry(props: RundownEntryProps) {
removeOpenEvent,
startTimeIsLastEnd,
updateEvent,
swapEvents,
],
);
if (data.type === SupportedEvent.Event) {
return (
<EventBlock
cue={data.cue}
timeStart={data.timeStart}
timeEnd={data.timeEnd}
duration={data.duration}
eventIndex={eventIndex + 1}
eventId={data.id}
isPublic={data.isPublic}
endAction={data.endAction}
@@ -6,11 +6,12 @@
background-color: $block-bg2;
min-width: 34rem;
display: grid;
grid-template-columns: 32px 1fr auto;
grid-template-columns: 2rem 1fr auto;
align-items: center;
height: $secondary-block-height;
gap: 8px;
gap: 0.5rem;
&.hasCursor {
outline: 1px solid $block-cursor-color;
@@ -59,7 +59,7 @@ export default function BlockBlock(props: BlockBlockProps) {
<IoReorderTwo />
</span>
<EditableBlockTitle title={data.title} eventId={data.id} placeholder='Block title' />
<BlockActionMenu className={style.actionMenu} showAdd showDelay enableDelete actionHandler={actionHandler} />
<BlockActionMenu className={style.actionMenu} enableDelete actionHandler={actionHandler} />
</div>
);
}
@@ -6,12 +6,13 @@
background-color: $block-bg2;
min-width: 34rem;
display: grid;
grid-template-columns: 32px 1fr auto;
grid-template-columns: 2rem 1fr auto;
grid-template-areas: 'drag inpt btns';
align-items: center;
height: $secondary-block-height;
gap: 8px;
gap: 0.5rem;
&.hasCursor {
outline: 1px solid $block-cursor-color;
@@ -79,7 +79,7 @@ export default function DelayBlock(props: DelayBlockProps) {
<Button onClick={cancelDelayHandler} size='sm' leftIcon={<IoClose />} variant='ontime-subtle-white'>
Cancel
</Button>
<BlockActionMenu showAdd enableDelete actionHandler={actionHandler} />
<BlockActionMenu enableDelete actionHandler={actionHandler} />
</HStack>
</div>
);
@@ -90,6 +90,18 @@ $skip-opacity: 0.1;
position: absolute;
margin-top: 0.25rem;
}
.cue {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
width: 5.5rem;
text-align: center;
font-weight: 600;
letter-spacing: 0.5px;
rotate: -90deg;
}
}
.playbackActions {
@@ -1,27 +1,29 @@
import { MouseEvent, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
import { IoCopyOutline } from '@react-icons/all-files/io5/IoCopyOutline';
import { IoPeopleOutline } from '@react-icons/all-files/io5/IoPeopleOutline';
import { IoReorderTwo } from '@react-icons/all-files/io5/IoReorderTwo';
import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical';
import { EndAction, OntimeEvent, Playback, TimerType } from 'ontime-types';
import { useContextMenu } from '../../../common/hooks/useContextMenu';
import { useEventAction } from '../../../common/hooks/useEventAction';
import { useAppMode } from '../../../common/stores/appModeStore';
import copyToClipboard from '../../../common/utils/copyToClipboard';
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
import type { EventItemActions } from '../RundownEntry';
import { useEventIdSwapping } from '../useEventIdSwapping';
import EventBlockInner from './EventBlockInner';
import style from './EventBlock.module.scss';
interface EventBlockProps {
cue: string;
timeStart: number;
timeEnd: number;
duration: number;
eventIndex: number;
eventId: string;
isPublic: boolean;
endAction: EndAction;
@@ -52,11 +54,11 @@ interface EventBlockProps {
export default function EventBlock(props: EventBlockProps) {
const {
eventId,
cue,
timeStart,
timeEnd,
duration,
eventIndex,
eventId,
isPublic = true,
endAction,
timerType,
@@ -75,22 +77,37 @@ export default function EventBlock(props: EventBlockProps) {
actionHandler,
disableEdit,
} = props;
const { updateEvent } = useEventAction();
const { selectedEventId, setSelectedEventId, clearSelectedEventId } = useEventIdSwapping();
const moveCursorTo = useAppMode((state) => state.setCursor);
const handleRef = useRef<null | HTMLSpanElement>(null);
const [isVisible, setIsVisible] = useState(false);
const openId = useAppMode((state) => state.editId);
const [onContextMenu] = useContextMenu<HTMLDivElement>([
{ label: `Copy ID: ${eventId}}`, icon: IoCopyOutline, onClick: () => copyToClipboard(eventId) },
{ label: `Copy ID: ${eventId}`, icon: IoCopyOutline, onClick: () => copyToClipboard(eventId) },
{
label: 'Toggle public',
icon: IoPeopleOutline,
onClick: () =>
updateEvent({
id: eventId,
isPublic: !isPublic,
actionHandler('update', {
field: 'isPublic',
value: !isPublic,
}),
},
{
label: 'Add to swap',
icon: IoAdd,
onClick: () => setSelectedEventId(eventId),
withDivider: true,
},
{
label: `Swap this event with ${selectedEventId ?? ''}`,
icon: IoSwapVertical,
onClick: () => {
actionHandler('swap', { field: 'id', value: selectedEventId });
clearSelectedEventId();
},
isDisabled: selectedEventId == null || selectedEventId === eventId,
},
]);
const {
@@ -170,7 +187,7 @@ export default function EventBlock(props: EventBlockProps) {
<span className={style.drag} ref={handleRef} {...dragAttributes} {...dragListeners}>
<IoReorderTwo />
</span>
{eventIndex}
<span className={style.cue}>{cue}</span>
</div>
{isVisible && (
<EventBlockInner
@@ -1,5 +1,6 @@
import { memo, useCallback, useEffect, useState } from 'react';
import { Tooltip } from '@chakra-ui/react';
import { BiArrowToBottom } from '@react-icons/all-files/bi/BiArrowToBottom';
import { IoArrowDown } from '@react-icons/all-files/io5/IoArrowDown';
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
import { IoOptions } from '@react-icons/all-files/io5/IoOptions';
@@ -144,7 +145,10 @@ const EventBlockInner = (props: EventBlockInnerProps) => {
</Tooltip>
<Tooltip label={`${isPublic ? 'Event is public' : 'Event is private'}`} {...tooltipProps}>
<span>
<IoPeople className={`${style.statusIcon} ${isPublic ? style.active : style.disabled}`} />
<IoPeople
className={`${style.statusIcon} ${isPublic ? style.active : style.disabled}`}
data-isPublic={isPublic}
/>
</span>
</Tooltip>
</div>
@@ -163,7 +167,7 @@ const EventBlockInner = (props: EventBlockInnerProps) => {
color={isOpen ? 'white' : '#f6f6f6'}
isDisabled={disableEdit}
/>
<BlockActionMenu showAdd showDelay showBlock showClone enableDelete={!selected} actionHandler={actionHandler} />
<BlockActionMenu showClone enableDelete={!selected} actionHandler={actionHandler} />
</div>
</>
);
@@ -193,5 +197,8 @@ function TimerIcon(props: { type: TimerType; className: string }) {
if (type === TimerType.Clock) {
return <IoTime className={className} />;
}
if (type === TimerType.TimeToEnd) {
return <BiArrowToBottom className={className} />;
}
return <IoArrowDown className={className} />;
}
@@ -11,9 +11,6 @@ import { tooltipDelayMid } from '../../../../ontimeConfig';
import { EventItemActions } from '../../RundownEntry';
interface BlockActionMenuProps {
showAdd?: boolean;
showDelay?: boolean;
showBlock?: boolean;
enableDelete?: boolean;
showClone?: boolean;
actionHandler: (action: EventItemActions, payload?: any) => void;
@@ -21,7 +18,7 @@ interface BlockActionMenuProps {
}
export default function BlockActionMenu(props: BlockActionMenuProps) {
const { showAdd, showDelay, showBlock, enableDelete, showClone, actionHandler, className } = props;
const { enableDelete, showClone, actionHandler, className } = props;
const handleAddEvent = useCallback(() => actionHandler('event'), [actionHandler]);
const handleAddDelay = useCallback(() => actionHandler('delay'), [actionHandler]);
@@ -43,17 +40,17 @@ export default function BlockActionMenu(props: BlockActionMenuProps) {
/>
</Tooltip>
<MenuList>
<MenuItem icon={<IoAdd />} onClick={handleAddEvent} isDisabled={!showAdd}>
<MenuItem icon={<IoAdd />} onClick={handleAddEvent}>
Add Event after
</MenuItem>
<MenuItem icon={<IoTimerOutline />} onClick={handleAddDelay} isDisabled={!showDelay}>
<MenuItem icon={<IoTimerOutline />} onClick={handleAddDelay}>
Add Delay after
</MenuItem>
<MenuItem icon={<IoRemoveCircleOutline />} onClick={handleAddBlock} isDisabled={!showBlock}>
<MenuItem icon={<IoRemoveCircleOutline />} onClick={handleAddBlock}>
Add Block after
</MenuItem>
{showClone && (
<MenuItem icon={<IoDuplicateOutline />} onClick={handleClone} isDisabled={!showBlock}>
<MenuItem icon={<IoDuplicateOutline />} onClick={handleClone}>
Clone event
</MenuItem>
)}
@@ -4,26 +4,27 @@
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
margin: 4px 0;
font-size: 12px;
padding: 0 10px;
margin: 0.25rem 0;
font-size: $inner-section-text-size;
padding: 0 0.75rem;
gap: 1rem;
}
.btnRow {
justify-self: center;
display: flex;
gap: 10%;
gap: max(0.5rem, 2rem);
.quickBtn {
font-weight: 400;
width: auto;
padding: 0 32px;
padding: 0 1rem;
}
}
.keyboard {
margin-left: 8px;
padding: 0 4px;
margin-left: 0.5rem;
padding: 0 0.25rem;
color: $label-gray;
border-radius: 2px;
background-color: rgba(0, 0, 0, 0.1);
@@ -32,4 +33,5 @@
.options {
display: flex;
flex-direction: column;
white-space: nowrap;
}
@@ -80,6 +80,7 @@ const QuickAddBlock = (props: QuickAddBlockProps) => {
size='xs'
variant='ontime-subtle-white'
className={style.quickBtn}
data-testid='quick-add-event'
>
Event {showKbd && <span className={style.keyboard}>Alt + E</span>}
</Button>
@@ -91,6 +92,7 @@ const QuickAddBlock = (props: QuickAddBlockProps) => {
variant='ontime-subtle-white'
disabled={disableAddDelay}
className={style.quickBtn}
data-testid='quick-add-delay'
>
Delay {showKbd && <span className={style.keyboard}>Alt + D</span>}
</Button>
@@ -102,6 +104,7 @@ const QuickAddBlock = (props: QuickAddBlockProps) => {
variant='ontime-subtle-white'
disabled={disableAddBlock}
className={style.quickBtn}
data-testid='quick-add-block'
>
Block {showKbd && <span className={style.keyboard}>Alt + B</span>}
</Button>
@@ -0,0 +1,13 @@
import { create } from 'zustand';
interface EventIdSwappingStore {
selectedEventId: string | null;
setSelectedEventId: (newEventId: string | null) => void;
clearSelectedEventId: () => void;
}
export const useEventIdSwapping = create<EventIdSwappingStore>((set) => ({
selectedEventId: null,
setSelectedEventId: (newEventId) => set(() => ({ selectedEventId: newEventId })),
clearSelectedEventId: () => set(() => ({ selectedEventId: null })),
}));
@@ -3,7 +3,7 @@ import { ComponentType, useMemo } from 'react';
import { TitleBlock } from 'ontime-types';
import { useStore } from 'zustand';
import useEventData from '../../common/hooks-query/useEventData';
import useProjectData from '../../common/hooks-query/useProjectData';
import useRundown from '../../common/hooks-query/useRundown';
import useViewSettings from '../../common/hooks-query/useViewSettings';
import { runtime } from '../../common/stores/runtime';
@@ -18,7 +18,7 @@ const withData = <P extends object>(Component: ComponentType<P>) => {
// HTTP API data
const { data: rundownData } = useRundown();
const { data: eventData } = useEventData();
const { data: project } = useProjectData();
const { data: viewSettings } = useViewSettings();
const publicEvents = useMemo(() => {
@@ -104,7 +104,7 @@ const withData = <P extends object>(Component: ComponentType<P>) => {
publicSelectedId={publicSelectedId}
viewSettings={viewSettings}
nextId={nextId}
general={eventData}
general={project}
onAir={onAir}
/>
);
@@ -25,7 +25,7 @@
/* =================== HEADER + EXTRAS ===================*/
.event-header {
.project-header {
grid-area: header;
font-size: clamp(32px, 4.5vw, 64px);
font-weight: 600;
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react';
import QRCode from 'react-qr-code';
import { AnimatePresence, motion } from 'framer-motion';
import { EventData, Message, OntimeEvent, ViewSettings } from 'ontime-types';
import { Message, OntimeEvent, ProjectData, SupportedEvent, ViewSettings } from 'ontime-types';
import { formatDisplay } from 'ontime-utils';
import { overrideStylesURL } from '../../../common/api/apiConstants';
@@ -15,7 +15,6 @@ import { TIME_FORMAT_OPTION } from '../../../common/components/view-params-edito
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
import { TimeManagerType } from '../../../common/models/TimeManager.type';
import { getEventsWithDelay } from '../../../common/utils/eventsManager';
import { formatTime } from '../../../common/utils/time';
import { useTranslation } from '../../../translation/TranslationProvider';
import { titleVariants } from '../common/animation';
@@ -35,7 +34,7 @@ interface BackstageProps {
time: TimeManagerType;
backstageEvents: OntimeEvent[];
selectedId: string | null;
general: EventData;
general: ProjectData;
viewSettings: ViewSettings;
}
@@ -74,7 +73,7 @@ export default function Backstage(props: BackstageProps) {
: formatTime(time.expectedFinish, formatOptions);
const qrSize = Math.max(window.innerWidth / 15, 128);
const filteredEvents = getEventsWithDelay(backstageEvents);
const filteredEvents = backstageEvents.filter((event) => event.type === SupportedEvent.Event);
const showPublicMessage = publ.text && publ.visible;
const showProgress = time.playback !== 'stop';
@@ -94,7 +93,7 @@ export default function Backstage(props: BackstageProps) {
<div className={`backstage ${isMirrored ? 'mirror' : ''}`} data-testid='backstage-view'>
<NavigationMenu />
<ViewParamsEditor paramFields={[TIME_FORMAT_OPTION]} />
<div className='event-header'>
<div className='project-header'>
{general.title}
<div className='clock-container'>
<div className='label'>{getLocalizedString('common.time_now')}</div>
@@ -167,7 +166,7 @@ export default function Backstage(props: BackstageProps) {
<ScheduleProvider events={filteredEvents} selectedEventId={selectedId} isBackstage>
<ScheduleNav className='schedule-nav-container' />
<Schedule className='schedule-container' />
<Schedule isProduction className='schedule-container' />
</ScheduleProvider>
<div className={showPublicMessage ? 'public-container' : 'public-container public-container--hidden'}>
@@ -17,7 +17,7 @@ export function getTimerByType(timerObject?: TimerTypeParams): string | number |
return timer;
}
if (timerObject.timerType === TimerType.CountDown) {
if (timerObject.timerType === TimerType.CountDown || timerObject.timerType === TimerType.TimeToEnd) {
timer = timerObject.current;
} else if (timerObject.timerType === TimerType.CountUp) {
timer = timerObject.elapsed;
@@ -9,7 +9,6 @@ import { TIME_FORMAT_OPTION } from '../../../common/components/view-params-edito
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
import { TimeManagerType } from '../../../common/models/TimeManager.type';
import getDelayTo from '../../../common/utils/getDelayTo';
import { formatTime } from '../../../common/utils/time';
import { useTranslation } from '../../../translation/TranslationProvider';
@@ -72,7 +71,7 @@ export default function Countdown(props: CountdownProps) {
if (followThis !== null) {
setFollow(followThis);
const idx: number = backstageEvents.findIndex((event: OntimeRundownEntry) => event.id === followThis?.id);
const delayToEvent = getDelayTo(backstageEvents, idx);
const delayToEvent = backstageEvents[idx]?.delay ?? 0;
setDelay(delayToEvent);
}
}, [backstageEvents, searchParams]);
@@ -1,6 +1,6 @@
import { useEffect } from 'react';
import { useSearchParams } from 'react-router-dom';
import { EventData, Playback, TimerMessage, TimerType, ViewSettings } from 'ontime-types';
import { Playback, TimerMessage, TimerType, ViewSettings } from 'ontime-types';
import { overrideStylesURL } from '../../../common/api/apiConstants';
import NavigationMenu from '../../../common/components/navigation-menu/NavigationMenu';
@@ -19,7 +19,6 @@ interface MinimalTimerProps {
pres: TimerMessage;
time: TimeManagerType;
viewSettings: ViewSettings;
general: EventData;
}
export default function MinimalTimer(props: MinimalTimerProps) {
@@ -24,7 +24,7 @@
/* =================== HEADER + EXTRAS ===================*/
.event-header {
.project-header {
grid-area: header;
font-size: clamp(32px, 4.5vw, 64px);
font-weight: 600;
@@ -1,7 +1,7 @@
import { useEffect } from 'react';
import QRCode from 'react-qr-code';
import { AnimatePresence, motion } from 'framer-motion';
import { EventData, Message, OntimeEvent, ViewSettings } from 'ontime-types';
import { Message, OntimeEvent, ProjectData, ViewSettings } from 'ontime-types';
import { overrideStylesURL } from '../../../common/api/apiConstants';
import NavigationMenu from '../../../common/components/navigation-menu/NavigationMenu';
@@ -32,7 +32,7 @@ interface BackstageProps {
time: TimeManagerType;
events: OntimeEvent[];
publicSelectedId: string | null;
general: EventData;
general: ProjectData;
viewSettings: ViewSettings;
}
@@ -58,7 +58,7 @@ export default function Public(props: BackstageProps) {
<div className={`public-screen ${isMirrored ? 'mirror' : ''}`} data-testid='public-view'>
<NavigationMenu />
<ViewParamsEditor paramFields={[TIME_FORMAT_OPTION]} />
<div className='event-header'>
<div className='project-header'>
{general.title}
<div className='clock-container'>
<div className='label'>{getLocalizedString('common.time_now')}</div>
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import type { OntimeRundown, ViewSettings } from 'ontime-types';
import type { OntimeEvent, OntimeRundown, ViewSettings } from 'ontime-types';
import { SupportedEvent } from 'ontime-types';
import { formatDisplay } from 'ontime-utils';
import { overrideStylesURL } from '../../../common/api/apiConstants';
@@ -11,15 +12,11 @@ import useFitText from '../../../common/hooks/useFitText';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
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 { type ScheduleEvent, formatEventList, trimRundown } from './studioClock.utils';
import './StudioClock.scss';
const formatOptions = {
@@ -66,8 +63,8 @@ export default function StudioClock(props: StudioClockProps) {
return;
}
const delayed = getEventsWithDelay(backstageEvents);
const trimmed = trimRundown(delayed, selectedId || '', MAX_TITLES);
const delayed = backstageEvents.filter((event) => event.type === SupportedEvent.Event);
const trimmed = trimRundown(delayed as OntimeEvent[], selectedId || '', MAX_TITLES);
const formatted = formatEventList(trimmed, selectedId || '', nextId || '', {
showEnd: false,
@@ -0,0 +1,203 @@
import { OntimeEvent, SupportedEvent } from 'ontime-types';
import { formatEventList, trimRundown } from '../studioClock.utils';
describe('test trimEventlist function', () => {
const limit = 8;
const testData = [
{ id: '1' },
{ id: '2' },
{ id: '3' },
{ id: '4' },
{ id: '5' },
{ id: '6' },
{ id: '7' },
{ id: '8' },
{ id: '9' },
{ id: '10' },
{ id: '11' },
{ id: '12' },
];
it('when we use the first item', () => {
const selectedId = '1';
const expected = [
{ id: '1' },
{ id: '2' },
{ id: '3' },
{ id: '4' },
{ id: '5' },
{ id: '6' },
{ id: '7' },
{ id: '8' },
];
const l = trimRundown(testData as OntimeEvent[], selectedId, limit);
expect(l.length).toBe(limit);
expect(l).toStrictEqual(expected);
});
it('when we use the third item', () => {
const selectedId = '3';
const expected = [
{ id: '1' },
{ id: '2' },
{ id: '3' },
{ id: '4' },
{ id: '5' },
{ id: '6' },
{ id: '7' },
{ id: '8' },
];
const l = trimRundown(testData as OntimeEvent[], selectedId, limit);
expect(l.length).toBe(limit);
expect(l).toStrictEqual(expected);
});
it('when we use the fourth item', () => {
const selectedId = '4';
const expected = [
{ id: '2' },
{ id: '3' },
{ id: '4' },
{ id: '5' },
{ id: '6' },
{ id: '7' },
{ id: '8' },
{ id: '9' },
];
const l = trimRundown(testData as OntimeEvent[], selectedId, limit);
expect(l.length).toBe(limit);
expect(l).toStrictEqual(expected);
});
it('if selected is not found', () => {
const selectedId = '15';
const expected = [
{ id: '1' },
{ id: '2' },
{ id: '3' },
{ id: '4' },
{ id: '5' },
{ id: '6' },
{ id: '7' },
{ id: '8' },
];
const l = trimRundown(testData as OntimeEvent[], selectedId, limit);
expect(l.length).toBe(limit);
expect(l).toStrictEqual(expected);
});
});
describe('test formatEvents function', () => {
const testEvent = [
{
title: 'Welcome to Ontime',
subtitle: 'Subtitles are useful',
presenter: 'cpvalente',
note: 'Maybe a running note for the operator?',
timeStart: 28800000,
timeEnd: 30600000,
isPublic: false,
colour: '',
type: SupportedEvent.Event,
revision: 0,
id: '5946',
},
{
title: 'Unless recalled by the OSC address',
subtitle: '',
presenter: '',
note: 'In green, below',
timeStart: 34800000,
timeEnd: 35400000,
isPublic: false,
colour: '',
type: SupportedEvent.Event,
revision: 0,
id: '8ee5',
},
];
it('it parses correctly', () => {
const selectedId = 'otherEvent';
const nextId = 'notHere';
const expected = [
{
id: '5946',
time: '08:00 - 08:30',
title: 'Welcome to Ontime',
isNow: false,
isNext: false,
colour: '',
},
{
id: '8ee5',
time: '09:40 - 09:50',
title: 'Unless recalled by the OSC address',
isNow: false,
isNext: false,
colour: '',
},
];
const parsed = formatEventList(testEvent as OntimeEvent[], selectedId, nextId, { showEnd: true });
expect(parsed).toStrictEqual(expected);
});
it('it handles selected correctly', () => {
const selectedId = '5946';
const nextId = '8ee5';
const expected = [
{
id: '5946',
time: '08:00 - 08:30',
title: 'Welcome to Ontime',
isNow: true,
isNext: false,
colour: '',
},
{
id: '8ee5',
time: '09:40 - 09:50',
title: 'Unless recalled by the OSC address',
isNow: false,
isNext: true,
colour: '',
},
];
const parsed = formatEventList(testEvent as OntimeEvent[], selectedId, nextId, { showEnd: true });
expect(parsed).toStrictEqual(expected);
});
it('it handles next correctly', () => {
const selectedId = '8ee5';
const nextId = 'notHere';
const expected = [
{
id: '5946',
time: '08:00 - 08:30',
title: 'Welcome to Ontime',
isNow: false,
isNext: false,
colour: '',
},
{
id: '8ee5',
time: '09:40 - 09:50',
title: 'Unless recalled by the OSC address',
isNow: true,
isNext: false,
colour: '',
},
];
const parsed = formatEventList(testEvent as OntimeEvent[], selectedId, nextId, { showEnd: true });
expect(parsed).toStrictEqual(expected);
});
});
@@ -0,0 +1,76 @@
import { OntimeEvent } from 'ontime-types';
import { formatTime } from '../../../common/utils/time';
export type ScheduleEvent = {
id: string;
time: string;
title: string;
isNow: boolean;
isNext: boolean;
colour: string;
};
/**
* @description Returns trimmed event list array
* @param {Object[]} rundown - given rundown
* @param {string} selectedId - id of currently selected event
* @param {number} limit - max number of events to return
* @returns {Object[]} Event list with maximum <limit> objects
*/
export const trimRundown = (rundown: OntimeEvent[], selectedId: string, limit: number): OntimeEvent[] => {
if (rundown == null) return [];
const BEFORE = 2;
const trimmedRundown = [...rundown];
// limit events length if necessary
if (limit != null) {
while (trimmedRundown.length > limit) {
const idx = trimmedRundown.findIndex((e) => e.id === selectedId);
if (idx <= BEFORE) {
trimmedRundown.pop();
} else {
trimmedRundown.shift();
}
}
}
return trimmedRundown;
};
type FormatEventListOptionsProp = {
showEnd?: boolean;
};
/**
* @description Returns list of events formatted to be displayed
* @param {Object[]} rundown - given rundown
* @param {string} selectedId - id of currently selected event
* @param {string} nextId - id of next event
* @param {object} [options]
* @param {boolean} [options.showEnd] - whether to show the end time
* @returns {Object[]} Formatted list of events [{time: -, title: -, isNow, isNext}]
*/
export const formatEventList = (
rundown: OntimeEvent[],
selectedId: string,
nextId: string,
options: FormatEventListOptionsProp,
): ScheduleEvent[] => {
if (rundown == null) return [];
const { showEnd = false } = options;
// format list
return rundown.map((event) => {
const start = formatTime(event.timeStart + (event.delay || 0));
const end = formatTime(event.timeEnd + (event.delay || 0));
return {
id: event.id,
time: showEnd ? `${start} - ${end}` : start,
title: event.title,
isNow: event.id === selectedId,
isNext: event.id === nextId,
colour: event.colour,
};
});
};
@@ -1,6 +1,6 @@
import { useEffect } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import { EventData, Playback, TimerMessage, TimerType, ViewSettings } from 'ontime-types';
import { Playback, TimerMessage, TimerType, ViewSettings } from 'ontime-types';
import { overrideStylesURL } from '../../../common/api/apiConstants';
import MultiPartProgressBar from '../../../common/components/multi-part-progress-bar/MultiPartProgressBar';
@@ -40,7 +40,6 @@ const titleVariants = {
interface TimerProps {
isMirrored: boolean;
general: EventData;
pres: TimerMessage;
title: TitleManager;
time: TimeManagerType;

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