Compare commits

..

2 Commits

Author SHA1 Message Date
alex-arc 8f56bef9b7 chore: export client in resolver 2026-05-17 21:01:40 +02:00
alex-arc 3ae042416f feat: add load rundown api endpoint 2026-05-17 21:01:40 +02:00
311 changed files with 4173 additions and 13427 deletions
-5
View File
@@ -1,5 +0,0 @@
# CodeGraph data files — local to each machine, not for committing.
# Ignore everything in .codegraph/ except this file itself, so transient
# files (the database, daemon.pid, sockets, logs) never show up in git.
*
!.gitignore
+2 -10
View File
@@ -15,10 +15,6 @@ jobs:
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v6 uses: pnpm/action-setup@v6
@@ -58,10 +54,6 @@ jobs:
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v6 uses: pnpm/action-setup@v6
@@ -89,11 +81,11 @@ jobs:
key: ${{ runner.os }}-playwright-${{ env.PLAYWRIGHT_VERSION }} key: ${{ runner.os }}-playwright-${{ env.PLAYWRIGHT_VERSION }}
restore-keys: | restore-keys: |
${{ runner.os }}-playwright- ${{ runner.os }}-playwright-
- run: pnpm playwright install --with-deps - run: npx playwright install --with-deps
if: steps.playwright-cache.outputs.cache-hit != 'true' if: steps.playwright-cache.outputs.cache-hit != 'true'
- name: Install Playwright Browsers - name: Install Playwright Browsers
run: pnpm playwright install --with-deps run: npx playwright install --with-deps
- name: Run Playwright tests - name: Run Playwright tests
run: pnpm e2e run: pnpm e2e
-3
View File
@@ -54,6 +54,3 @@ ontime-data/
# temporary write files # temporary write files
**.tmp **.tmp
# Claude Code metadata
.claude/
+1 -1
View File
@@ -1 +1 @@
v22.22.3 v22.22.2
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getontime/cli", "name": "@getontime/cli",
"version": "4.12.0", "version": "4.8.0",
"author": "Carlos Valente", "author": "Carlos Valente",
"description": "Time keeping for live events", "description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime", "repository": "https://github.com/cpvalente/ontime",
+16 -16
View File
@@ -1,10 +1,10 @@
{ {
"name": "ontime-ui", "name": "ontime-ui",
"version": "4.12.0", "version": "4.8.0",
"private": true, "private": true,
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@base-ui/react": "1.6.0", "@base-ui/react": "1.3.0",
"@codemirror/commands": "^6.0.0", "@codemirror/commands": "^6.0.0",
"@codemirror/lang-css": "^6.0.0", "@codemirror/lang-css": "^6.0.0",
"@codemirror/state": "^6.0.0", "@codemirror/state": "^6.0.0",
@@ -12,27 +12,27 @@
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@fontsource/open-sans": "^5.2.7", "@fontsource/open-sans": "^5.2.6",
"@mantine/hooks": "^8.3.7", "@mantine/hooks": "^8.3.7",
"@sentry/react": "^10.59.0", "@sentry/react": "^10.2.0",
"@table-nav/react": "^0.0.7", "@table-nav/react": "^0.0.7",
"@tanstack/react-query": "^5.101.0", "@tanstack/react-query": "^5.85.9",
"@tanstack/react-query-devtools": "^5.101.0", "@tanstack/react-query-devtools": "^5.85.9",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"@uiw/codemirror-theme-vscode": "^4.25.10", "@uiw/codemirror-theme-vscode": "^4.25.9",
"autosize": "^6.0.1", "autosize": "^6.0.1",
"axios": "^1.18.0", "axios": "^1.12.2",
"csv-stringify": "^6.6.0", "csv-stringify": "^6.6.0",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"react": "^19.2.7", "react": "^19.2.3",
"react-colorful": "^5.7.0", "react-colorful": "^5.6.1",
"react-dom": "^19.2.7", "react-dom": "^19.2.3",
"react-fast-compare": "^3.2.2", "react-fast-compare": "^3.2.2",
"react-hook-form": "^7.80.0", "react-hook-form": "^7.72.0",
"react-icons": "5.6.0", "react-icons": "5.5.0",
"react-router": "^8.0.1", "react-router": "^7.11.0",
"react-virtuoso": "^4.18.7", "react-virtuoso": "^4.17.0",
"zustand": "^5.0.14" "zustand": "^5.0.9"
}, },
"scripts": { "scripts": {
"addversion": "node -p \"'export const ONTIME_VERSION = ' + JSON.stringify(require('../../package.json').version) + ';'\" > src/ONTIME_VERSION.js", "addversion": "node -p \"'export const ONTIME_VERSION = ' + JSON.stringify(require('../../package.json').version) + ';'\" > src/ONTIME_VERSION.js",
+11 -39
View File
@@ -6,7 +6,6 @@ import {
ProjectRundownsList, ProjectRundownsList,
RenumberCues, RenumberCues,
Rundown, Rundown,
RundownImportPayload,
TransientEventPayload, TransientEventPayload,
} from 'ontime-types'; } from 'ontime-types';
@@ -35,17 +34,19 @@ export async function fetchCurrentRundown(options?: RequestOptions): Promise<Run
throw new Error('Invalid rundown payload'); throw new Error('Invalid rundown payload');
} }
return res.data; return res.data;
}
/** function isValidRundown(x: any): x is Rundown {
* HTTP request to fetch all entries in the given rundown return (
*/ x &&
export async function fetchRundown(rundownId: RundownId, options?: RequestOptions): Promise<Rundown> { typeof x === 'object' &&
const res = await axios.get(`${rundownPath}/${rundownId}`, { signal: options?.signal }); typeof x.id === 'string' &&
if (!isValidRundown(res.data)) { Array.isArray(x.order) &&
throw new Error('Invalid rundown payload'); Array.isArray(x.flatOrder) &&
x.entries &&
typeof x.entries === 'object' &&
typeof x.revision === 'number'
);
} }
return res.data;
} }
/** /**
@@ -83,15 +84,6 @@ export async function deleteRundown(rundownId: RundownId): Promise<AxiosResponse
return axios.delete(`${rundownPath}/${rundownId}`); return axios.delete(`${rundownPath}/${rundownId}`);
} }
/**
* HTTP request to apply an imported rundown using a merge strategy or into a new rundown
*/
export async function importRundownWithOptions(
payload: RundownImportPayload,
): Promise<AxiosResponse<ProjectRundownsList>> {
return axios.post(`${rundownPath}/import`, payload);
}
// #endregion operations on project rundowns ====================== // #endregion operations on project rundowns ======================
// #region operations on rundown entries ========================== // #region operations on rundown entries ==========================
@@ -176,13 +168,6 @@ export async function postCloneEntry(
return axios.post(`${rundownPath}/${rundownId}/clone/${entryId}`, options); return axios.post(`${rundownPath}/${rundownId}/clone/${entryId}`, options);
} }
/**
* HTTP request events duration to fit inside the group target
*/
export async function requestFitGroupTarget(rundownId: RundownId, eventId: EntryId): Promise<AxiosResponse<Rundown>> {
return axios.post(`${rundownPath}/${rundownId}/${eventId}/fit-group-duration`);
}
/** /**
* HTTP request for grouping a list of entries into a group * HTTP request for grouping a list of entries into a group
*/ */
@@ -212,16 +197,3 @@ export async function requestDeleteAll(rundownId: RundownId): Promise<AxiosRespo
} }
// #endregion operations on rundown entries ======================= // #endregion operations on rundown entries =======================
function isValidRundown(x: any): x is Rundown {
return (
x &&
typeof x === 'object' &&
typeof x.id === 'string' &&
Array.isArray(x.order) &&
Array.isArray(x.flatOrder) &&
x.entries &&
typeof x.entries === 'object' &&
typeof x.revision === 'number'
);
}
@@ -1,82 +1,24 @@
.section {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 0.75rem;
background-color: $gray-1300;
border: 1px solid $gray-1100;
border-radius: $component-border-radius-md;
}
.sectionHeader {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.separator {
display: flex;
align-items: center;
gap: 0.75rem;
color: $gray-500;
font-size: $inner-section-text-size;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
&::before,
&::after {
content: '';
flex: 1;
border-top: 1px solid $gray-1100;
}
}
.textEntry { .textEntry {
grid-area: input;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
flex: 1;
min-width: 0;
color: $label-gray; color: $label-gray;
} }
.inlineEntry { .inlineEntry {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) auto; grid-template-areas:
align-items: center; 'label label'
gap: 0.75rem; 'input btn';
grid-template-columns: 1fr auto;
column-gap: 0.5rem;
} }
.redirect { .redirect {
margin-left: auto; grid-area: btn;
} }
.label { .label {
color: $gray-200; color: $label-gray;
font-size: $text-body-size;
font-weight: 600;
}
.description {
color: $gray-500;
font-size: $inner-section-text-size;
}
.origin {
flex: 0 0 auto;
color: $gray-500;
font-size: $inner-section-text-size;
white-space: nowrap;
}
@media (max-width: $min-tablet) {
.inlineEntry {
grid-template-columns: 1fr;
align-items: stretch;
}
.redirect {
align-self: flex-end;
}
} }
@@ -1,7 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { IoArrowForward } from 'react-icons/io5'; import { IoArrowForward } from 'react-icons/io5';
import { baseURI } from '../../../externals';
import { navigatorConstants } from '../../../viewerConfig'; import { navigatorConstants } from '../../../viewerConfig';
import useUrlPresets from '../../hooks-query/useUrlPresets'; import useUrlPresets from '../../hooks-query/useUrlPresets';
import { setClientRemote } from '../../hooks/useSocket'; import { setClientRemote } from '../../hooks/useSocket';
@@ -9,7 +8,7 @@ import Button from '../buttons/Button';
import Dialog from '../dialog/Dialog'; import Dialog from '../dialog/Dialog';
import Info from '../info/Info'; import Info from '../info/Info';
import Input from '../input/input/Input'; import Input from '../input/input/Input';
import ExternalLink from '../link/external-link/ExternalLink'; import AppLink from '../link/app-link/AppLink';
import Select from '../select/Select'; import Select from '../select/Select';
import style from './RedirectClientModal.module.scss'; import style from './RedirectClientModal.module.scss';
@@ -45,7 +44,6 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
}; };
const enabledPresets = data.filter((preset) => preset.enabled); const enabledPresets = data.filter((preset) => preset.enabled);
const settingsUrl = `${window.location.origin}${baseURI}/?settings=sharing__presets`;
const viewOptions = [ const viewOptions = [
...navigatorConstants.map((view) => ({ ...navigatorConstants.map((view) => ({
@@ -68,46 +66,34 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
bodyElements={ bodyElements={
<> <>
<Info> <Info>
<Info.Body> Remotely redirect the client to a different URL. <br />
Remotely redirect the client to a different URL. Choose a custom path or select a URL Preset. Either by entering a custom path or selecting a URL Preset.
</Info.Body> <br />
<Info.Footer> <br />
<ExternalLink href={settingsUrl}>Manage URL Presets</ExternalLink> <AppLink search='settings=sharing__presets'>Manage URL Presets</AppLink>
</Info.Footer>
</Info> </Info>
<section className={style.section}> <div className={style.inlineEntry}>
<div className={style.sectionHeader}> <span className={style.label}>Enter custom path</span>
<span className={style.label}>Enter custom path</span> <label className={style.textEntry}>
<span className={style.description}>Type the path the client should open.</span> {origin}
</div> <Input placeholder='eg. /timer' fluid value={path} onChange={(event) => setPath(event.target.value)} />
<div className={style.inlineEntry}> </label>
<label className={style.textEntry}> <Button
<span className={style.origin}>{origin}</span> variant='primary'
<Input placeholder='eg. /timer' fluid value={path} onChange={(event) => setPath(event.target.value)} /> aria-label='Redirect'
</label> disabled={path === currentPath || path === ''}
<Button className={style.redirect}
variant='primary' onClick={() => handleRedirect(path)}
aria-label='Redirect to custom path' >
disabled={path === currentPath || path === ''} Redirect
className={style.redirect} <IoArrowForward />
onClick={() => handleRedirect(path)} </Button>
>
Redirect
<IoArrowForward />
</Button>
</div>
</section>
<div className={style.separator} role='separator'>
<span>Or</span>
</div> </div>
<section className={style.section}> <div>
<div className={style.sectionHeader}> <span className={style.label}>Select View or URL Preset</span>
<span className={style.label}>Select view or URL Preset</span>
<span className={style.description}>Choose from the available destinations.</span>
</div>
<div className={style.inlineEntry}> <div className={style.inlineEntry}>
<label className={style.textEntry}> <label className={style.textEntry}>
<span className={style.origin}>{origin}</span> {origin}
<Select <Select
fluid fluid
options={viewOptions} options={viewOptions}
@@ -129,7 +115,7 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
Redirect <IoArrowForward /> Redirect <IoArrowForward />
</Button> </Button>
</div> </div>
</section> </div>
</> </>
} }
/> />
@@ -1,13 +0,0 @@
@use '../../../theme/ontimeStyles' as *;
.form {
padding: 0.25rem 0.5rem 0.5rem;
}
.label {
display: flex;
flex-direction: column;
gap: 0.5rem;
color: $label-gray;
font-size: $inner-section-text-size;
}
@@ -5,8 +5,6 @@ import { setClientRemote } from '../../hooks/useSocket';
import Dialog from '../dialog/Dialog'; import Dialog from '../dialog/Dialog';
import Input from '../input/input/Input'; import Input from '../input/input/Input';
import style from './RenameClientModal.module.scss';
interface RenameClientModalProps { interface RenameClientModalProps {
id: string; id: string;
name?: string; name?: string;
@@ -33,22 +31,9 @@ export function RenameClientModal({ id, name: currentName = '', isOpen, onClose
isOpen={isOpen} isOpen={isOpen}
title={`Rename Client: ${currentName}`} title={`Rename Client: ${currentName}`}
showCloseButton showCloseButton
showBackdrop
onClose={onClose} onClose={onClose}
bodyElements={ bodyElements={
<div className={style.form}> <Input height='large' placeholder='New name' value={name} onChange={(event) => setName(event.target.value)} />
<label className={style.label}>
Client name
<Input
height='large'
fluid
autoComplete='off'
placeholder='New name'
value={name}
onChange={(event) => setName(event.target.value)}
/>
</label>
</div>
} }
footerElements={ footerElements={
<> <>
@@ -1,16 +1,12 @@
.dialog { .dialog {
position: fixed; position: fixed;
top: 10vh; top: 10%;
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
box-sizing: border-box;
width: min(600px, 90vw);
max-height: calc(90vh - 1rem);
display: flex;
flex-direction: column;
padding-inline: 1rem; padding-inline: 1rem;
min-width: min(600px, 90vw);
background-color: $gray-1250; background-color: $gray-1250;
color: $ui-white; color: $ui-white;
@@ -47,8 +43,7 @@
flex-direction: column; flex-direction: column;
gap: 0.5rem; gap: 0.5rem;
flex: 1; max-height: min(80vh, 600px);
min-height: 0;
overflow-y: auto; overflow-y: auto;
} }
@@ -58,5 +53,4 @@
align-items: center; align-items: center;
justify-content: end; justify-content: end;
gap: 1rem; gap: 1rem;
flex-wrap: wrap;
} }
@@ -3,8 +3,8 @@ import { ChangeEvent, KeyboardEvent, RefObject, useCallback, useEffect, useMemo,
interface UseReactiveTextInputReturn { interface UseReactiveTextInputReturn {
value: string; value: string;
onChange: (event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => void; onChange: (event: ChangeEvent) => void;
onBlur: (event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => void; onBlur: (event: ChangeEvent) => void;
onKeyDown: (event: KeyboardEvent<HTMLElement>) => void; onKeyDown: (event: KeyboardEvent<HTMLElement>) => void;
} }
@@ -15,9 +15,7 @@ export default function useReactiveTextInput(
options?: { options?: {
submitOnEnter?: boolean; submitOnEnter?: boolean;
submitOnCtrlEnter?: boolean; submitOnCtrlEnter?: boolean;
submitOnTab?: boolean;
onCancelUpdate?: () => void; onCancelUpdate?: () => void;
onTabCancel?: () => void;
allowSubmitSameValue?: boolean; allowSubmitSameValue?: boolean;
allowKeyboardNavigation?: boolean; allowKeyboardNavigation?: boolean;
}, },
@@ -25,10 +23,6 @@ export default function useReactiveTextInput(
const [text, setText] = useState<string>(initialText); const [text, setText] = useState<string>(initialText);
// track whether we are submitting via a submit key (eg enter) and avoid submitting again on blur // track whether we are submitting via a submit key (eg enter) and avoid submitting again on blur
const isKeyboardSubmitting = useRef(false); const isKeyboardSubmitting = useRef(false);
// track escape to prevent the subsequent blur from submitting
const isEscaping = useRef(false);
// track tab-out to prevent blur from submitting (used by timer cells)
const isTabbing = useRef(false);
useEffect(() => { useEffect(() => {
if (typeof initialText === 'undefined') { if (typeof initialText === 'undefined') {
@@ -84,7 +78,6 @@ export default function useReactiveTextInput(
* @param {string} valueToSubmit * @param {string} valueToSubmit
*/ */
const handleEscape = useCallback(() => { const handleEscape = useCallback(() => {
isEscaping.current = true;
// No need to update if it hasn't changed // No need to update if it hasn't changed
setText(initialText); setText(initialText);
// force the text to be the initial value // force the text to be the initial value
@@ -148,30 +141,16 @@ export default function useReactiveTextInput(
event.stopPropagation(); event.stopPropagation();
} }
// for cells that opt out of tab-submit, track tab-out without preventing navigation
if (event.key === 'Tab' && options?.submitOnTab === false) {
isTabbing.current = true;
}
hotKeyHandler(event); hotKeyHandler(event);
}; };
}, [handleEscape, handleSubmit, options?.submitOnCtrlEnter, options?.submitOnEnter, options?.submitOnTab, text]); }, [handleEscape, handleSubmit, options?.submitOnCtrlEnter, options?.submitOnEnter, text]);
return { return {
value: text, value: text,
onChange: (event) => handleChange(event.target.value), onChange: (event: ChangeEvent) => handleChange((event.target as HTMLInputElement).value),
onBlur: (event) => { onBlur: (event: ChangeEvent) => {
if (isTabbing.current) {
isTabbing.current = false;
(options?.onTabCancel ?? options?.onCancelUpdate)?.();
return;
}
if (isEscaping.current) {
isEscaping.current = false;
return;
}
if (!isKeyboardSubmitting.current) { if (!isKeyboardSubmitting.current) {
handleSubmit(event.target.value); handleSubmit((event.target as HTMLInputElement).value);
} }
}, },
onKeyDown: keyHandler, onKeyDown: keyHandler,
@@ -1,14 +0,0 @@
.kbd {
display: inline-block;
min-width: 1.5rem;
padding: 0.0625rem 0.375rem;
border-radius: 2px;
background-color: $gray-1100;
color: $ui-white;
box-shadow: 0 0 3px rgba(0, 0, 0, 0.4);
font-family: monospace;
font-size: calc(1rem - 4px);
line-height: 1.5;
text-align: center;
white-space: nowrap;
}
@@ -1,7 +0,0 @@
import { PropsWithChildren } from 'react';
import style from './Kbd.module.scss';
export default function Kbd({ children }: PropsWithChildren) {
return <kbd className={style.kbd}>{children}</kbd>;
}
@@ -21,15 +21,4 @@
outline: none; outline: none;
box-shadow: 0 1px 0 0 currentColor; box-shadow: 0 1px 0 0 currentColor;
} }
&:hover .icon,
&:focus-visible .icon {
transform: translateX(0.25rem);
}
}
.icon {
flex: 0 0 1.5em;
font-size: 1em;
transition: transform $transition-time-action;
} }
@@ -1,5 +1,5 @@
import { MouseEvent, ReactNode } from 'react'; import { MouseEvent, ReactNode } from 'react';
import { HiArrowLongRight } from 'react-icons/hi2'; import { IoOpenOutline } from 'react-icons/io5';
import { openLink } from '../../../utils/linkUtils'; import { openLink } from '../../../utils/linkUtils';
import { cx } from '../../../utils/styleUtils'; import { cx } from '../../../utils/styleUtils';
@@ -10,10 +10,9 @@ interface ExternalLinkProps {
href: string; href: string;
children: ReactNode; children: ReactNode;
inline?: boolean; inline?: boolean;
className?: string;
} }
export default function ExternalLink({ href, inline, className, children }: ExternalLinkProps) { export default function ExternalLink({ href, inline, children }: ExternalLinkProps) {
const handleClick = (event: MouseEvent) => { const handleClick = (event: MouseEvent) => {
event.preventDefault(); event.preventDefault();
openLink(href); openLink(href);
@@ -24,11 +23,10 @@ export default function ExternalLink({ href, inline, className, children }: Exte
href='#!' href='#!'
target='_blank' target='_blank'
rel='noreferrer' rel='noreferrer'
className={cx([style.link, inline && style.inline, className])} className={cx([style.link, inline && style.inline])}
onClick={handleClick} onClick={handleClick}
> >
{children} {children} <IoOpenOutline style={{ fontSize: '1em' }} />
<HiArrowLongRight className={style.icon} />
</a> </a>
); );
} }
@@ -4,13 +4,10 @@
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
box-sizing: border-box; padding-inline: 1rem;
width: min(880px, 90vw); min-width: min(880px, 90vw);
min-height: min(200px, 10vh); min-height: min(200px, 10vh);
max-height: calc(90vh - 1rem); max-width: min(1200px, 90vw);
display: flex;
flex-direction: column;
background-color: $gray-1250; background-color: $gray-1250;
color: $ui-white; color: $ui-white;
@@ -20,21 +17,14 @@
outline: none; outline: none;
} }
.compact {
min-width: 0;
width: min(680px, 90vw);
}
.small {
min-width: 0;
width: min(480px, 90vw);
}
.wide { .wide {
top: 4vh; top: 4vh;
width: min(1800px, 98vw); min-width: min(1280px, 96vw);
height: min(88vh, calc(96vh - 1rem)); max-width: min(1600px, 96vw);
min-height: 0; height: 88vh;
max-height: 88vh;
display: flex;
flex-direction: column;
} }
.backdrop { .backdrop {
@@ -51,19 +41,15 @@
.title { .title {
padding-block: 1rem; padding-block: 1rem;
padding-inline: 1rem;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 1rem;
} }
.titleText { .titleText {
min-width: 0;
font-size: 1rem; font-size: 1rem;
font-weight: 600; font-weight: 600;
overflow-wrap: anywhere;
} }
.body { .body {
@@ -71,23 +57,21 @@
flex-direction: column; flex-direction: column;
gap: 0.5rem; gap: 0.5rem;
flex: 1; max-height: 60vh;
min-height: 0;
padding: 0.5rem 1rem;
overflow-y: auto; overflow-y: auto;
scroll-padding-block: 0.5rem;
} }
.wide .body { .wide .body {
flex: 1;
min-height: 0;
max-height: none;
overflow: hidden; overflow: hidden;
} }
.footer { .footer {
padding-block: 1rem; padding-block: 1rem;
padding-inline: 1rem;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: end; justify-content: end;
gap: 1rem; gap: 1rem;
flex-wrap: wrap;
} }
@@ -12,7 +12,7 @@ interface ModalProps {
title?: string; title?: string;
showCloseButton?: boolean; showCloseButton?: boolean;
showBackdrop?: boolean; showBackdrop?: boolean;
size?: 'small' | 'compact' | 'default' | 'wide'; size?: 'default' | 'wide';
bodyElements: ReactNode; bodyElements: ReactNode;
footerElements?: ReactNode; footerElements?: ReactNode;
onClose: () => void; onClose: () => void;
@@ -38,7 +38,7 @@ export default function Modal({
> >
<BaseDialog.Portal> <BaseDialog.Portal>
{showBackdrop && <BaseDialog.Backdrop className={style.backdrop} />} {showBackdrop && <BaseDialog.Backdrop className={style.backdrop} />}
<BaseDialog.Popup aria-label={title} className={cx([style.modal, style[size]])}> <BaseDialog.Popup aria-label={title} className={cx([style.modal, size === 'wide' && style.wide])}>
<div className={style.title}> <div className={style.title}>
{title ? <BaseDialog.Title className={style.titleText}>{title}</BaseDialog.Title> : <div />} {title ? <BaseDialog.Title className={style.titleText}>{title}</BaseDialog.Title> : <div />}
{showCloseButton && ( {showCloseButton && (
@@ -38,6 +38,8 @@ $progress-bar-br: 3px;
.multiprogress-bar__indicator-bar { .multiprogress-bar__indicator-bar {
background-color: var(--background-color-override, $ui-black); background-color: var(--background-color-override, $ui-black);
opacity: 0.8; opacity: 0.8;
transition: 1s linear;
transition-property: width;
.multiprogress-bar--ignore-css-override & { .multiprogress-bar--ignore-css-override & {
background-color: $ui-black; background-color: $ui-black;
@@ -1,6 +1,5 @@
import { MaybeNumber } from 'ontime-types'; import { MaybeNumber } from 'ontime-types';
import { useAnimatedProgress } from '../../hooks/useAnimatedProgress';
import { getProgress } from '../../utils/getProgress'; import { getProgress } from '../../utils/getProgress';
import { cx } from '../../utils/styleUtils'; import { cx } from '../../utils/styleUtils';
@@ -9,7 +8,6 @@ import './MultiPartProgressBar.scss';
interface MultiPartProgressBar { interface MultiPartProgressBar {
now: MaybeNumber; now: MaybeNumber;
complete: MaybeNumber; complete: MaybeNumber;
eventId?: string | null;
normalColor: string; normalColor: string;
warning?: MaybeNumber; warning?: MaybeNumber;
warningColor: string; warningColor: string;
@@ -25,7 +23,6 @@ export default function MultiPartProgressBar(props: MultiPartProgressBar) {
const { const {
now, now,
complete, complete,
eventId,
normalColor, normalColor,
warning, warning,
warningColor, warningColor,
@@ -37,7 +34,7 @@ export default function MultiPartProgressBar(props: MultiPartProgressBar) {
className = '', className = '',
} = props; } = props;
const percentRemaining = 100 - useAnimatedProgress(now, complete, eventId); const percentRemaining = 100 - getProgress(now, complete);
const dangerWidth = danger ? 100 - getProgress(danger, complete) : 0; const dangerWidth = danger ? 100 - getProgress(danger, complete) : 0;
const warningWidth = warning ? 100 - dangerWidth - getProgress(warning, complete) : 0; const warningWidth = warning ? 100 - dangerWidth - getProgress(warning, complete) : 0;
const isOvertime = now !== null && now < 0; const isOvertime = now !== null && now < 0;
@@ -8,7 +8,6 @@ import { useLocation } from 'react-router';
import { isLocalhost, supportsFullscreen } from '../../../externals'; import { isLocalhost, supportsFullscreen } from '../../../externals';
import { canUseWakeLock, useKeepAwakeOptions } from '../../../features/keep-awake/useWakeLock'; import { canUseWakeLock, useKeepAwakeOptions } from '../../../features/keep-awake/useWakeLock';
import { navigatorConstants } from '../../../viewerConfig'; import { navigatorConstants } from '../../../viewerConfig';
import useUrlPresets from '../../hooks-query/useUrlPresets';
import { useIsSmallScreen } from '../../hooks/useIsSmallScreen'; import { useIsSmallScreen } from '../../hooks/useIsSmallScreen';
import { useClientStore } from '../../stores/clientStore'; import { useClientStore } from '../../stores/clientStore';
import { useViewOptionsStore } from '../../stores/viewOptions'; import { useViewOptionsStore } from '../../stores/viewOptions';
@@ -106,8 +105,6 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
{route.label} {route.label}
</ClientLink> </ClientLink>
))} ))}
<PresetNavigation isSmallScreen={isSmallScreen} onClose={onClose} />
</div> </div>
{isLocalhost && ( {isLocalhost && (
@@ -120,27 +117,3 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
</Dialog.Root> </Dialog.Root>
); );
} }
function PresetNavigation({ isSmallScreen, onClose }: { isSmallScreen: boolean; onClose: () => void }) {
const location = useLocation();
const { data: urlPresets } = useUrlPresets();
const navPresets = urlPresets.filter((preset) => preset.enabled && preset.displayInNav);
if (navPresets.length === 0) return null;
return (
<>
<hr className={style.separator} />
{navPresets.map((preset) => (
<ClientLink
key={preset.alias}
to={`preset/${preset.alias}`}
current={location.pathname === `/preset/${preset.alias}`}
postAction={isSmallScreen ? onClose : undefined}
>
{preset.alias}
</ClientLink>
))}
</>
);
}
@@ -1,8 +1,6 @@
import { useDisclosure, useHotkeys } from '@mantine/hooks'; import { useDisclosure, useHotkeys } from '@mantine/hooks';
import { memo } from 'react'; import { memo } from 'react';
import { useSearchParams } from 'react-router';
import { hasCustomParams } from '../../stores/savedViewParams';
import { useViewParamsEditorStore } from '../view-params-editor/viewParamsEditor.store'; import { useViewParamsEditorStore } from '../view-params-editor/viewParamsEditor.store';
import FloatingNavigation from './floating-navigation/FloatingNavigation'; import FloatingNavigation from './floating-navigation/FloatingNavigation';
import NavigationMenu from './NavigationMenu'; import NavigationMenu from './NavigationMenu';
@@ -19,8 +17,6 @@ export default memo(ViewNavigationMenu);
function ViewNavigationMenu({ isNavigationLocked, suppressSettings }: ViewNavigationMenuProps) { function ViewNavigationMenu({ isNavigationLocked, suppressSettings }: ViewNavigationMenuProps) {
const [isMenuOpen, menuHandler] = useDisclosure(); const [isMenuOpen, menuHandler] = useDisclosure();
const { open: showEditFormDrawer } = useViewParamsEditorStore(); const { open: showEditFormDrawer } = useViewParamsEditorStore();
const [searchParams] = useSearchParams();
const hasSavedChanges = hasCustomParams(searchParams);
useHotkeys([ useHotkeys([
[ [
@@ -50,7 +46,6 @@ function ViewNavigationMenu({ isNavigationLocked, suppressSettings }: ViewNaviga
<FloatingNavigation <FloatingNavigation
toggleMenu={isNavigationLocked ? undefined : menuHandler.toggle} toggleMenu={isNavigationLocked ? undefined : menuHandler.toggle}
toggleSettings={suppressSettings ? undefined : showEditFormDrawer} toggleSettings={suppressSettings ? undefined : showEditFormDrawer}
hasSavedChanges={hasSavedChanges}
/> />
{!isNavigationLocked && <NavigationMenu isOpen={isMenuOpen} onClose={menuHandler.close} />} {!isNavigationLocked && <NavigationMenu isOpen={isMenuOpen} onClose={menuHandler.close} />}
</> </>
@@ -1,29 +1,4 @@
.trailing {
display: inline-flex;
align-items: center;
gap: 0.5rem;
margin-left: auto;
}
.linkIcon { .linkIcon {
margin-left: auto;
@include rotate-fourty-five; @include rotate-fourty-five;
} }
.indicator {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
background-color: $active-indicator;
pointer-events: none;
flex-shrink: 0;
}
.clear {
opacity: 0.6;
transition: opacity $transition-time-action;
&:hover,
&:focus-visible {
opacity: 1;
}
}
@@ -1,17 +1,9 @@
import { MouseEvent, PropsWithChildren } from 'react'; import { PropsWithChildren } from 'react';
import { IoArrowUp, IoCloseOutline } from 'react-icons/io5'; import { IoArrowUp } from 'react-icons/io5';
import { useLocation, useNavigate, useSearchParams } from 'react-router'; import { useNavigate } from 'react-router';
import { useElectronEvent } from '../../../hooks/useElectronEvent'; import { useElectronEvent } from '../../../hooks/useElectronEvent';
import {
hasCustomParams,
reservedParams,
stripReservedParams,
useSavedViewParams,
} from '../../../stores/savedViewParams';
import { handleLinks } from '../../../utils/linkUtils'; import { handleLinks } from '../../../utils/linkUtils';
import IconButton from '../../buttons/IconButton';
import Tooltip from '../../tooltip/Tooltip';
import NavigationMenuItem from '../navigation-menu-item/NavigationMenuItem'; import NavigationMenuItem from '../navigation-menu-item/NavigationMenuItem';
import style from './ClientLink.module.scss'; import style from './ClientLink.module.scss';
@@ -24,104 +16,32 @@ interface ClientLinkProps {
export default function ClientLink({ current, to, postAction, children }: PropsWithChildren<ClientLinkProps>) { export default function ClientLink({ current, to, postAction, children }: PropsWithChildren<ClientLinkProps>) {
const { isElectron } = useElectronEvent(); const { isElectron } = useElectronEvent();
const navigate = useNavigate();
if (isElectron) { if (isElectron) {
return ( return (
<ElectronNavigationItem current={current} to={to} postAction={postAction}> <NavigationMenuItem
active={current}
onClick={() => {
handleLinks(to);
postAction?.();
}}
>
{children} {children}
</ElectronNavigationItem> <IoArrowUp className={style.linkIcon} />
</NavigationMenuItem>
); );
} }
return ( return (
<BrowserNavigationItem current={current} to={to} postAction={postAction}> <NavigationMenuItem
active={current}
onClick={() => {
navigate(`/${to}`);
postAction?.();
}}
>
{children} {children}
</BrowserNavigationItem>
);
}
function ElectronNavigationItem({ current, to, postAction, children }: PropsWithChildren<ClientLinkProps>) {
const navigateToLink = () => {
handleLinks(to);
postAction?.();
};
return (
<NavigationMenuItem active={current} onClick={navigateToLink}>
{children}
<span className={style.trailing}>
<IoArrowUp className={style.linkIcon} />
</span>
</NavigationMenuItem>
);
}
function BrowserNavigationItem({ current, to, postAction, children }: PropsWithChildren<ClientLinkProps>) {
const navigate = useNavigate();
const location = useLocation();
const [searchParams, setSearchParams] = useSearchParams();
const { params: savedParams, save, clear } = useSavedViewParams();
/**
* Save the params of the view we are leaving and resolve the destination,
* restoring any params previously saved for the target view.
*/
const resolveDestination = () => {
const currentView = location.pathname.replace(/^\//, '');
save(currentView, stripReservedParams(location.search));
const restored = savedParams[to];
return `${to}${restored ? `?${restored}` : ''}`;
};
const isCustomised = current ? hasCustomParams(searchParams) : Boolean(savedParams[to]);
const navigateToLink = () => {
const destination = resolveDestination();
navigate(`/${destination}`);
postAction?.();
};
/**
* Clear the saved settings for this route without navigating to it.
* When it is the current view, also reset the live URL to its defaults.
*/
const clearViewSettings = (event: MouseEvent) => {
event.stopPropagation();
clear(to);
// if mounted, clear the URL params
if (current) {
const preserved = new URLSearchParams();
reservedParams.forEach((key) => {
const value = searchParams.get(key);
if (value !== null) preserved.set(key, value);
});
setSearchParams(preserved);
}
};
return (
<NavigationMenuItem active={current} onClick={navigateToLink}>
{children}
{isCustomised && (
<span className={style.trailing}>
<span className={style.indicator} aria-hidden data-testid='client-link__saved-indicator' />
<Tooltip
text='Reset to default'
render={
<IconButton
variant='ghosted-white'
size='small'
className={style.clear}
aria-label='Reset to default'
onClick={clearViewSettings}
/>
}
>
<IoCloseOutline />
</Tooltip>
</span>
)}
</NavigationMenuItem> </NavigationMenuItem>
); );
} }
@@ -22,19 +22,3 @@
top: 0; top: 0;
z-index: $zindex-nav; z-index: $zindex-nav;
} }
.buttonWithIndicator {
position: relative;
display: inline-flex;
}
.indicator {
position: absolute;
top: -0.15rem;
right: -0.15rem;
width: 0.6rem;
height: 0.6rem;
border-radius: 50%;
background-color: $active-indicator;
pointer-events: none;
}
@@ -9,10 +9,9 @@ import style from './FloatingNavigation.module.scss';
interface FloatingNavigationProps { interface FloatingNavigationProps {
toggleMenu?: () => void; toggleMenu?: () => void;
toggleSettings?: () => void; toggleSettings?: () => void;
hasSavedChanges?: boolean;
} }
export default function FloatingNavigation({ toggleMenu, toggleSettings, hasSavedChanges }: FloatingNavigationProps) { export default function FloatingNavigation({ toggleMenu, toggleSettings }: FloatingNavigationProps) {
const isButtonShown = useFadeOutOnInactivity(true); const isButtonShown = useFadeOutOnInactivity(true);
return ( return (
@@ -21,18 +20,15 @@ export default function FloatingNavigation({ toggleMenu, toggleSettings, hasSave
className={cx([style.fadeable, style.buttonContainer, !isButtonShown && style.hidden])} className={cx([style.fadeable, style.buttonContainer, !isButtonShown && style.hidden])}
> >
{toggleMenu && ( {toggleMenu && (
<div className={style.buttonWithIndicator}> <IconButton
<IconButton variant='subtle-white'
variant='subtle-white' size='xlarge'
size='xlarge' onClick={toggleMenu}
onClick={toggleMenu} aria-label='toggle menu'
aria-label='toggle menu' data-testid='navigation__toggle-menu'
data-testid='navigation__toggle-menu' >
> <IoApps />
<IoApps /> </IconButton>
</IconButton>
{hasSavedChanges && <span className={style.indicator} data-testid='navigation__saved-indicator' />}
</div>
)} )}
{toggleSettings && ( {toggleSettings && (
<IconButton <IconButton
@@ -14,4 +14,6 @@ $progress-bar-br: 3px;
.progress-bar__indicator { .progress-bar__indicator {
height: $progress-bar-size; height: $progress-bar-size;
background-color: var(--timer-progress-override, $accent-color); background-color: var(--timer-progress-override, $accent-color);
transition: 1s linear;
transition-property: width;
} }
@@ -1,18 +1,18 @@
import { MaybeNumber } from 'ontime-types'; import { MaybeNumber } from 'ontime-types';
import { useAnimatedProgress } from '../../hooks/useAnimatedProgress'; import { getProgress } from '../../utils/getProgress';
import './ProgressBar.scss'; import './ProgressBar.scss';
interface ProgressBarProps { interface ProgressBarProps {
current: MaybeNumber; current: MaybeNumber;
duration: MaybeNumber; duration: MaybeNumber;
eventId?: string | null;
className?: string; className?: string;
} }
export default function ProgressBar({ current, duration, eventId, className }: ProgressBarProps) { export default function ProgressBar(props: ProgressBarProps) {
const progress = useAnimatedProgress(current, duration, eventId); const { current, duration, className } = props;
const progress = getProgress(current, duration);
return ( return (
<div className={`progress-bar__bg ${className}`}> <div className={`progress-bar__bg ${className}`}>
@@ -1,43 +1,18 @@
@use '@/theme/viewerDefs' as *;
.emptyContainer { .emptyContainer {
width: 100%; width: 100%;
text-align: center; text-align: center;
color: var(--secondary-color-override, $viewer-secondary-color); color: $white-10;
.empty { .empty {
display: block; width: 100%;
width: min(100%, 14rem); opacity: 0.8;
margin: 0 auto -1.5rem;
opacity: 0.6;
} }
.text { .text {
display: block; display: block;
margin-inline: auto; margin-inline: auto;
font-weight: 400; font-weight: 600;
font-size: clamp(1rem, 1.55vw, 1.5rem); font-size: 2em;
line-height: 1.35; max-width: 600px;
max-width: min(100%, 40rem);
}
&.error {
color: $error-red;
.empty {
opacity: 0.35;
filter: grayscale(1);
}
.text {
margin-top: 0.5rem;
}
}
.errorIcon {
display: block;
width: 1.5rem;
height: 1.5rem;
margin: -0.125rem auto 0;
} }
} }
@@ -1,5 +1,4 @@
import { CSSProperties } from 'react'; import { CSSProperties } from 'react';
import { IoWarningOutline } from 'react-icons/io5';
import EmptyImage from '../../../assets/images/empty.svg?react'; import EmptyImage from '../../../assets/images/empty.svg?react';
import { cx } from '../../utils/styleUtils'; import { cx } from '../../utils/styleUtils';
@@ -10,18 +9,12 @@ interface EmptyProps {
text?: string; text?: string;
injectedStyles?: CSSProperties; injectedStyles?: CSSProperties;
className?: string; className?: string;
variant?: 'error';
} }
export default function Empty({ text, className, injectedStyles, variant }: EmptyProps) { export default function Empty({ text, className, injectedStyles }: EmptyProps) {
return ( return (
<div <div className={cx([style.emptyContainer, className])} style={injectedStyles}>
className={cx([style.emptyContainer, variant === 'error' && style.error, className])}
style={injectedStyles}
role={variant === 'error' ? 'alert' : undefined}
>
<EmptyImage className={style.empty} /> <EmptyImage className={style.empty} />
{variant === 'error' && <IoWarningOutline className={style.errorIcon} aria-hidden />}
{text && <span className={style.text}>{text}</span>} {text && <span className={style.text}>{text}</span>}
</div> </div>
); );
@@ -1,11 +0,0 @@
.fill {
box-sizing: border-box;
width: 100%;
height: 100%;
min-height: 0;
flex: 1;
display: grid;
place-items: start center;
padding: clamp(4rem, 20dvh, 12rem) 1.5rem 1rem;
}
@@ -1,19 +0,0 @@
import { cx } from '../../utils/styleUtils';
import Empty from './Empty';
import style from './EmptyFill.module.scss';
interface EmptyFillProps {
text?: string;
/** placed on the fill wrapper — e.g. to assign a grid-area in a grid parent */
className?: string;
}
/** Container-filling empty/loading state for panels and grid/flex cells. */
export default function EmptyFill({ text, className }: EmptyFillProps) {
return (
<div className={cx([style.fill, className])}>
<Empty text={text} />
</div>
);
}
@@ -6,7 +6,7 @@
box-sizing: border-box; /* reset */ box-sizing: border-box; /* reset */
overflow: hidden; overflow: hidden;
width: 100%; /* restrict the page width to viewport */ width: 100%; /* restrict the page width to viewport */
height: 100dvh; height: 100vh;
font-family: var(--font-family-override, $viewer-font-family); font-family: var(--font-family-override, $viewer-font-family);
background: var(--background-color-override, $viewer-background-color); background: var(--background-color-override, $viewer-background-color);
@@ -16,6 +16,5 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; padding-top: 5rem;
padding-block: 5rem;
} }
@@ -7,13 +7,12 @@ import style from './EmptyPage.module.scss';
interface EmptyPageProps { interface EmptyPageProps {
text?: string; text?: string;
injectedStyles?: CSSProperties; injectedStyles?: CSSProperties;
variant?: 'error';
} }
export default function EmptyPage({ text, injectedStyles, variant }: EmptyPageProps) { export default function EmptyPage({ text, injectedStyles }: EmptyPageProps) {
return ( return (
<div className={style.page}> <div className={style.page}>
<Empty text={text} injectedStyles={injectedStyles} variant={variant} /> <Empty text={text} injectedStyles={injectedStyles} />
</div> </div>
); );
} }
@@ -8,11 +8,13 @@
padding-top: 10vh; padding-top: 10vh;
} }
.inline { .empty {
display: flex; width: 100%;
align-items: center; opacity: 0.8;
justify-content: center; }
gap: 1rem;
margin-top: 1em; .text {
font-weight: 600;
font-size: 2em;
} }
} }
@@ -1,32 +1,18 @@
import { SupportedEntry } from 'ontime-types'; import EmptyImage from '../../../assets/images/empty.svg?react';
import { IoAdd } from 'react-icons/io5';
import { useTranslation } from '../../../translation/TranslationProvider';
import Button from '../buttons/Button';
import Empty from './Empty';
import style from './EmptyTableBody.module.scss'; import style from './EmptyTableBody.module.scss';
interface EmptyTableBodyProps { interface EmptyTableBodyProps {
handleAddNew?: (type: SupportedEntry) => void; text: string;
} }
export default function EmptyTableBody({ handleAddNew }: EmptyTableBodyProps) { export default function EmptyTableBody({ text }: EmptyTableBodyProps) {
const { getLocalizedString } = useTranslation();
const text = getLocalizedString('common.no_data');
return ( return (
<tbody className={style.emptyContainer}> <tbody className={style.emptyContainer}>
<tr> <tr>
<td colSpan={99} className={style.emptyCell}> <td colSpan={99} className={style.emptyCell}>
<Empty text={text} injectedStyles={{ marginTop: '5vh' }} /> <EmptyImage className={style.empty} />
{handleAddNew && ( {text && <span className={style.text}>{text}</span>}
<div className={style.inline}>
<Button onClick={() => handleAddNew(SupportedEntry.Event)} variant='primary' size='large'>
<IoAdd />
Create Event
</Button>
</div>
)}
</td> </td>
</tr> </tr>
</tbody> </tbody>
@@ -11,11 +11,6 @@
color: $ui-white; color: $ui-white;
} }
.active {
border: 1px solid $green-700;
color: $green-400;
}
.warning { .warning {
background-color: $orange-1300; background-color: $orange-1300;
color: $orange-300; color: $orange-300;
@@ -6,7 +6,7 @@ import style from './Tag.module.scss';
interface TagProps { interface TagProps {
className?: string; className?: string;
variant?: 'default' | 'active' | 'warning'; variant?: 'default' | 'warning';
} }
export default function Tag({ className, variant = 'default', children }: PropsWithChildren<TagProps>) { export default function Tag({ className, variant = 'default', children }: PropsWithChildren<TagProps>) {
@@ -6,7 +6,6 @@ import { useSearchParams } from 'react-router';
import useViewSettings from '../../hooks-query/useViewSettings'; import useViewSettings from '../../hooks-query/useViewSettings';
import { useIsSmallScreen } from '../../hooks/useIsSmallScreen'; import { useIsSmallScreen } from '../../hooks/useIsSmallScreen';
import { useSavedViewParams } from '../../stores/savedViewParams';
import Button from '../buttons/Button'; import Button from '../buttons/Button';
import IconButton from '../buttons/IconButton'; import IconButton from '../buttons/IconButton';
import Info from '../info/Info'; import Info from '../info/Info';
@@ -28,7 +27,6 @@ function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const { data: viewSettings } = useViewSettings(); const { data: viewSettings } = useViewSettings();
const { isOpen, close } = useViewParamsEditorStore(); const { isOpen, close } = useViewParamsEditorStore();
const clearSavedParams = useSavedViewParams((store) => store.clear);
const isSmallScreen = useIsSmallScreen(); const isSmallScreen = useIsSmallScreen();
const getPreservedParams = () => getPreservedSearchParams(searchParams, viewOptions); const getPreservedParams = () => getPreservedSearchParams(searchParams, viewOptions);
@@ -38,7 +36,6 @@ function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
}; };
const resetParams = () => { const resetParams = () => {
clearSavedParams(target);
setSearchParams(getPreservedParams()); setSearchParams(getPreservedParams());
}; };
@@ -12,7 +12,6 @@ import {
renameRundown, renameRundown,
} from '../api/rundown'; } from '../api/rundown';
//TODO: make suspends so we don't have to deal with no value all over
/** /**
* Project rundowns * Project rundowns
*/ */
@@ -4,7 +4,7 @@ import { useEffect, useMemo } from 'react';
import { queryRefetchIntervalSlow } from '../../ontimeConfig'; import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { CURRENT_RUNDOWN_QUERY_KEY, getRundownQueryKey } from '../api/constants'; import { CURRENT_RUNDOWN_QUERY_KEY, getRundownQueryKey } from '../api/constants';
import { fetchCurrentRundown, fetchRundown } from '../api/rundown'; import { fetchCurrentRundown } from '../api/rundown';
import { useSelectedEventId } from '../hooks/useSocket'; import { useSelectedEventId } from '../hooks/useSocket';
import { ExtendedEntry, getFlatRundownMetadata, getRundownMetadata } from '../utils/rundownMetadata'; import { ExtendedEntry, getFlatRundownMetadata, getRundownMetadata } from '../utils/rundownMetadata';
import { useProjectRundowns } from './useProjectRundowns'; import { useProjectRundowns } from './useProjectRundowns';
@@ -20,11 +20,7 @@ const cachedRundownPlaceholder: Rundown = {
}; };
/** /**
* Normalised rundown data for the currently loaded rundown. * Normalised rundown data
*
* Bootstraps via the `/current` alias so the first paint is a single round-trip,
* independent of the project rundown list. Once the loaded id is known, the
* query key swaps to the id-keyed cache that is shared with `useRundownById`.
*/ */
export default function useRundown() { export default function useRundown() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -38,7 +34,7 @@ export default function useRundown() {
refetchInterval: queryRefetchIntervalSlow, refetchInterval: queryRefetchIntervalSlow,
}); });
// Seed the id-keyed cache when fetching via the bootstrap alias // Seed the ID-based cache when fetching via the 'current' alias (bootstrap)
useEffect(() => { useEffect(() => {
if (!data || loadedRundownId) return; if (!data || loadedRundownId) return;
queryClient.setQueryData(getRundownQueryKey(data.id), data); queryClient.setQueryData(getRundownQueryKey(data.id), data);
@@ -119,21 +115,3 @@ export function useRundownAuxData() {
}, [data]); }, [data]);
return { data: filteredData, status }; return { data: filteredData, status };
} }
/**
* Provides access to a specific rundown by ID.
* When rundownId is null/undefined the query is disabled and returns the placeholder.
*/
export function useRundownById(rundownId: string | null | undefined) {
const enabled = Boolean(rundownId);
const { data, status, isError, refetch, isFetching } = useQuery<Rundown>({
queryKey: getRundownQueryKey(rundownId ?? ''),
queryFn: ({ signal }) => fetchRundown(rundownId!, { signal }),
enabled,
placeholderData: (previousData, _previousQuery) => previousData,
refetchInterval: queryRefetchIntervalSlow,
});
return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching };
}
@@ -1,54 +0,0 @@
import { EntryId, Rundown } from 'ontime-types';
import { useMemo } from 'react';
import { useSelectedEventId } from '../hooks/useSocket';
import { getFlatRundownMetadata, type ExtendedEntry } from '../utils/rundownMetadata';
import { useProjectRundowns } from './useProjectRundowns';
import { useRundownById } from './useRundown';
export type RundownSource = {
rundownId: string | null;
rundown: Rundown;
flatRundown: ExtendedEntry[];
status: string;
selectedEventId: EntryId | null;
};
/**
* Explicitly scoped rundown data for views that may operate on a non-loaded rundown.
*/
export function useScopedRundown(rundownId: string | null): RundownSource {
const { data: projectRundowns } = useProjectRundowns();
return useRundownSource(rundownId, projectRundowns.loaded || null);
}
/**
* Loaded-rundown source for views that must follow the active runtime rundown.
*/
export function useLoadedRundownSource(): RundownSource {
const { data: projectRundowns } = useProjectRundowns();
const loadedRundownId = projectRundowns.loaded || null;
return useRundownSource(loadedRundownId, loadedRundownId);
}
function useRundownSource(rundownId: string | null, loadedRundownId: string | null): RundownSource {
const isLoadedTarget = rundownId !== null && rundownId === loadedRundownId;
const runtimeSelectedEventId = useSelectedEventId();
const effectiveSelectedEventId = isLoadedTarget ? runtimeSelectedEventId : null;
const { data: rundown, status } = useRundownById(rundownId);
const flatRundown = useMemo(
() => getFlatRundownMetadata(rundown, effectiveSelectedEventId),
[effectiveSelectedEventId, rundown],
);
return useMemo(
() => ({
rundownId,
rundown,
flatRundown,
status,
selectedEventId: effectiveSelectedEventId,
}),
[effectiveSelectedEventId, flatRundown, rundown, rundownId, status],
);
}
@@ -1,49 +0,0 @@
import { EntryId, MaybeNumber, Playback } from 'ontime-types';
import { useEffect, useRef, useState } from 'react';
import { getProgress } from '../utils/getProgress';
import { useIsOnline, usePlayback } from './useSocket';
/**
* Returns the live completion percentage (0100) of a countdown, interpolated locally.
*/
export function useAnimatedProgress(current: MaybeNumber, duration: MaybeNumber, eventId?: EntryId | null): number {
const playback = usePlayback();
const isOnline = useIsOnline();
const isRunning = playback === Playback.Play || playback === Playback.Roll;
const baseline = useRef({ current, duration, eventId, playback, at: performance.now() });
const [, setTick] = useState(0);
const now = performance.now();
const hasAuthoritativeUpdate =
baseline.current.current !== current || // handle timer updates
baseline.current.duration !== duration || // handle duration changes
baseline.current.eventId !== eventId || // handle event changing
baseline.current.playback !== playback; // handle playback changes
if (hasAuthoritativeUpdate) {
// Reset during render so an event change is reflected in this very paint.
baseline.current = { current, duration, eventId, playback, at: now };
}
// There is only something to animate while a connected timer is counting down towards 0.
const shouldAnimate = isOnline && isRunning && current !== null && current > 0 && duration !== null;
// while counting down, re-render every animation frame so the derived progress stays smooth
useEffect(() => {
if (!shouldAnimate) {
return;
}
let frame = requestAnimationFrame(function tick() {
setTick((value) => value + 1);
frame = requestAnimationFrame(tick);
});
return () => cancelAnimationFrame(frame);
}, [shouldAnimate]);
// Derive from the anchor plus elapsed time at render; freeze while disconnected or not running.
const anchored = baseline.current.current;
const value = isOnline && isRunning && anchored !== null ? anchored - (now - baseline.current.at) : anchored;
return getProgress(value, duration);
}
+16 -63
View File
@@ -1,6 +1,7 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { import {
EntryId, EntryId,
InsertOptions,
MaybeString, MaybeString,
OntimeDelay, OntimeDelay,
OntimeEntry, OntimeEntry,
@@ -15,7 +16,6 @@ import {
TimeStrategy, TimeStrategy,
isOntimeEvent, isOntimeEvent,
isOntimeGroup, isOntimeGroup,
isOntimeMilestone,
} from 'ontime-types'; } from 'ontime-types';
import { import {
MILLIS_PER_SECOND, MILLIS_PER_SECOND,
@@ -49,7 +49,6 @@ import {
requestEventSwap, requestEventSwap,
requestGroupEntries, requestGroupEntries,
requestUngroup, requestUngroup,
requestFitGroupTarget,
} from '../api/rundown'; } from '../api/rundown';
import { logAxiosError } from '../api/utils'; import { logAxiosError } from '../api/utils';
import { useEditorSettings } from '../stores/editorSettings'; import { useEditorSettings } from '../stores/editorSettings';
@@ -60,26 +59,13 @@ export type EventOptions = Partial<{
before: MaybeString; before: MaybeString;
// options of entries of type OntimeEvent // options of entries of type OntimeEvent
linkPrevious: boolean; linkPrevious: boolean;
/** the timing reference for the new entry, usually the same as after */
lastEventId: MaybeString; lastEventId: MaybeString;
}>; }>;
type ClientInsertOptions = {
after?: EntryId;
before?: EntryId;
};
/** /**
* Gather utilities for actions on entries in the loaded rundown. * Gather utilities for actions on entries
*/ */
export const useEntryActions = () => useEntryActionsForRundown(undefined); export const useEntryActions = () => {
/**
* Gather utilities for actions on entries in an explicitly selected rundown.
*/
export const useScopedEntryActions = (rundownId: string | null) => useEntryActionsForRundown(rundownId ?? '');
function useEntryActionsForRundown(scopedRundownId: string | undefined) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { const {
linkPrevious, linkPrevious,
@@ -89,16 +75,15 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
defaultDangerTime, defaultDangerTime,
defaultTimerType, defaultTimerType,
defaultEndAction, defaultEndAction,
inheritGroupColour,
} = useEditorSettings(); } = useEditorSettings();
const resolveCurrentRundownQueryKey = useCallback(() => { const resolveCurrentRundownQueryKey = useCallback(() => {
if (scopedRundownId !== undefined) {
return getRundownQueryKey(scopedRundownId);
}
const loadedRundownId = queryClient.getQueryData<ProjectRundownsList>(PROJECT_RUNDOWNS)?.loaded; const loadedRundownId = queryClient.getQueryData<ProjectRundownsList>(PROJECT_RUNDOWNS)?.loaded;
return loadedRundownId ? getRundownQueryKey(loadedRundownId) : CURRENT_RUNDOWN_QUERY_KEY; if (loadedRundownId) {
}, [queryClient, scopedRundownId]); return getRundownQueryKey(loadedRundownId);
}
return CURRENT_RUNDOWN_QUERY_KEY;
}, [queryClient]);
/** /**
* Returns the currently loaded rundown * Returns the currently loaded rundown
@@ -126,7 +111,7 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
* @private * @private
*/ */
const { mutateAsync: addEntryMutation } = useMutation({ const { mutateAsync: addEntryMutation } = useMutation({
mutationFn: ([rundownId, entry]: [string, PatchWithId & ClientInsertOptions]) => postAddEntry(rundownId, entry), mutationFn: ([rundownId, entry]: [string, PatchWithId & InsertOptions]) => postAddEntry(rundownId, entry),
onMutate: async ([_rundownId, entry]) => { onMutate: async ([_rundownId, entry]) => {
const queryKey = resolveCurrentRundownQueryKey(); const queryKey = resolveCurrentRundownQueryKey();
await queryClient.cancelQueries({ queryKey }); await queryClient.cancelQueries({ queryKey });
@@ -155,9 +140,8 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
addToRundown( addToRundown(
newRundown, newRundown,
optimisticEntry, optimisticEntry,
parent ? (newRundown.entries[parent.id] as OntimeGroup) : null,
afterId, afterId,
entry.before ?? null, parent ? (newRundown.entries[parent.id] as OntimeGroup) : null,
); );
queryClient.setQueryData<Rundown>(queryKey, newRundown); queryClient.setQueryData<Rundown>(queryKey, newRundown);
@@ -200,7 +184,7 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
throw new Error('Rundown not initialised'); throw new Error('Rundown not initialised');
} }
const newEntry: PatchWithId & ClientInsertOptions = { ...entry, id: generateId() }; const newEntry: PatchWithId & InsertOptions = { ...entry, id: generateId() };
// handle adding options that concern all event types // handle adding options that concern all event types
if (options?.after) { if (options?.after) {
@@ -212,8 +196,8 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
// ************* CHECK OPTIONS specific to events // ************* CHECK OPTIONS specific to events
if (isOntimeEvent(newEntry)) { if (isOntimeEvent(newEntry)) {
// last event id is the timing reference of the previous OntimeEvent in rundown
if (options?.lastEventId) { if (options?.lastEventId) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we know this is a value
const previousEvent = rundownData.entries[options?.lastEventId]; const previousEvent = rundownData.entries[options?.lastEventId];
if (isOntimeEvent(previousEvent)) { if (isOntimeEvent(previousEvent)) {
newEntry.timeStart = previousEvent.timeEnd; newEntry.timeStart = previousEvent.timeEnd;
@@ -248,14 +232,6 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
} }
} }
if (inheritGroupColour && (isOntimeEvent(newEntry) || isOntimeMilestone(newEntry)) && !newEntry.colour) {
const parentId = resolveInsertParent(rundownData, newEntry);
const maybeParent = parentId ? rundownData.entries[parentId] : null;
if (maybeParent && isOntimeGroup(maybeParent)) {
newEntry.colour = maybeParent.colour;
}
}
try { try {
await addEntryMutation([rundownId, newEntry]); await addEntryMutation([rundownId, newEntry]);
} catch (error) { } catch (error) {
@@ -271,7 +247,6 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
defaultTimerType, defaultTimerType,
defaultEndAction, defaultEndAction,
defaultTimeStrategy, defaultTimeStrategy,
inheritGroupColour,
addEntryMutation, addEntryMutation,
], ],
); );
@@ -291,7 +266,7 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
* Clone an entry * Clone an entry
*/ */
const clone = useCallback( const clone = useCallback(
async (entryId: EntryId, options?: ClientInsertOptions) => { async (entryId: EntryId, options?: InsertOptions) => {
try { try {
const rundownId = getCurrentRundownData()?.id; const rundownId = getCurrentRundownData()?.id;
if (!rundownId) { if (!rundownId) {
@@ -467,27 +442,7 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
return previousEnd; return previousEnd;
} }
}, },
[getCurrentRundownData, updateEntryMutation, queryClient, resolveCurrentRundownQueryKey], [getCurrentRundownData, updateEntryMutation, queryClient],
);
/**
* Updates time of existing event so it satisfies the group target duration
* @param eventId {EntryId} - id of the event
*/
const matchGroupDuration = useCallback(
async (eventId: EntryId) => {
const rundownId = getCurrentRundownData()?.id;
if (!rundownId) {
throw new Error('Rundown not initialised');
}
try {
await requestFitGroupTarget(rundownId, eventId);
} catch (error) {
logAxiosError('Error updating event', error);
}
},
[getCurrentRundownData],
); );
/** /**
@@ -1030,7 +985,6 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
swapEvents, swapEvents,
updateEntry, updateEntry,
updateTimer, updateTimer,
matchGroupDuration,
}), }),
[ [
addEntry, addEntry,
@@ -1048,10 +1002,9 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
swapEvents, swapEvents,
updateEntry, updateEntry,
updateTimer, updateTimer,
matchGroupDuration,
], ],
); );
} };
/** /**
* Utility to optimistically delete entries from client cache * Utility to optimistically delete entries from client cache
@@ -1086,7 +1039,7 @@ function optimisticDeleteEntries(entryIds: EntryId[], rundown: Rundown) {
/** /**
* Utility to create an optimistic entry for immediate cache insertion * Utility to create an optimistic entry for immediate cache insertion
*/ */
function createOptimisticEntry(payload: PatchWithId & ClientInsertOptions): OntimeEntry { function createOptimisticEntry(payload: PatchWithId & InsertOptions): OntimeEntry {
const { after: _after, before: _before, ...entryData } = payload; const { after: _after, before: _before, ...entryData } = payload;
const id = entryData.id; const id = entryData.id;
let parent: EntryId | null = null; let parent: EntryId | null = null;
-12
View File
@@ -139,17 +139,6 @@ export const setEventPlayback = {
pause: () => sendSocket('pause', undefined), pause: () => sendSocket('pause', undefined),
}; };
export const useTimerProgress = createSelector((state: RuntimeStore) => ({
playback: state.timer.playback,
phase: state.timer.phase,
addedTime: state.timer.addedTime,
secondaryTimer: state.timer.secondaryTimer,
current: state.timer.current,
expectedFinish: state.timer.expectedFinish,
startedAt: state.timer.startedAt,
isCountToEnd: state.eventNow?.countToEnd ?? false,
}));
export const useTimer = createSelector((state: RuntimeStore) => ({ export const useTimer = createSelector((state: RuntimeStore) => ({
...state.timer, ...state.timer,
})); }));
@@ -163,7 +152,6 @@ export const useNextFlag = createSelector((state: RuntimeStore) => ({
export const useProgressData = createSelector((state: RuntimeStore) => ({ export const useProgressData = createSelector((state: RuntimeStore) => ({
current: state.timer.current, current: state.timer.current,
duration: state.timer.duration, duration: state.timer.duration,
eventId: state.eventNow?.id ?? null,
timeWarning: state.eventNow?.timeWarning ?? null, timeWarning: state.eventNow?.timeWarning ?? null,
timeDanger: state.eventNow?.timeDanger ?? null, timeDanger: state.eventNow?.timeDanger ?? null,
})); }));
@@ -1,87 +0,0 @@
import { hasCustomParams, stripReservedParams, useSavedViewParams } from '../savedViewParams';
describe('savedViewParams store', () => {
beforeEach(() => {
useSavedViewParams.getState().clearAll();
});
it('saves and restores params per view', () => {
useSavedViewParams.getState().save('timer', 'hideSeconds=true');
useSavedViewParams.getState().save('backstage', 'showProgress=false');
expect(useSavedViewParams.getState().params.timer).toBe('hideSeconds=true');
expect(useSavedViewParams.getState().params.backstage).toBe('showProgress=false');
});
it('overwrites the saved params for a view on subsequent saves', () => {
useSavedViewParams.getState().save('timer', 'hideSeconds=true');
useSavedViewParams.getState().save('timer', 'hideSeconds=false');
expect(useSavedViewParams.getState().params.timer).toBe('hideSeconds=false');
});
it('does not store an entry for an empty search string', () => {
useSavedViewParams.getState().save('timer', '');
expect(useSavedViewParams.getState().params).toEqual({});
});
it('removes a previously saved entry when saved with an empty search string', () => {
useSavedViewParams.getState().save('timer', 'hideSeconds=true');
useSavedViewParams.getState().save('timer', '');
expect(useSavedViewParams.getState().params).toEqual({});
});
it('ignores empty view keys', () => {
useSavedViewParams.getState().save('', 'hideSeconds=true');
expect(useSavedViewParams.getState().params).toEqual({});
});
it('clears the saved params for a single view without touching others', () => {
useSavedViewParams.getState().save('timer', 'hideSeconds=true');
useSavedViewParams.getState().save('backstage', 'showProgress=false');
useSavedViewParams.getState().clear('timer');
expect(useSavedViewParams.getState().params).toEqual({ backstage: 'showProgress=false' });
});
it('is a no-op when clearing a view without saved params', () => {
useSavedViewParams.getState().save('backstage', 'showProgress=false');
useSavedViewParams.getState().clear('timer');
expect(useSavedViewParams.getState().params).toEqual({ backstage: 'showProgress=false' });
});
it('clears all saved params', () => {
useSavedViewParams.getState().save('timer', 'hideSeconds=true');
useSavedViewParams.getState().clearAll();
expect(useSavedViewParams.getState().params).toEqual({});
});
});
describe('stripReservedParams', () => {
it('removes reserved auth/preset params while keeping view customisation', () => {
expect(stripReservedParams('hideSeconds=true&token=abc&n=1&alias=my')).toBe('hideSeconds=true');
});
it('returns an empty string when only reserved params are present', () => {
expect(stripReservedParams('token=abc&n=1&alias=my')).toBe('');
});
});
describe('hasCustomParams', () => {
it('is true when a non-reserved param is present', () => {
expect(hasCustomParams(new URLSearchParams('hideSeconds=true&token=abc'))).toBe(true);
});
it('is false when only reserved params are present', () => {
expect(hasCustomParams(new URLSearchParams('token=abc&n=1&alias=my'))).toBe(false);
});
it('is false when there are no params', () => {
expect(hasCustomParams(new URLSearchParams(''))).toBe(false);
});
});
@@ -12,10 +12,8 @@ type EditorSettingsStore = {
defaultDangerTime: string; defaultDangerTime: string;
defaultTimerType: TimerType; defaultTimerType: TimerType;
defaultEndAction: EndAction; defaultEndAction: EndAction;
inheritGroupColour: boolean;
setDefaultDuration: (defaultDuration: string) => void; setDefaultDuration: (defaultDuration: string) => void;
setLinkPrevious: (linkPrevious: boolean) => void; setLinkPrevious: (linkPrevious: boolean) => void;
setInheritGroupColour: (inheritGroupColour: boolean) => void;
setTimeStrategy: (timeStrategy: TimeStrategy) => void; setTimeStrategy: (timeStrategy: TimeStrategy) => void;
setWarnTime: (warnTime: string) => void; setWarnTime: (warnTime: string) => void;
setDangerTime: (dangerTime: string) => void; setDangerTime: (dangerTime: string) => void;
@@ -31,7 +29,6 @@ export const editorSettingsDefaults = {
dangerTime: '00:01:00', // 60000 same as backend dangerTime: '00:01:00', // 60000 same as backend
timerType: TimerType.CountDown, timerType: TimerType.CountDown,
endAction: EndAction.None, endAction: EndAction.None,
inheritGroupColour: false,
}; };
enum EditorSettingsKeys { enum EditorSettingsKeys {
@@ -42,7 +39,6 @@ enum EditorSettingsKeys {
DefaultDangerTime = 'ontime-default-danger-time', DefaultDangerTime = 'ontime-default-danger-time',
DefaultTimerType = 'ontime-default-timer-type', DefaultTimerType = 'ontime-default-timer-type',
DefaultEndAction = 'ontime-default-end-action', DefaultEndAction = 'ontime-default-end-action',
InheritGroupColour = 'ontime-inherit-group-colour',
} }
export const useEditorSettings = create<EditorSettingsStore>((set) => { export const useEditorSettings = create<EditorSettingsStore>((set) => {
@@ -63,10 +59,6 @@ export const useEditorSettings = create<EditorSettingsStore>((set) => {
localStorage.getItem(EditorSettingsKeys.DefaultEndAction), localStorage.getItem(EditorSettingsKeys.DefaultEndAction),
editorSettingsDefaults.endAction, editorSettingsDefaults.endAction,
), ),
inheritGroupColour: booleanFromLocalStorage(
EditorSettingsKeys.InheritGroupColour,
editorSettingsDefaults.inheritGroupColour,
),
setDefaultDuration: (defaultDuration) => setDefaultDuration: (defaultDuration) =>
set(() => { set(() => {
@@ -105,10 +97,5 @@ export const useEditorSettings = create<EditorSettingsStore>((set) => {
localStorage.setItem(EditorSettingsKeys.DefaultEndAction, String(defaultEndAction)); localStorage.setItem(EditorSettingsKeys.DefaultEndAction, String(defaultEndAction));
return { defaultEndAction }; return { defaultEndAction };
}), }),
setInheritGroupColour: (inheritGroupColour) =>
set(() => {
localStorage.setItem(EditorSettingsKeys.InheritGroupColour, String(inheritGroupColour));
return { inheritGroupColour };
}),
}; };
}); });
@@ -1,62 +0,0 @@
import { create } from 'zustand';
// params that are auth/preset markers, not user view customisation (see common/utils/urlPresets.ts)
export const reservedParams = new Set(['token', 'n', 'alias']);
interface SavedViewParamsStore {
params: Record<string, string>; // view key (e.g. "timer") -> search string without leading "?"
save: (view: string, search: string) => void;
clear: (view: string) => void;
clearAll: () => void;
}
/**
* Remembers the last view parameters used for each view so they can be
* restored when the user navigates back to that view through the menu.
* In-memory only: persists across SPA navigation, resets on a full page reload.
*/
export const useSavedViewParams = create<SavedViewParamsStore>((set) => ({
params: {},
save: (view, search) =>
set((state) => {
// ignore empty view keys and do not store empty entries, so the
// "saved changes" indicator only reflects genuine customisation
if (!view) return state;
const params = { ...state.params };
if (search) {
params[view] = search;
} else {
delete params[view];
}
return { params };
}),
clear: (view) =>
set((state) => {
if (!state.params[view]) return state;
const params = { ...state.params };
delete params[view];
return { params };
}),
clearAll: () => set({ params: {} }),
}));
/**
* Removes reserved (auth/preset) params from a search string, keeping only
* genuine view customisation.
*/
export function stripReservedParams(search: string): string {
const sp = new URLSearchParams(search);
reservedParams.forEach((key) => sp.delete(key));
return sp.toString();
}
/**
* Whether the search params contain any genuine view customisation,
* ignoring reserved params.
*/
export function hasCustomParams(searchParams: URLSearchParams): boolean {
for (const key of searchParams.keys()) {
if (!reservedParams.has(key)) return true;
}
return false;
}
@@ -1,4 +1,4 @@
import { hostToBaseUrl, linkToOtherHost } from '../linkUtils'; import { linkToOtherHost } from '../linkUtils';
describe('linkToOTherHost', () => { describe('linkToOTherHost', () => {
it('should handle electron links', () => { it('should handle electron links', () => {
@@ -22,18 +22,3 @@ describe('linkToOTherHost', () => {
expect(destination).toBe('https://app.getontime.no/user-hash/path'); expect(destination).toBe('https://app.getontime.no/user-hash/path');
}); });
}); });
describe('hostToBaseUrl', () => {
it('uses http when the page is plain http', () => {
expect(hostToBaseUrl('192.168.10.166', 4001, 'http://localhost:4001')).toBe('http://192.168.10.166:4001');
});
it('keeps https when the page reaches the server over TLS on the same port', () => {
expect(hostToBaseUrl('192.168.10.166', 4001, 'https://localhost:4001')).toBe('https://192.168.10.166:4001');
});
it('falls back to http when the page is https on a different port (TLS proxy)', () => {
// the raw server port does not speak TLS, an https link to it would fail
expect(hostToBaseUrl('192.168.10.166', 4001, 'https://ontime.example.com')).toBe('http://192.168.10.166:4001');
});
});
@@ -1,25 +0,0 @@
import { describe, expect, it } from 'vitest';
import { removeFileExtension } from '../uploadUtils';
describe('removeFileExtension()', () => {
it('removes a trailing extension', () => {
expect(removeFileExtension('show.xlsx')).toBe('show');
});
it('only removes the last extension', () => {
expect(removeFileExtension('my.show.xlsx')).toBe('my.show');
});
it('returns the name unchanged when there is no extension', () => {
expect(removeFileExtension('rundown')).toBe('rundown');
});
it('does not treat a leading dot as an extension', () => {
expect(removeFileExtension('.gitignore')).toBe('.gitignore');
});
it('handles an empty string', () => {
expect(removeFileExtension('')).toBe('');
});
});
@@ -37,16 +37,6 @@ describe('getRouteFromPreset()', () => {
alias: 'demopage', alias: 'demopage',
target: OntimeView.Timer, target: OntimeView.Timer,
search: 'user=guest', search: 'user=guest',
displayInNav: false,
options: {},
},
];
const disabledPresets: URLPreset[] = [
{
enabled: false,
alias: 'demopage',
target: OntimeView.Timer,
search: 'user=guest',
options: {}, options: {},
}, },
]; ];
@@ -57,16 +47,6 @@ describe('getRouteFromPreset()', () => {
expect(getRouteFromPreset(location, presets)).toStrictEqual('timer?user=guest&alias=demopage'); expect(getRouteFromPreset(location, presets)).toStrictEqual('timer?user=guest&alias=demopage');
}); });
it('checks if the current location matches an enabled preset target', () => {
const location = resolvePath('/timer');
expect(getRouteFromPreset(location, presets)).toStrictEqual('timer?user=guest&alias=demopage');
});
it('does not redirect disabled presets', () => {
const location = resolvePath('/demopage');
expect(getRouteFromPreset(location, disabledPresets)).toBeNull();
});
it('returns null when already on a preset path', () => { it('returns null when already on a preset path', () => {
const location = resolvePath('/preset/demopage'); const location = resolvePath('/preset/demopage');
expect(getRouteFromPreset(location, presets)).toBeNull(); expect(getRouteFromPreset(location, presets)).toBeNull();
@@ -106,11 +86,6 @@ describe('getRouteFromPreset()', () => {
const location = resolvePath('/demopage?n=1&token=123'); const location = resolvePath('/demopage?n=1&token=123');
expect(getRouteFromPreset(location, presets)).toBe('timer?user=guest&alias=demopage&n=1&token=123'); expect(getRouteFromPreset(location, presets)).toBe('timer?user=guest&alias=demopage&n=1&token=123');
}); });
it('redirects stale unwrapped params back to the saved preset params while preserving feature params', () => {
const location = resolvePath('/timer?user=admin&alias=demopage&n=1&token=123');
expect(getRouteFromPreset(location, presets)).toBe('timer?user=guest&alias=demopage&n=1&token=123');
});
}); });
describe('cuesheet presets', () => { describe('cuesheet presets', () => {
@@ -120,7 +95,6 @@ describe('getRouteFromPreset()', () => {
alias: 'cuesheet-4685d6', alias: 'cuesheet-4685d6',
target: OntimeView.Cuesheet, target: OntimeView.Cuesheet,
search: '', search: '',
displayInNav: false,
options: { options: {
read: 'full', read: 'full',
write: '-', write: '-',
@@ -133,7 +107,6 @@ describe('getRouteFromPreset()', () => {
alias: 'cuesheet-basic', alias: 'cuesheet-basic',
target: OntimeView.Cuesheet, target: OntimeView.Cuesheet,
search: '', search: '',
displayInNav: false,
}, },
]; ];
const cuesheetPresetWithNavLock: URLPreset[] = [ const cuesheetPresetWithNavLock: URLPreset[] = [
@@ -142,7 +115,6 @@ describe('getRouteFromPreset()', () => {
alias: 'cuesheet-locked', alias: 'cuesheet-locked',
target: OntimeView.Cuesheet, target: OntimeView.Cuesheet,
search: 'n=1', search: 'n=1',
displayInNav: false,
}, },
]; ];
@@ -224,7 +196,6 @@ describe('generateUrlPresetOptions', () => {
target: 'timer', target: 'timer',
search: 'param1=value1&param2=value2', search: 'param1=value1&param2=value2',
enabled: true, enabled: true,
displayInNav: false,
}, },
], ],
[ [
@@ -236,7 +207,6 @@ describe('generateUrlPresetOptions', () => {
target: 'timer', target: 'timer',
search: 'param1=value1&param2=value2', search: 'param1=value1&param2=value2',
enabled: true, enabled: true,
displayInNav: false,
}, },
], ],
[ [
@@ -248,7 +218,6 @@ describe('generateUrlPresetOptions', () => {
target: 'timer', target: 'timer',
search: 'param1=value1&param2=value2', search: 'param1=value1&param2=value2',
enabled: true, enabled: true,
displayInNav: false,
}, },
], ],
[ [
@@ -260,7 +229,6 @@ describe('generateUrlPresetOptions', () => {
target: 'timer', target: 'timer',
search: 'param1=value1&param2=value2', search: 'param1=value1&param2=value2',
enabled: true, enabled: true,
displayInNav: false,
}, },
], ],
])('should generate URL preset options for %s', (_description, alias, url, expected) => { ])('should generate URL preset options for %s', (_description, alias, url, expected) => {
+1 -20
View File
@@ -37,12 +37,7 @@ export function handleLinks(
event?.preventDefault(); event?.preventDefault();
const destination = new URL(externalServerUrl); const destination = new URL(externalServerUrl);
// split off any query string so it is not encoded into the pathname destination.pathname = externalBaseURI ? `${externalBaseURI}/${location}` : location;
const [pathname, search] = location.split('?');
destination.pathname = externalBaseURI ? `${externalBaseURI}/${pathname}` : pathname;
if (search) {
destination.search = search;
}
openLink(destination.toString()); openLink(destination.toString());
} }
@@ -59,17 +54,3 @@ export function linkToOtherHost(
} }
return destination.toString(); return destination.toString();
} }
/**
* Builds a base URL for reaching the Ontime server on a given network interface.
* The Ontime server itself only speaks HTTP, so we only keep https when the page
* already reaches the server over TLS on that same port (eg. a proxy in front of it);
* any other explicit server port gets http.
* externalServerUrl is used for testing
*/
export function hostToBaseUrl(host: string, port: number, externalServerUrl: string = serverURL): string {
const page = new URL(externalServerUrl);
const pagePort = page.port === '' ? (page.protocol === 'https:' ? 443 : 80) : Number(page.port);
const scheme = page.protocol === 'https:' && pagePort === port ? 'https' : 'http';
return `${scheme}://${host}:${port}`;
}
+2 -7
View File
@@ -14,8 +14,8 @@ import {
APP_SETTINGS, APP_SETTINGS,
CLIENT_LIST, CLIENT_LIST,
CSS_OVERRIDE, CSS_OVERRIDE,
CURRENT_RUNDOWN_QUERY_KEY,
CUSTOM_FIELDS, CUSTOM_FIELDS,
CURRENT_RUNDOWN_QUERY_KEY,
PROJECT_DATA, PROJECT_DATA,
REPORT, REPORT,
RUNDOWN, RUNDOWN,
@@ -213,9 +213,6 @@ export const connectSocket = () => {
case RefetchKey.Settings: case RefetchKey.Settings:
ontimeQueryClient.invalidateQueries({ queryKey: APP_SETTINGS }); ontimeQueryClient.invalidateQueries({ queryKey: APP_SETTINGS });
break; break;
case RefetchKey.ProjectRundowns:
ontimeQueryClient.invalidateQueries({ queryKey: PROJECT_RUNDOWNS });
break;
default: { default: {
target satisfies never; target satisfies never;
break; break;
@@ -245,9 +242,7 @@ export function maybeInvalidateRundownCache(revision: MaybeNumber, rundownId?: s
// skip if we dont recognise the ID the revision is lower // skip if we dont recognise the ID the revision is lower
const queryKey = getRundownQueryKey(rundownId); const queryKey = getRundownQueryKey(rundownId);
const cachedRundown = ontimeQueryClient.getQueryData<{ revision: number }>(queryKey); const cachedRundown = ontimeQueryClient.getQueryData<{ revision: number }>(queryKey);
if (revision !== null && revision === cachedRundown?.revision) {
if (revision === cachedRundown?.revision) {
// we already have the latest change
return; return;
} }
+4 -9
View File
@@ -3,9 +3,7 @@ import {
MILLIS_PER_HOUR, MILLIS_PER_HOUR,
MILLIS_PER_MINUTE, MILLIS_PER_MINUTE,
MILLIS_PER_SECOND, MILLIS_PER_SECOND,
dayInMs,
formatFromMillis, formatFromMillis,
getExpectedEnd,
getExpectedStart, getExpectedStart,
} from 'ontime-utils'; } from 'ontime-utils';
@@ -31,11 +29,6 @@ export function nowInMillis(): number {
return elapsed; return elapsed;
} }
export function normaliseWallClock(time: number): number {
const timeOfDay = time % dayInMs;
return timeOfDay < 0 ? timeOfDay + dayInMs : timeOfDay;
}
/** /**
* @description Resolves format from url and store * @description Resolves format from url and store
* @return {string|null} A format string like "hh:mm:ss a" or null * @return {string|null} A format string like "hh:mm:ss a" or null
@@ -187,13 +180,15 @@ export function getExpectedTimesFromExtendedEvent(
...state, ...state,
}, },
); );
const expectedEnd = getExpectedEnd(event, expectedStart, state.currentDay);
const plannedEnd = event.timeStart + event.duration + event.delay; const plannedEnd = event.timeStart + event.duration + event.delay;
return { return {
expectedStart, expectedStart,
timeToStart: expectedStart - state.clock, timeToStart: expectedStart - state.clock,
expectedEnd, expectedEnd: event.countToEnd
? Math.max(expectedStart + event.duration, plannedEnd)
: expectedStart + event.duration,
plannedEnd, plannedEnd,
}; };
} }
@@ -38,18 +38,6 @@ export function validateProjectFile(file: File) {
} }
} }
/**
* Removes a trailing file extension from a file name (e.g. "show.xlsx" -> "show")
* A leading dot (dotfiles like ".gitignore") is not treated as an extension
*/
export function removeFileExtension(fileName: string): string {
const lastDot = fileName.lastIndexOf('.');
if (lastDot <= 0) {
return fileName;
}
return fileName.slice(0, lastDot);
}
export function isExcelFile(file: File | null) { export function isExcelFile(file: File | null) {
return file?.name.endsWith('.xlsx'); return file?.name.endsWith('.xlsx');
} }
@@ -194,7 +194,6 @@ export function generateUrlPresetOptions(alias: string, userUrl: string): URLPre
target: path, target: path,
search: url.searchParams.toString(), search: url.searchParams.toString(),
enabled: true, enabled: true,
displayInNav: false,
}; };
} }
@@ -1,21 +0,0 @@
import { describe, expect, it } from 'vitest';
import { matchesSettingsOptionQuery } from '../useAppSettingsMenu';
describe('matchesSettingsOptionQuery', () => {
const option = {
id: 'settings__general',
label: 'General settings',
keywords: ['pin', 'time zone'],
};
it('does not match an empty or whitespace-only query', () => {
expect(matchesSettingsOptionQuery(option, '')).toBe(false);
expect(matchesSettingsOptionQuery(option, ' ')).toBe(false);
});
it('matches keywords case-insensitively', () => {
expect(matchesSettingsOptionQuery(option, 'PIN')).toBe(true);
expect(matchesSettingsOptionQuery(option, 'TIME ZONE')).toBe(true);
});
});
@@ -1,4 +1,9 @@
$content-max-width: 1280px; .corner {
position: fixed;
top: 6rem;
right: 4rem;
z-index: $zindex-floating;
}
.contentWrapper { .contentWrapper {
display: flex; display: flex;
@@ -9,29 +14,9 @@ $content-max-width: 1280px;
position: relative; position: relative;
} }
.corner {
flex: 0 0 auto;
display: flex;
justify-content: flex-end;
// width is needed so the button can align to the right of the capped
// column, auto margins alone would shrink this to fit and centre it
width: 100%;
max-width: $content-max-width;
margin-inline: auto;
padding: 0 1rem 0.5rem;
box-sizing: border-box;
}
.content { .content {
box-sizing: border-box; margin: 1rem;
// as with .corner, the width is needed to fill the capped column, otherwise
// auto margins shrink this to fit and each panel ends up its own width
width: 100%;
max-width: $content-max-width;
margin: 0 auto 1rem;
padding-inline: 1rem;
overflow-y: auto; overflow-y: auto;
flex-grow: 1; flex-grow: 1;
// room for the last section to scroll to the top of the viewport padding-bottom: 300px;
padding-bottom: 40vh;
} }
@@ -12,12 +12,12 @@ interface PanelContentProps {
export default function PanelContent({ onClose, children }: PropsWithChildren<PanelContentProps>) { export default function PanelContent({ onClose, children }: PropsWithChildren<PanelContentProps>) {
return ( return (
<div className={style.contentWrapper}> <div className={style.contentWrapper}>
<div className={style.content}>{children}</div>
<div className={style.corner}> <div className={style.corner}>
<Button size='large' onClick={onClose}> <Button size='large' onClick={onClose}>
Close settings <IoClose /> Close settings <IoClose />
</Button> </Button>
</div> </div>
<div className={style.content}>{children}</div>
</div> </div>
); );
} }
@@ -1,13 +1,3 @@
.container {
width: min(30vw, 300px);
flex: 0 0 min(30vw, 300px);
min-width: min(30vw, 300px);
display: flex;
flex-direction: column;
gap: 0.75rem;
overflow-y: auto;
}
.tabs, .tabs,
ul { ul {
list-style: none; list-style: none;
@@ -16,20 +6,22 @@ ul {
} }
.tabs { .tabs {
width: min(30vw, 300px);
flex: 0 0 min(30vw, 300px);
min-width: min(30vw, 300px);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow-y: auto;
} }
.primary, .primary,
.secondary { .secondary {
padding: 0.25rem 1rem; padding: 0.25rem 1rem;
margin-right: 1rem; margin-right: 1rem;
border-radius: 2px;
&:focus-visible { &:focus {
background-color: $gray-1000; background-color: $gray-1000;
outline: 2px solid $blue-500; outline: 0;
outline-offset: -2px;
} }
&:hover { &:hover {
@@ -40,21 +32,14 @@ ul {
.primary { .primary {
font-size: 1rem; font-size: 1rem;
border-radius: 2px;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
border-left: 3px solid transparent;
&.groupActive {
color: $blue-400;
font-weight: 600;
}
&.active { &.active {
color: $ui-white; color: $blue-400;
border-left-color: $blue-400; background-color: $gray-1100;
background-color: rgba($blue-500, 0.16);
font-weight: 600;
} }
&.highlight { &.highlight {
@@ -77,9 +62,6 @@ ul {
font-size: $inner-section-text-size; font-size: $inner-section-text-size;
&.active { &.active {
color: $ui-white; color: $blue-400;
border-left-color: $blue-400;
background-color: rgba($blue-500, 0.16);
font-weight: 600;
} }
} }
@@ -1,18 +1,10 @@
import { Fragment, useState } from 'react'; import { Fragment } from 'react';
import Tooltip from '../../../common/components/tooltip/Tooltip'; import Tooltip from '../../../common/components/tooltip/Tooltip';
import { isKeyEnter } from '../../../common/utils/keyEvent'; import { isKeyEnter } from '../../../common/utils/keyEvent';
import { cx } from '../../../common/utils/styleUtils'; import { cx } from '../../../common/utils/styleUtils';
import * as Panel from '../panel-utils/PanelUtils'; import { SettingsOption, SettingsOptionId, useAppSettingsMenu } from '../useAppSettingsMenu';
import {
filterSettingsOptions,
matchesSettingsOptionQuery,
SettingsOption,
SettingsOptionId,
useAppSettingsMenu,
} from '../useAppSettingsMenu';
import useAppSettingsNavigation from '../useAppSettingsNavigation'; import useAppSettingsNavigation from '../useAppSettingsNavigation';
import SettingsSearch from './SettingsSearch';
import style from './PanelList.module.scss'; import style from './PanelList.module.scss';
@@ -24,57 +16,23 @@ interface PanelListProps extends PanelBaseProps {
selectedPanel: string; selectedPanel: string;
} }
/** Returns the first matching setting, preferring a matching child over its non-matching group. */
function getFirstResultId(results: SettingsOption[], query: string): SettingsOptionId | null {
const firstResult = results[0];
if (!firstResult) {
return null;
}
if (matchesSettingsOptionQuery(firstResult, query.trim().toLowerCase())) {
return firstResult.id as SettingsOptionId;
}
return (firstResult.secondary?.[0]?.id as SettingsOptionId | undefined) ?? null;
}
export default function PanelList({ selectedPanel, location }: PanelListProps) { export default function PanelList({ selectedPanel, location }: PanelListProps) {
const { options } = useAppSettingsMenu(); const { options } = useAppSettingsMenu();
const { setLocation } = useAppSettingsNavigation();
const [query, setQuery] = useState('');
const results = filterSettingsOptions(options, query);
const handleSearchSubmit = () => {
const target = getFirstResultId(results, query);
if (target) {
setLocation(target);
setQuery('');
}
};
return ( return (
<div className={style.container}> <ul className={style.tabs}>
<SettingsSearch query={query} onQueryChange={setQuery} onSubmit={handleSearchSubmit} /> {options.map((panel) => {
const isSelected = selectedPanel === panel.id;
{results.length === 0 ? ( if (panel.highlight) {
<Panel.EmptyState title='No settings match' description={`Nothing found for "${query.trim()}"`} /> return (
) : ( <Tooltip key={panel.id} text={panel.highlight} render={<span />}>
<ul className={style.tabs}> <PanelListItem panel={panel} location={location} isSelected={isSelected} />
{results.map((panel) => { </Tooltip>
const isSelected = selectedPanel === panel.id; );
if (panel.highlight) { }
return ( return <PanelListItem key={panel.id} panel={panel} location={location} isSelected={isSelected} />;
<Tooltip key={panel.id} text={panel.highlight} render={<span />}> })}
<PanelListItem panel={panel} location={location} isSelected={isSelected} /> </ul>
</Tooltip>
);
}
return <PanelListItem key={panel.id} panel={panel} location={location} isSelected={isSelected} />;
})}
</ul>
)}
</div>
); );
} }
@@ -86,15 +44,7 @@ interface PanelListItemProps {
function PanelListItem({ panel, isSelected, location }: PanelListItemProps) { function PanelListItem({ panel, isSelected, location }: PanelListItemProps) {
const { setLocation } = useAppSettingsNavigation(); const { setLocation } = useAppSettingsNavigation();
const hasSelectedChild = Boolean( const classes = cx([style.primary, isSelected && style.active, panel.highlight && style.highlight]);
isSelected && panel.secondary?.some((secondary) => secondary.id.split('__')[1] === location),
);
const classes = cx([
style.primary,
isSelected && !hasSelectedChild && style.active,
hasSelectedChild && style.groupActive,
panel.highlight && style.highlight,
]);
return ( return (
<Fragment key={panel.id}> <Fragment key={panel.id}>
@@ -125,7 +75,6 @@ function PanelListItem({ panel, isSelected, location }: PanelListItemProps) {
} }
}} }}
className={secondaryClasses} className={secondaryClasses}
tabIndex={0}
role='button' role='button'
> >
{secondary.label} {secondary.label}
@@ -1,24 +0,0 @@
.search {
position: relative;
display: flex;
align-items: center;
flex: 0 0 auto;
margin: 0.25rem 1rem 0.25rem 0.25rem;
}
.icon {
position: absolute;
left: 0.5rem;
color: $gray-400;
pointer-events: none;
}
.input {
padding-left: 2rem;
padding-right: 2rem;
}
.clear {
position: absolute;
right: 0.25rem;
}
@@ -1,60 +0,0 @@
import { KeyboardEvent, useRef } from 'react';
import { IoClose, IoSearch } from 'react-icons/io5';
import IconButton from '../../../common/components/buttons/IconButton';
import Input from '../../../common/components/input/input/Input';
import style from './SettingsSearch.module.scss';
interface SettingsSearchProps {
query: string;
onQueryChange: (query: string) => void;
onSubmit: () => void;
}
export default function SettingsSearch({ query, onQueryChange, onSubmit }: SettingsSearchProps) {
const searchRef = useRef<HTMLInputElement>(null);
const isSearching = query.trim().length > 0;
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Escape' && isSearching) {
// Do not let the settings panel close while the user is clearing a search.
event.stopPropagation();
onQueryChange('');
return;
}
if (event.key === 'Enter') {
onSubmit();
}
};
return (
<div className={style.search}>
<IoSearch className={style.icon} />
<Input
ref={searchRef}
value={query}
onChange={(event) => onQueryChange(event.target.value)}
onKeyDown={handleKeyDown}
placeholder='Search settings'
className={style.input}
fluid
/>
{isSearching && (
<IconButton
variant='ghosted-white'
size='small'
aria-label='Clear search'
className={style.clear}
onClick={() => {
onQueryChange('');
searchRef.current?.focus();
}}
>
<IoClose />
</IconButton>
)}
</div>
);
}
@@ -1,26 +1,24 @@
$inner-padding: 1rem; $inner-padding: 1rem;
$card-padding: 2rem;
.header { .header {
font-size: 1.5rem; font-size: 2rem;
padding-bottom: 0.5rem; padding-bottom: 0.5rem;
border-bottom: 1px solid $white-10; border-bottom: 1px solid $white-10;
font-weight: 600; font-weight: 600;
} }
.subheader { .subheader {
font-size: 1.125rem; font-size: 1.5rem;
padding-bottom: 0.5rem; padding-bottom: 0.5rem;
font-weight: 600; font-weight: 600;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 1rem;
} }
.title { .title {
font-size: 1rem; font-size: 1.375rem;
padding: 0 var(--panel-card-padding, #{$card-padding}); padding: 0 2rem;
font-weight: 600; font-weight: 600;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -37,12 +35,10 @@ $card-padding: 2rem;
color: $ui-white; color: $ui-white;
} }
// width is owned by the panel content column, which caps and centres itself.
// sections must stay full width so top level cards line up with Panel.Header
// and nested sections fill their card rather than shrinking to fit
.section { .section {
position: relative; position: relative;
margin-top: 2rem; margin-top: 2rem;
max-width: 850px;
} }
.indent { .indent {
@@ -55,10 +51,8 @@ $card-padding: 2rem;
} }
.card { .card {
--panel-card-padding: #{$card-padding};
position: relative; position: relative;
padding: var(--panel-card-padding); padding: 2rem;
background-color: $white-3; background-color: $white-3;
border: 1px solid $gray-1100; border: 1px solid $gray-1100;
border-radius: 3px; border-radius: 3px;
@@ -82,10 +76,10 @@ $card-padding: 2rem;
color: $error-red; color: $error-red;
} }
// tables scroll with the panel rather than owning a nested scroll area,
// which keeps the sticky table head anchored to the panel viewport
.pad { .pad {
padding: 0 var(--panel-card-padding, #{$card-padding}); padding: 0 2rem;
max-height: 550px;
overflow-y: scroll;
} }
.table { .table {
@@ -103,13 +97,10 @@ $card-padding: 2rem;
} }
th { th {
font-weight: 600; font-weight: 400;
color: $gray-300; color: $gray-400;
font-size: calc(1rem - 3px);
letter-spacing: 0.02em;
vertical-align: bottom;
white-space: nowrap; white-space: nowrap;
text-transform: uppercase; text-transform: capitalize;
} }
th, th,
@@ -121,17 +112,6 @@ $card-padding: 2rem;
background-color: $white-1; background-color: $white-1;
} }
// active rows use a panel-local class today; keep the treatment here so all
// settings tables share the same state without each panel owning its colours
tr[class*='current'] {
background-color: rgba($blue-500, 0.16);
> td:first-child {
box-shadow: inset 3px 0 $blue-400;
font-weight: 500;
}
}
// allow highlighting table elements // allow highlighting table elements
tr[data-warn='true'], tr[data-warn='true'],
td[data-warn='true'] { td[data-warn='true'] {
@@ -140,7 +120,7 @@ $card-padding: 2rem;
} }
.listGroup { .listGroup {
padding: 0 var(--panel-card-padding, #{$card-padding}); padding: 0 2rem;
> li:not(:last-child) { > li:not(:last-child) {
border-bottom: 1px solid $white-10; border-bottom: 1px solid $white-10;
@@ -153,17 +133,6 @@ $card-padding: 2rem;
align-items: center; align-items: center;
gap: 1rem; gap: 1rem;
padding: 0.5rem 0; padding: 0.5rem 0;
&.interactive {
margin-inline: calc(var(--panel-card-padding, #{$card-padding}) * -1);
padding: 0.75rem var(--panel-card-padding, #{$card-padding});
transition: background-color $transition-time-action;
&:hover,
&:focus-within {
background-color: $white-3;
}
}
} }
.fieldTitle { .fieldTitle {
@@ -194,13 +163,14 @@ $card-padding: 2rem;
.divider { .divider {
border: none; border: none;
border-top: 1px solid $white-10; border-top: 1px solid $white-10;
margin: 1rem calc(var(--panel-card-padding, #{$card-padding}) * -1); margin: 1rem -2rem;
} }
.overlay { .overlay {
position: absolute; position: absolute;
z-index: $zindex-backdrop; z-index: $zindex-backdrop;
inset: 0; width: 100%;
height: 100%;
backdrop-filter: blur(2px); backdrop-filter: blur(2px);
display: grid; display: grid;
place-content: center; place-content: center;
@@ -218,35 +188,19 @@ $card-padding: 2rem;
animation: animloader 1s ease-in infinite; animation: animloader 1s ease-in infinite;
} }
.emptyState { .empty {
padding: 3rem 1.5rem; background-color: $black-10;
text-align: center; text-align: center;
} color: $muted-gray;
.emptyMessage { td {
width: min(30rem, 100%); padding-block: 5rem;
margin-inline: auto; }
}
.emptyTitle { button {
margin-bottom: 0.25rem; margin-top: 1rem;
color: rgba($gray-200, 0.72); margin-inline: auto;
font-size: calc(1rem + 2px); }
font-weight: 400;
}
.emptyBody {
color: rgba($gray-200, 0.55);
font-size: calc(1rem - 3px);
line-height: 1.5;
}
.emptyAction {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
margin-top: 1rem;
} }
.inlineElements { .inlineElements {
@@ -285,3 +239,14 @@ $card-padding: 2rem;
opacity: 0; opacity: 0;
} }
} }
@keyframes animloader {
0% {
transform: scale(0);
opacity: 0.6;
}
100% {
transform: scale(1);
opacity: 0;
}
}
@@ -1,5 +1,7 @@
import { HTMLAttributes, PropsWithChildren, ReactNode } from 'react'; import { HTMLAttributes, PropsWithChildren, ReactNode } from 'react';
import { IoAdd } from 'react-icons/io5';
import Button from '../../../common/components/buttons/Button';
import { cx } from '../../../common/utils/styleUtils'; import { cx } from '../../../common/utils/styleUtils';
import style from './PanelUtils.module.scss'; import style from './PanelUtils.module.scss';
@@ -60,29 +62,16 @@ export function Table({ className, children }: { className?: string; children: R
); );
} }
export interface EmptyStateProps { export function TableEmpty({ label, handleClick }: { label?: string; handleClick?: () => void }) {
title: string;
description?: ReactNode;
action?: ReactNode;
}
export function EmptyState({ title, description, action }: EmptyStateProps) {
return ( return (
<div className={style.emptyState}> <tr className={style.empty}>
<div className={style.emptyMessage}>
<div className={style.emptyTitle}>{title}</div>
{description && <div className={style.emptyBody}>{description}</div>}
{action && <div className={style.emptyAction}>{action}</div>}
</div>
</div>
);
}
export function TableEmpty(props: EmptyStateProps) {
return (
<tr>
<td colSpan={99}> <td colSpan={99}>
<EmptyState {...props} /> <div>{label ?? 'No data yet'}</div>
{handleClick && (
<Button onClick={handleClick} disabled={!handleClick} variant='primary'>
New <IoAdd />
</Button>
)}
</td> </td>
</tr> </tr>
); );
@@ -92,8 +81,8 @@ export function ListGroup({ className, children }: { className?: string; childre
return <ul className={cx([style.listGroup, className])}>{children}</ul>; return <ul className={cx([style.listGroup, className])}>{children}</ul>;
} }
export function ListItem({ children, interactive = false }: { children: ReactNode; interactive?: boolean }) { export function ListItem({ children }: { children: ReactNode }) {
return <li className={cx([style.listItem, interactive && style.interactive])}>{children}</li>; return <li className={style.listItem}>{children}</li>;
} }
export function Field({ export function Field({
@@ -1,4 +1,4 @@
import Info from '../../../../common/components/info/Info'; import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
import { import {
buyMeACoffeeUrl, buyMeACoffeeUrl,
discordUrl, discordUrl,
@@ -11,8 +11,6 @@ import {
} from '../../../../externals'; } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import AppVersion from './AppVersion'; import AppVersion from './AppVersion';
import CloudPanel from './CloudPanel';
import ExternalLinkRow from './ExternalLinkRow';
export default function AboutPanel() { export default function AboutPanel() {
return ( return (
@@ -21,49 +19,28 @@ export default function AboutPanel() {
<Panel.Section> <Panel.Section>
<Panel.Card> <Panel.Card>
<Panel.SubHeader>Ontime</Panel.SubHeader> <Panel.SubHeader>Ontime</Panel.SubHeader>
<Panel.Divider /> <Panel.Paragraph>
<Info>Free, open-source software for managing rundowns and event timers.</Info> Free, open-source software for managing rundowns and event timers
<Panel.ListGroup> <ExternalLink href={websiteUrl}>www.getontime.no</ExternalLink>
<ExternalLinkRow href={websiteUrl} title='www.getontime.no' description='App site and downloads' /> </Panel.Paragraph>
<ExternalLinkRow <Panel.Paragraph>
href={githubSponsorUrl} Considering sponsoring our work
title='GitHub Sponsors' <ExternalLink href={githubSponsorUrl}>GitHub Sponsors</ExternalLink>
description='Support development through GitHub' <ExternalLink href={buyMeACoffeeUrl}>Buy Me a Coffee</ExternalLink>
/> </Panel.Paragraph>
<ExternalLinkRow <Panel.Paragraph>
href={buyMeACoffeeUrl} If you are looking for an online version of Ontime, consider trying out our cloud service
title='Buy Me a Coffee' <ExternalLink href={websiteUrl}>www.getontime.no</ExternalLink>
description='Make a one-time contribution' </Panel.Paragraph>
/>
</Panel.ListGroup>
</Panel.Card>
</Panel.Section>
<CloudPanel />
<Panel.Section>
<Panel.Card>
<Panel.SubHeader>Current version</Panel.SubHeader>
<Panel.Divider />
<Panel.ListGroup>
<AppVersion />
</Panel.ListGroup>
</Panel.Card>
</Panel.Section>
<Panel.Section>
<Panel.Card>
<Panel.SubHeader>Helpful links</Panel.SubHeader>
<Panel.Divider />
<Panel.ListGroup>
<ExternalLinkRow
href={documentationUrl}
title='Read the docs'
description='Setup guides and feature documentation'
/>
<ExternalLinkRow href={githubUrl} title='GitHub' description='Report issues or contribute to Ontime' />
<ExternalLinkRow href={youtubeUrl} title='YouTube' description='Watch tutorials and feature walkthroughs' />
<ExternalLinkRow href={discordUrl} title='Discord' description='Get help and share suggestions' />
<ExternalLinkRow href={subredditUrl} title='Reddit' description='Join the Ontime community discussion' />
</Panel.ListGroup>
</Panel.Card> </Panel.Card>
<Panel.SubHeader>Current version</Panel.SubHeader>
<AppVersion />
<Panel.SubHeader>Links</Panel.SubHeader>
<ExternalLink href={documentationUrl}>Read the docs</ExternalLink>
<ExternalLink href={githubUrl}>Follow the project on GitHub</ExternalLink>
<ExternalLink href={discordUrl}>Discord server</ExternalLink>
<ExternalLink href={youtubeUrl}>YouTube channel</ExternalLink>
<ExternalLink href={subredditUrl}>Subreddit</ExternalLink>
</Panel.Section> </Panel.Section>
</> </>
); );
@@ -1,7 +0,0 @@
.updateIndicator {
width: 0.5em;
height: 0.5em;
flex: 0 0 auto;
border-radius: 99px;
background-color: $red-400;
}
@@ -1,47 +1,35 @@
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink'; import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
import useAppVersion from '../../../../common/hooks-query/useAppVersion'; import useAppVersion from '../../../../common/hooks-query/useAppVersion';
import { appVersion, isOntimeCloud, websiteUrl } from '../../../../externals'; import { appVersion, isOntimeCloud } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import style from './AppVersion.module.scss';
export default function AppVersion() { export default function AppVersion() {
const { data, isError } = useAppVersion(); const { data, isError } = useAppVersion();
if (isError) { if (isError) {
return ( return (
<Panel.ListItem> <Panel.Paragraph>
<Panel.Field title={`Ontime ${appVersion}`} description='' error='Could not fetch version information' /> {`You are currently using Ontime version ${appVersion}`}
</Panel.ListItem> <Panel.Error>Could not fetch version information</Panel.Error>
</Panel.Paragraph>
); );
} }
if (data.hasUpdates) { if (data.hasUpdates) {
return ( return (
<Panel.ListItem> <Panel.Paragraph>
<Panel.Field {`You are currently using Ontime version ${appVersion}.`}
title={ <br />
<> <br />
<span className={style.updateIndicator} aria-hidden='true' /> {`A new version ${data.version} is available.`} <br />
{`Ontime ${appVersion}`} {isOntimeCloud ? (
</> 'You can restart your stage to get the latest available version.'
} ) : (
description={ <ExternalLink href={data.url}>Please visit the release page to download</ExternalLink>
isOntimeCloud
? `Version ${data.version} is available. Restart your stage to update.`
: `Version ${data.version} is available.`
}
/>
{!isOntimeCloud && (
<ExternalLink href={websiteUrl}>Download the latest version from Ontime's page</ExternalLink>
)} )}
</Panel.ListItem> </Panel.Paragraph>
); );
} }
return ( return <Panel.Paragraph>{`You are currently using the latest version of Ontime: ${appVersion}`}</Panel.Paragraph>;
<Panel.ListItem>
<Panel.Field title={`Ontime ${appVersion}`} description='You are using the latest version.' />
</Panel.ListItem>
);
} }
@@ -1,74 +0,0 @@
.card {
padding: 1.5rem;
border-color: rgba($action-blue, 0.42);
background-color: rgba($action-blue, 0.06);
}
.eyebrow {
color: $action-text-color;
font-size: calc(1rem - 3px);
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.title {
margin-top: 0.35rem;
font-size: 1.5rem;
font-weight: 600;
line-height: 1.15;
}
.description {
display: flex;
flex-direction: column;
gap: 0.75rem;
max-width: 33rem;
margin-top: 0.9rem;
color: $gray-300;
line-height: 1.5;
}
.portability {
padding-top: 0.75rem;
border-top: 1px solid $white-10;
color: $gray-400;
font-size: calc(1rem - 2px);
}
.benefits {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem 1rem;
margin-top: 1.5rem;
padding: 0;
list-style: none;
li {
display: flex;
align-items: flex-start;
gap: 0.5rem;
line-height: 1.4;
}
svg {
flex: 0 0 auto;
margin-top: 0.1rem;
color: $action-text-color;
font-size: 1.1rem;
}
}
.actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 1rem;
margin-top: 1.5rem;
}
@media (max-width: $min-tablet) {
.benefits {
grid-template-columns: 1fr;
}
}
@@ -1,52 +0,0 @@
import { IoCheckmarkCircle } from 'react-icons/io5';
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
import { isOntimeCloud, websiteUrl } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils';
import style from './CloudPanel.module.scss';
export default function CloudPanel() {
if (isOntimeCloud) {
return null;
}
return (
<Panel.Section>
<Panel.Card className={style.card}>
<span className={style.eyebrow}>Ontime Cloud</span>
<h3 className={style.title}>Run Ontime online with zero setup</h3>
<p className={style.description}>
<span>
Ontime Cloud lets you run and share Ontime online with your team. Subscriptions support continued
development while Ontime remains fully open-source and self-hostable.
</span>
<span className={style.portability}>
Project files work locally and in Cloud, so your shows can move freely.
</span>
</p>
<ul className={style.benefits}>
<li>
<IoCheckmarkCircle />
Start in minutes, with no setup or maintenance
</li>
<li>
<IoCheckmarkCircle />
Coordinate multi-track rundowns with Overview views
</li>
<li>
<IoCheckmarkCircle />
Manage every instance from one dashboard
</li>
<li>
<IoCheckmarkCircle />
Share secure read-only views with collaborators
</li>
</ul>
<div className={style.actions}>
<ExternalLink href={websiteUrl}>Learn more about Ontime Cloud</ExternalLink>
</div>
</Panel.Card>
</Panel.Section>
);
}
@@ -1,15 +0,0 @@
.link {
width: calc(100% + (var(--panel-card-padding, 2rem) * 2));
margin: -0.75rem calc(var(--panel-card-padding, 2rem) * -1);
padding: 0.75rem var(--panel-card-padding, 2rem);
> :first-child {
flex: 1;
min-width: 0;
}
}
.action {
color: $action-text-color;
font-size: calc(1rem - 2px);
}
@@ -1,21 +0,0 @@
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
import * as Panel from '../../panel-utils/PanelUtils';
import style from './ExternalLinkRow.module.scss';
interface ExternalLinkRowProps {
href: string;
title: string;
description: string;
}
export default function ExternalLinkRow({ href, title, description }: ExternalLinkRowProps) {
return (
<Panel.ListItem interactive>
<ExternalLink href={href} className={style.link}>
<Panel.Field title={title} description={description} />
<span className={style.action}>Open</span>
</ExternalLink>
</Panel.ListItem>
);
}
@@ -1,13 +1,6 @@
.outerColumn { .outerColumn {
display: flex;
flex-direction: column;
gap: 2rem; gap: 2rem;
font-size: calc(1rem - 1px); margin-bottom: 2rem;
color: $ui-white;
// the shared modal body owns scrolling for this regular form modal
min-height: 100%;
padding-block: 0.5rem;
h3 { h3 {
font-size: 1rem; font-size: 1rem;
@@ -19,12 +19,12 @@ import IconButton from '../../../../common/components/buttons/IconButton';
import Info from '../../../../common/components/info/Info'; import Info from '../../../../common/components/info/Info';
import Input from '../../../../common/components/input/input/Input'; import Input from '../../../../common/components/input/input/Input';
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink'; import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
import Modal from '../../../../common/components/modal/Modal';
import RadioGroup from '../../../../common/components/radio-group/RadioGroup'; import RadioGroup from '../../../../common/components/radio-group/RadioGroup';
import Select from '../../../../common/components/select/Select'; import Select from '../../../../common/components/select/Select';
import Tag from '../../../../common/components/tag/Tag'; import Tag from '../../../../common/components/tag/Tag';
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings'; import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
import useCustomFields from '../../../../common/hooks-query/useCustomFields'; import useCustomFields from '../../../../common/hooks-query/useCustomFields';
import { preventEscape } from '../../../../common/utils/keyEvent';
import { startsWithHttp } from '../../../../common/utils/regex'; import { startsWithHttp } from '../../../../common/utils/regex';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import { isAutomation, makeFieldList } from './automationUtils'; import { isAutomation, makeFieldList } from './automationUtils';
@@ -34,7 +34,6 @@ import TemplateInput from './template-input/TemplateInput';
import style from './AutomationForm.module.scss'; import style from './AutomationForm.module.scss';
const integrationsDocsUrl = 'https://docs.getontime.no/api/automation/#using-variables-in-automation'; const integrationsDocsUrl = 'https://docs.getontime.no/api/automation/#using-variables-in-automation';
const formId = 'automation-form';
interface AutomationFormProps { interface AutomationFormProps {
automation: Automation | AutomationDTO; automation: Automation | AutomationDTO;
@@ -186,320 +185,284 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
const canSubmit = !isSubmitting && isDirty && isValid; const canSubmit = !isSubmitting && isDirty && isValid;
return ( return (
<Modal <Panel.Indent
isOpen as='form'
onClose={onClose} name='automation-form'
showBackdrop onSubmit={handleSubmit(onSubmit)}
showCloseButton className={style.outerColumn}
title={isEdit ? 'Edit automation' : 'Create automation'} onKeyDown={(event) => preventEscape(event, onClose)}
bodyElements={ >
<form id={formId} onSubmit={handleSubmit(onSubmit)} className={style.outerColumn}> <Panel.SubHeader>{isEdit ? 'Edit automation' : 'Create automation'}</Panel.SubHeader>
<div className={style.innerColumn}> <div className={style.innerSection}>
<h3>Automation options</h3> <h3>Automation options</h3>
<div className={style.titleSection}> <div className={style.titleSection}>
<label> <label>
Title Title
<Input <Input
{...register('title', { required: { value: true, message: 'Required field' } })} {...register('title', { required: { value: true, message: 'Required field' } })}
fluid fluid
placeholder='Load preset' placeholder='Load preset'
/> />
</label> </label>
<Panel.Error>{errors.title?.message}</Panel.Error> <Panel.Error>{errors.title?.message}</Panel.Error>
</div> </div>
</div> </div>
<div className={style.innerColumn}> <div className={style.innerSection}>
<h3>Filters (optional)</h3> <h3>Filters (optional)</h3>
<div className={style.ruleSection}> <div className={style.ruleSection}>
<label> <label>
Trigger outputs if Trigger outputs if
<RadioGroup <RadioGroup
orientation='horizontal' orientation='horizontal'
value={watch('filterRule')} value={watch('filterRule')}
onValueChange={(value) => setValue('filterRule', value, { shouldDirty: true })} onValueChange={(value) => setValue('filterRule', value, { shouldDirty: true })}
items={[ items={[
{ value: 'all', label: 'All filters pass' }, { value: 'all', label: 'All filters pass' },
{ value: 'any', label: 'Any filter passes' }, { value: 'any', label: 'Any filter passes' },
]} ]}
/> />
</label> </label>
{fieldFilters.map((field, index) => { {fieldFilters.map((field, index) => {
const key = `filters.${index}.field.${field.id}`; const key = `filters.${index}.field.${field.id}`;
return ( return (
<div key={key} className={style.filterSection}> <div key={key} className={style.filterSection}>
<label> <label>
Runtime data source Runtime data source
<Select<string | null> <Select<string | null>
// need to normalize '' to null for the Select to show the placeholder // need to normalize '' to null for the Select to show the placeholder
value={watch(`filters.${index}.field`) || null} value={watch(`filters.${index}.field`) || null}
onValueChange={(value) => { onValueChange={(value) => {
if (value === null) return; if (value === null) return;
setValue(`filters.${index}.field`, value, { shouldDirty: true }); setValue(`filters.${index}.field`, value, { shouldDirty: true });
}} }}
options={fieldList.map(({ value, label }) => ({ options={fieldList.map(({ value, label }) => ({
value, value,
label, label,
disabled: value === null, disabled: value === null,
}))} }))}
aria-label='Event field' aria-label='Event field'
/> />
<Panel.Error>{errors.filters?.[index]?.field?.message}</Panel.Error> <Panel.Error>{errors.filters?.[index]?.field?.message}</Panel.Error>
</label> </label>
<label> <label>
Matching condition Matching condition
<Select <Select
value={watch(`filters.${index}.operator`)} value={watch(`filters.${index}.operator`)}
onValueChange={(value: string | null) => { onValueChange={(value: string | null) => {
if (value === null) return; if (value === null) return;
setValue( setValue(
`filters.${index}.operator`, `filters.${index}.operator`,
value as value as 'equals' | 'not_equals' | 'greater_than' | 'less_than' | 'contains' | 'not_contains',
| 'equals' { shouldDirty: true },
| 'not_equals' );
| 'greater_than' }}
| 'less_than' options={[
| 'contains' { value: 'equals', label: 'equals' },
| 'not_contains', { value: 'not_equals', label: 'not equals' },
{ shouldDirty: true }, { value: 'contains', label: 'contains' },
); ]}
}} aria-label='Operator'
options={[ />
{ value: 'equals', label: 'equals' }, <Panel.Error>{errors.filters?.[index]?.operator?.message}</Panel.Error>
{ value: 'not_equals', label: 'not equals' }, </label>
{ value: 'contains', label: 'contains' }, <label>
]} Value to match
aria-label='Operator' <Input {...register(`filters.${index}.value`)} fluid placeholder='<empty / no value>' />
/> </label>
<Panel.Error>{errors.filters?.[index]?.operator?.message}</Panel.Error> <div>
</label> <span>&nbsp;</span>
<label> <div>
Value to match <IconButton aria-label='Delete' variant='ghosted-destructive' onClick={() => removeFilter(index)}>
<Input {...register(`filters.${index}.value`)} fluid placeholder='<empty / no value>' /> <IoTrash />
</label> </IconButton>
<div>
<span>&nbsp;</span>
<div>
<IconButton
aria-label='Delete'
variant='ghosted-destructive'
onClick={() => removeFilter(index)}
>
<IoTrash />
</IconButton>
</div>
</div>
</div> </div>
); </div>
})}
<div>
<Button onClick={handleAddNewFilter}>
Add filter <IoAdd />
</Button>
</div> </div>
</div> );
})}
<div>
<Button onClick={handleAddNewFilter}>
Add filter <IoAdd />
</Button>
</div> </div>
</div>
</div>
<div className={style.innerColumn}> <div className={style.innerColumn}>
<h3>Outputs</h3> <h3>Outputs</h3>
<Info> <Info>
Automation outputs can be used to send data from Ontime to external software <br /> Automation outputs can be used to send data from Ontime to external software <br />
or to change properties of Ontime itself. <br /> <br /> or to change properties of Ontime itself.
Use Ontime runtime data in these fields with template strings. Type {'{{'} to see autocomplete, or{' '} <ExternalLink href={integrationsDocsUrl}>See the documentation for templates</ExternalLink>
<ExternalLink href={integrationsDocsUrl}>read the docs</ExternalLink> </Info>
</Info>
{fieldOutputs.map((output, index) => { {fieldOutputs.map((output, index) => {
if (isOSCOutput(output)) { if (isOSCOutput(output)) {
const rowErrors = errors.outputs?.[index] as const rowErrors = errors.outputs?.[index] as
| { | {
targetIP?: { message?: string }; targetIP?: { message?: string };
targetPort?: { message?: string }; targetPort?: { message?: string };
address?: { message?: string }; address?: { message?: string };
args?: { message?: string }; args?: { message?: string };
} }
| undefined; | undefined;
return ( return (
<div key={output.id} className={style.outputCard}> <div key={output.id} className={style.outputCard}>
<Tag>OSC</Tag> <Tag>OSC</Tag>
<div className={style.oscSection}> <div className={style.oscSection}>
<label> <label>
Target IP Target IP
<Input <Input
{...register(`outputs.${index}.targetIP`, { {...register(`outputs.${index}.targetIP`, {
required: { value: true, message: 'Required field' }, required: { value: true, message: 'Required field' },
})} })}
fluid fluid
placeholder='127.0.0.1' placeholder='127.0.0.1'
/> />
<Panel.Error>{rowErrors?.targetIP?.message}</Panel.Error> <Panel.Error>{rowErrors?.targetIP?.message}</Panel.Error>
</label> </label>
<label> <label>
Target Port Target Port
<Input <Input
{...register(`outputs.${index}.targetPort`, { {...register(`outputs.${index}.targetPort`, {
required: { value: true, message: 'Required field' }, required: { value: true, message: 'Required field' },
setValueAs: (value) => (value === '' ? 0 : Number(value)), setValueAs: (value) => (value === '' ? 0 : Number(value)),
max: { value: 65535, message: 'Port must be within range 1024 - 65535' }, max: { value: 65535, message: 'Port must be within range 1024 - 65535' },
min: { value: 1024, message: 'Port must be within range 1024 - 65535' }, min: { value: 1024, message: 'Port must be within range 1024 - 65535' },
})} })}
fluid fluid
type='number' type='number'
maxLength={5} maxLength={5}
placeholder='8000' placeholder='8000'
/> />
<Panel.Error>{rowErrors?.targetPort?.message}</Panel.Error> <Panel.Error>{rowErrors?.targetPort?.message}</Panel.Error>
</label> </label>
<label> <label>
Address Address
<TemplateInput <Input {...register(`outputs.${index}.address`)} fluid placeholder='/cue/start' />
{...register(`outputs.${index}.address`)} <Panel.Error>{rowErrors?.address?.message}</Panel.Error>
value={output.address} </label>
fluid <label>
placeholder='/cue/start' Arguments
/> <TemplateInput {...register(`outputs.${index}.args`)} value={output.args} placeholder='1' />
<Panel.Error>{rowErrors?.address?.message}</Panel.Error> <Panel.Error>{rowErrors?.args?.message}</Panel.Error>
</label> </label>
<label> <div>
Arguments <span>&nbsp;</span>
<TemplateInput <Panel.InlineElements relation='inner'>
{...register(`outputs.${index}.args`)} <Button variant='ghosted-white' onClick={() => handleTestOSCOutput(index)}>
value={output.args} Test
fluid </Button>
placeholder='1' <IconButton aria-label='Delete' variant='ghosted-destructive' onClick={() => removeOutput(index)}>
/> <IoTrash />
<Panel.Error>{rowErrors?.args?.message}</Panel.Error> </IconButton>
</label> </Panel.InlineElements>
<div>
<span>&nbsp;</span>
<Panel.InlineElements relation='inner'>
<Button variant='ghosted-white' onClick={() => handleTestOSCOutput(index)}>
Test
</Button>
<IconButton
aria-label='Delete'
variant='ghosted-destructive'
onClick={() => removeOutput(index)}
>
<IoTrash />
</IconButton>
</Panel.InlineElements>
</div>
</div>
</div> </div>
); </div>
} </div>
if (isHTTPOutput(output)) { );
const rowErrors = errors.outputs?.[index] as }
| { if (isHTTPOutput(output)) {
url?: { message?: string }; const rowErrors = errors.outputs?.[index] as
} | {
| undefined; url?: { message?: string };
return ( }
<div key={output.id} className={style.outputCard}> | undefined;
<Tag>HTTP</Tag> return (
<div className={style.httpSection}> <div key={output.id} className={style.outputCard}>
<label> <Tag>HTTP</Tag>
Target URL <div className={style.httpSection}>
<TemplateInput <label>
{...register(`outputs.${index}.url`, { Target URL
required: { value: true, message: 'Required field' }, <Input
pattern: { {...register(`outputs.${index}.url`, {
value: startsWithHttp, required: { value: true, message: 'Required field' },
message: 'HTTP messages should target http:// or https://', pattern: {
}, value: startsWithHttp,
})} message: 'HTTP messages should target http:// or https://',
value={output.url} },
fluid })}
placeholder='http://127.0.0.1/start/1' fluid
/> placeholder='http://127.0.0.1/start/1'
<Panel.Error>{rowErrors?.url?.message}</Panel.Error> />
</label> <Panel.Error>{rowErrors?.url?.message}</Panel.Error>
<div> </label>
<span>&nbsp;</span> <div>
<Panel.InlineElements relation='inner'> <span>&nbsp;</span>
<Button variant='ghosted-white' onClick={() => handleTestHTTPOutput(index)}> <Panel.InlineElements relation='inner'>
Test <Button variant='ghosted-white' onClick={() => handleTestHTTPOutput(index)}>
</Button> Test
<IconButton </Button>
aria-label='Delete' <IconButton aria-label='Delete' variant='ghosted-destructive' onClick={() => removeOutput(index)}>
variant='ghosted-destructive' <IoTrash />
onClick={() => removeOutput(index)} </IconButton>
> </Panel.InlineElements>
<IoTrash />
</IconButton>
</Panel.InlineElements>
</div>
</div>
</div> </div>
); </div>
} </div>
);
}
if (isOntimeAction(output)) { if (isOntimeAction(output)) {
const rowErrors = errors.outputs?.[index] as const rowErrors = errors.outputs?.[index] as
| { | {
action?: { message?: string }; action?: { message?: string };
time?: { message?: string }; time?: { message?: string };
text?: { message?: string }; text?: { message?: string };
visible?: { message?: string }; visible?: { message?: string };
secondarySource?: { message?: string }; secondarySource?: { message?: string };
} }
| undefined; | undefined;
return ( return (
<div key={output.id} className={style.outputCard}> <div key={output.id} className={style.outputCard}>
<Tag>Ontime action</Tag> <Tag>Ontime action</Tag>
<OntimeActionForm <OntimeActionForm
value={output.action} value={output.action}
index={index} index={index}
register={register} register={register}
rowErrors={rowErrors} rowErrors={rowErrors}
setValue={setValue} setValue={setValue}
watch={watch} watch={watch}
> >
<span>&nbsp;</span> <span>&nbsp;</span>
<Panel.InlineElements relation='inner'> <Panel.InlineElements relation='inner'>
<Button variant='ghosted-white' onClick={() => handleTestOntimeAction(index)}> <Button variant='ghosted-white' onClick={() => handleTestOntimeAction(index)}>
Test Test
</Button> </Button>
<IconButton <IconButton aria-label='Delete' variant='ghosted-destructive' onClick={() => removeOutput(index)}>
aria-label='Delete' <IoTrash />
variant='ghosted-destructive' </IconButton>
onClick={() => removeOutput(index)} </Panel.InlineElements>
> </OntimeActionForm>
<IoTrash /> </div>
</IconButton> );
</Panel.InlineElements> }
</OntimeActionForm>
</div>
);
}
return null; // there should be no other output types
})} return null;
<Panel.InlineElements relation='inner'> })}
<Button onClick={handleAddNewOSCOutput}> <Panel.InlineElements relation='inner'>
OSC <IoAdd /> <Button onClick={handleAddNewOSCOutput}>
</Button> OSC <IoAdd />
<Button onClick={handleAddNewHTTPOutput}>
HTTP <IoAdd />
</Button>
<Button onClick={handleAddnewOntimeAction}>
Ontime action <IoAdd />
</Button>
</Panel.InlineElements>
</div>
</form>
}
footerElements={
<>
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
<Button onClick={onClose}>Cancel</Button>
<Button variant='primary' type='submit' form={formId} disabled={!canSubmit} loading={isSubmitting}>
Save
</Button> </Button>
</> <Button onClick={handleAddNewHTTPOutput}>
} HTTP <IoAdd />
/> </Button>
<Button onClick={handleAddnewOntimeAction}>
Ontime action <IoAdd />
</Button>
</Panel.InlineElements>
</div>
<Panel.InlineElements align='end'>
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
<Button onClick={onClose}>Cancel</Button>
<Button variant='primary' type='submit' disabled={!canSubmit} loading={isSubmitting}>
Save
</Button>
</Panel.InlineElements>
</Panel.Indent>
); );
} }
@@ -19,27 +19,24 @@ export default function AutomationPanel({ location }: PanelBaseProps) {
return ( return (
<> <>
<Panel.Header>Automation</Panel.Header> <Panel.Header>Automation</Panel.Header>
<div ref={settingsRef}> <Panel.Section>
<AutomationSettingsForm <Panel.Loader isLoading={isLoading} />
enabledAutomations={data.enabledAutomations} <div ref={settingsRef}>
enabledOscIn={data.enabledOscIn} <AutomationSettingsForm
oscPortIn={data.oscPortIn} enabledAutomations={data.enabledAutomations}
automationState={automationState} enabledOscIn={data.enabledOscIn}
oscInputState={oscInputState} oscPortIn={data.oscPortIn}
isLoading={isLoading} automationState={automationState}
/> oscInputState={oscInputState}
</div> />
<div ref={automationsRef}> </div>
<AutomationsList automations={data.automations} enabledAutomations={automationState} isLoading={isLoading} /> <div ref={automationsRef}>
</div> <AutomationsList automations={data.automations} enabledAutomations={automationState} />
<div ref={triggersRef}> </div>
<TriggersList <div ref={triggersRef}>
triggers={data.triggers} <TriggersList triggers={data.triggers} automations={data.automations} enabledAutomations={automationState} />
automations={data.automations} </div>
enabledAutomations={automationState} </Panel.Section>
isLoading={isLoading}
/>
</div>
</> </>
); );
} }
@@ -21,7 +21,6 @@ interface AutomationSettingsProps {
oscPortIn: number; oscPortIn: number;
automationState?: boolean; automationState?: boolean;
oscInputState?: boolean; oscInputState?: boolean;
isLoading?: boolean;
} }
export default function AutomationSettingsForm({ export default function AutomationSettingsForm({
@@ -30,7 +29,6 @@ export default function AutomationSettingsForm({
oscPortIn, oscPortIn,
automationState, automationState,
oscInputState, oscInputState,
isLoading,
}: AutomationSettingsProps) { }: AutomationSettingsProps) {
const { const {
handleSubmit, handleSubmit,
@@ -67,129 +65,126 @@ export default function AutomationSettingsForm({
const oscInputEnabled = watch('enabledOscIn'); const oscInputEnabled = watch('enabledOscIn');
return ( return (
<Panel.Section> <Panel.Card>
<Panel.Card> <Panel.SubHeader>
<Panel.Loader isLoading={Boolean(isLoading)} /> Automation settings
<Panel.SubHeader> <Panel.InlineElements>
Automation settings <Button variant='ghosted' onClick={onReset} disabled={!canSubmit}>
<Panel.InlineElements> Revert to saved
<Button variant='ghosted' onClick={onReset} disabled={!canSubmit}> </Button>
Revert to saved <Button
</Button> variant='primary'
<Button type='submit'
variant='primary' form='automation-settings-form'
type='submit' disabled={!canSubmit}
form='automation-settings-form' loading={isSubmitting}
disabled={!canSubmit} >
loading={isSubmitting} Save
> </Button>
Save </Panel.InlineElements>
</Button> </Panel.SubHeader>
</Panel.InlineElements> {errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
</Panel.SubHeader>
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
<Panel.Divider /> <Panel.Divider />
<Panel.Section> <Panel.Section>
<Info> <Info>
<span>Control Ontime and share its data with external systems in your workflow.</span> <span>Control Ontime and share its data with external systems in your workflow.</span>
<span>- Automations allow Ontime to send its data on lifecycle triggers.</span> <span>- Automations allow Ontime to send its data on lifecycle triggers.</span>
<span>- OSC Input tells Ontime to listen to messages on the specific port.</span> <span>- OSC Input tells Ontime to listen to messages on the specific port.</span>
<ExternalLink href={oscApiDocsUrl}>See the docs</ExternalLink> <ExternalLink href={oscApiDocsUrl}>See the docs</ExternalLink>
</Info> </Info>
</Panel.Section> </Panel.Section>
<Panel.Section <Panel.Section
as='form' as='form'
id='automation-settings-form' id='automation-settings-form'
onSubmit={handleSubmit(onSubmit)} onSubmit={handleSubmit(onSubmit)}
onKeyDown={(event) => preventEscape(event, onReset)} onKeyDown={(event) => preventEscape(event, onReset)}
> >
<Panel.Title>Automation</Panel.Title> <Panel.Loader isLoading={false} />
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field
title={
<>
<span>Enable automations</span>
{automationState === false && <Tag variant='warning'>OFF</Tag>}
</>
}
description={
automationState === false
? 'Automations are OFF. Triggers stay configured, but Ontime will not send messages.'
: 'Allow Ontime to send messages on lifecycle triggers'
}
descriptionTone={automationState === false ? 'warning' : 'default'}
error={errors.enabledAutomations?.message}
/>
<Switch
size='large'
checked={automationsEnabled}
onCheckedChange={(value: boolean) =>
setValue('enabledAutomations', value, { shouldDirty: true, shouldValidate: true })
}
/>
</Panel.ListItem>
</Panel.ListGroup>
<Panel.Title>OSC Input</Panel.Title>
<Panel.ListGroup> <Panel.Title>Automation</Panel.Title>
{isOntimeCloud && ( <Panel.ListGroup>
<Info>For security reasons OSC integrations are not available in the cloud service.</Info> <Panel.ListItem>
)} <Panel.Field
<Panel.ListItem> title={
<Panel.Field <>
title={ <span>Enable automations</span>
<> {automationState === false && <Tag variant='warning'>OFF</Tag>}
<span>OSC input</span> </>
{oscInputState === false && <Tag variant='warning'>OFF</Tag>} }
</> description={
} automationState === false
description={ ? 'Automations are OFF. Triggers stay configured, but Ontime will not send messages.'
oscInputState === false : 'Allow Ontime to send messages on lifecycle triggers'
? 'OSC input is OFF. Ontime will not listen for incoming OSC control messages.' }
: 'Allow control of Ontime through OSC' descriptionTone={automationState === false ? 'warning' : 'default'}
} error={errors.enabledAutomations?.message}
descriptionTone={oscInputState === false ? 'warning' : 'default'} />
error={errors.enabledOscIn?.message} <Switch
/> size='large'
<Switch checked={automationsEnabled}
size='large' onCheckedChange={(value: boolean) =>
checked={oscInputEnabled} setValue('enabledAutomations', value, { shouldDirty: true, shouldValidate: true })
onCheckedChange={(value: boolean) => }
setValue('enabledOscIn', value, { shouldDirty: true, shouldValidate: true }) />
} </Panel.ListItem>
/> </Panel.ListGroup>
</Panel.ListItem> <Panel.Title>OSC Input</Panel.Title>
<Panel.ListItem>
<Panel.Field <Panel.ListGroup>
title='Listen on port' {isOntimeCloud && <Info>For security reasons OSC integrations are not available in the cloud service.</Info>}
description='Port for incoming OSC. Default: 8888' <Panel.ListItem>
error={errors.oscPortIn?.message} <Panel.Field
/> title={
<Input <>
id='oscPortIn' <span>OSC input</span>
placeholder='8888' {oscInputState === false && <Tag variant='warning'>OFF</Tag>}
maxLength={5} </>
style={{ textAlign: 'right', width: '5rem' }} }
type='number' description={
fluid oscInputState === false
{...register('oscPortIn', { ? 'OSC input is OFF. Ontime will not listen for incoming OSC control messages.'
required: { value: true, message: 'Required field' }, : 'Allow control of Ontime through OSC'
max: { value: 65535, message: 'Port must be within range 1024 - 65535' }, }
min: { value: 1024, message: 'Port must be within range 1024 - 65535' }, descriptionTone={oscInputState === false ? 'warning' : 'default'}
pattern: { error={errors.enabledOscIn?.message}
value: isOnlyNumbers, />
message: 'Value should be numeric', <Switch
}, size='large'
})} checked={oscInputEnabled}
/> onCheckedChange={(value: boolean) =>
</Panel.ListItem> setValue('enabledOscIn', value, { shouldDirty: true, shouldValidate: true })
</Panel.ListGroup> }
</Panel.Section> />
</Panel.Card> </Panel.ListItem>
</Panel.Section> <Panel.ListItem>
<Panel.Field
title='Listen on port'
description='Port for incoming OSC. Default: 8888'
error={errors.oscPortIn?.message}
/>
<Input
id='oscPortIn'
placeholder='8888'
maxLength={5}
style={{ textAlign: 'right', width: '5rem' }}
type='number'
fluid
{...register('oscPortIn', {
required: { value: true, message: 'Required field' },
max: { value: 65535, message: 'Port must be within range 1024 - 65535' },
min: { value: 1024, message: 'Port must be within range 1024 - 65535' },
pattern: {
value: isOnlyNumbers,
message: 'Value should be numeric',
},
})}
/>
</Panel.ListItem>
</Panel.ListGroup>
</Panel.Section>
</Panel.Card>
); );
} }
@@ -22,10 +22,10 @@ const automationPlaceholder: AutomationDTO = {
interface AutomationsListProps { interface AutomationsListProps {
automations: NormalisedAutomation; automations: NormalisedAutomation;
enabledAutomations?: boolean; enabledAutomations?: boolean;
isLoading: boolean;
} }
export default function AutomationsList({ automations, enabledAutomations, isLoading }: AutomationsListProps) { export default function AutomationsList(props: AutomationsListProps) {
const { automations, enabledAutomations } = props;
const { refetch } = useAutomationSettings(); const { refetch } = useAutomationSettings();
const [automationFormData, setAutomationFormData] = useState<AutomationDTO | null>(null); const [automationFormData, setAutomationFormData] = useState<AutomationDTO | null>(null);
const [deleteError, setDeleteError] = useState<string | null>(null); const [deleteError, setDeleteError] = useState<string | null>(null);
@@ -44,94 +44,89 @@ export default function AutomationsList({ automations, enabledAutomations, isLoa
const arrayAutomations = Object.keys(automations); const arrayAutomations = Object.keys(automations);
return ( return (
<Panel.Section> <Panel.Card>
<Panel.Card> <Panel.SubHeader>
{automationFormData !== null && ( Manage automations
<AutomationForm automation={automationFormData} onClose={() => setAutomationFormData(null)} /> <Button
)} type='submit'
<Panel.SubHeader> disabled={Boolean(automationFormData)}
Manage automations onClick={() => setAutomationFormData(automationPlaceholder)}
<Button onClick={() => setAutomationFormData(automationPlaceholder)}> >
New <IoAdd /> New <IoAdd />
</Button> </Button>
</Panel.SubHeader> </Panel.SubHeader>
<Panel.Divider /> <Panel.Divider />
<Panel.Section> {enabledAutomations === false && (
{enabledAutomations === false && ( <Info>
<Info> Automations are disabled. You can still manage automation definitions here, but they will not run until
Automations are disabled. You can still manage automation definitions here, but they will not run until enabled.
enabled. </Info>
</Info> )}
{automationFormData !== null && (
<AutomationForm automation={automationFormData} onClose={() => setAutomationFormData(null)} />
)}
<Panel.Table>
<thead>
<tr>
<th style={{ width: '45%' }}>Title</th>
<th style={{ width: '15%' }}>Trigger rule</th>
<th style={{ width: '15%' }}>Filters</th>
<th style={{ width: '15%' }}>Outputs</th>
<th />
</tr>
</thead>
<tbody>
{arrayAutomations.length === 0 && (
<Panel.TableEmpty
handleClick={!automationFormData ? () => setAutomationFormData(automationPlaceholder) : undefined}
/>
)} )}
{arrayAutomations.map((automationId) => {
<Panel.Table> if (!Object.hasOwn(automations, automationId)) {
<thead> return null;
<tr> }
<th style={{ width: '45%' }}>Title</th> return (
<th style={{ width: '15%' }}>Trigger rule</th> <Fragment key={automationId}>
<th style={{ width: '15%' }}>Filters</th>
<th style={{ width: '15%' }}>Outputs</th>
<th />
</tr>
</thead>
<tbody>
{!isLoading && arrayAutomations.length === 0 && (
<Panel.TableEmpty
title='No automations yet'
description='An automation sends OSC or HTTP messages, or runs an Ontime action, whenever a trigger fires.'
action={
<Button variant='primary' onClick={() => setAutomationFormData(automationPlaceholder)}>
Create automation <IoAdd />
</Button>
}
/>
)}
{arrayAutomations.map((automationId) => {
if (!Object.hasOwn(automations, automationId)) {
return null;
}
return (
<Fragment key={automationId}>
<tr>
<td>{automations[automationId].title}</td>
<td>
<Tag>{automations[automationId].filterRule}</Tag>
</td>
<td>{automations[automationId].filters.length}</td>
<td>{automations[automationId].outputs.length}</td>
<Panel.InlineElements align='end' relation='inner' as='td'>
<IconButton
variant='ghosted-white'
aria-label='Edit entry'
onClick={() => setAutomationFormData(automations[automationId])}
>
<IoPencil />
</IconButton>
<IconButton
variant='ghosted-destructive'
aria-label='Delete entry'
onClick={() => handleDelete(automationId)}
>
<IoTrash />
</IconButton>
</Panel.InlineElements>
</tr>
</Fragment>
);
})}
{deleteError && (
<tr> <tr>
<td colSpan={5}> <td>{automations[automationId].title}</td>
<Panel.Error>{deleteError}</Panel.Error> <td>
<Tag>{automations[automationId].filterRule}</Tag>
</td> </td>
<td>{automations[automationId].filters.length}</td>
<td>{automations[automationId].outputs.length}</td>
<Panel.InlineElements align='end' relation='inner' as='td'>
<IconButton
variant='ghosted-white'
aria-label='Edit entry'
onClick={() => setAutomationFormData(automations[automationId])}
>
<IoPencil />
</IconButton>
<IconButton
variant='ghosted-destructive'
aria-label='Delete entry'
onClick={() => handleDelete(automationId)}
>
<IoTrash />
</IconButton>
</Panel.InlineElements>
</tr> </tr>
)} </Fragment>
</tbody> );
</Panel.Table> })}
</Panel.Section> {deleteError && (
</Panel.Card> <tr>
</Panel.Section> <td colSpan={5}>
<Panel.Error>{deleteError}</Panel.Error>
</td>
</tr>
)}
</tbody>
</Panel.Table>
</Panel.Card>
); );
} }
@@ -5,7 +5,6 @@ import { UseFormRegister, UseFormSetValue, UseFormWatch } from 'react-hook-form'
import Input from '../../../../common/components/input/input/Input'; import Input from '../../../../common/components/input/input/Input';
import Select from '../../../../common/components/select/Select'; import Select from '../../../../common/components/select/Select';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import TemplateInput from './template-input/TemplateInput';
import style from './AutomationForm.module.scss'; import style from './AutomationForm.module.scss';
@@ -72,8 +71,8 @@ export default function OntimeActionForm({
{ value: 'playback-pause', label: 'Playback: pause' }, { value: 'playback-pause', label: 'Playback: pause' },
{ value: 'playback-roll', label: 'Playback: roll' }, { value: 'playback-roll', label: 'Playback: roll' },
{ value: 'message-set', label: 'Primary Message' }, { value: 'message-set', label: 'Primary Message: set' },
{ value: 'message-secondary', label: 'Secondary Message' }, { value: 'message-secondary', label: 'Secondary Message: source' },
]} ]}
/> />
<Panel.Error>{rowErrors?.action?.message}</Panel.Error> <Panel.Error>{rowErrors?.action?.message}</Panel.Error>
@@ -97,12 +96,7 @@ export default function OntimeActionForm({
<> <>
<label> <label>
Text (leave empty for no change) Text (leave empty for no change)
<TemplateInput <Input {...register(`outputs.${index}.text`)} fluid placeholder='eg: Timer is finished' />
{...register(`outputs.${index}.text`)}
value={watch(`outputs.${index}.text`) ?? ''}
fluid
placeholder='eg: Timer is finished'
/>
<Panel.Error>{rowErrors?.text?.message}</Panel.Error> <Panel.Error>{rowErrors?.text?.message}</Panel.Error>
</label> </label>
<label> <label>
@@ -126,48 +120,31 @@ export default function OntimeActionForm({
)} )}
{selectedAction === 'message-secondary' && ( {selectedAction === 'message-secondary' && (
<> <label>
<label> Timer secondary source
Text (leave empty for no change) <Select<SecondarySource | 'null' | null>
<TemplateInput onValueChange={(value) => {
{...register(`outputs.${index}.text`)} // null -> no selection
value={watch(`outputs.${index}.text`) ?? ''} if (value === null) return;
fluid // 'null' -> clear the secondary source
placeholder='eg: Next up: keynote' if (value === 'null') {
/> setValue(`outputs.${index}.secondarySource`, null, { shouldDirty: true });
<Panel.Error>{rowErrors?.text?.message}</Panel.Error> return;
</label> }
<label> setValue(`outputs.${index}.secondarySource`, value, { shouldDirty: true });
Timer secondary source }}
<Select<SecondarySource | 'no-change' | 'null' | null> value={watch(`outputs.${index}.secondarySource`)}
onValueChange={(value) => { options={[
// null -> no selection { value: null, label: 'Select secondary source' },
if (value === null) return; { value: 'aux1', label: 'Auxiliary timer 1' },
// no-change -> leave the current secondary source untouched { value: 'aux2', label: 'Auxiliary timer 2' },
if (value === 'no-change') { { value: 'aux3', label: 'Auxiliary timer 3' },
setValue(`outputs.${index}.secondarySource`, undefined, { shouldDirty: true }); { value: 'secondary', label: 'Secondary' },
return; { value: 'null', label: 'None' }, // allow the user to clear the secondary source
} ]}
// 'null' -> clear the secondary source />
if (value === 'null') { <Panel.Error>{rowErrors?.secondarySource?.message}</Panel.Error>
setValue(`outputs.${index}.secondarySource`, null, { shouldDirty: true }); </label>
return;
}
setValue(`outputs.${index}.secondarySource`, value, { shouldDirty: true });
}}
value={watch(`outputs.${index}.secondarySource`) ?? 'no-change'}
options={[
{ value: 'no-change', label: 'No change' },
{ value: 'aux1', label: 'Auxiliary timer 1' },
{ value: 'aux2', label: 'Auxiliary timer 2' },
{ value: 'aux3', label: 'Auxiliary timer 3' },
{ value: 'secondary', label: 'Secondary' },
{ value: 'null', label: 'None' }, // allow the user to clear the secondary source
]}
/>
<Panel.Error>{rowErrors?.secondarySource?.message}</Panel.Error>
</label>
</>
)} )}
<div className={style.test}>{children}</div> <div className={style.test}>{children}</div>
@@ -1,4 +1,4 @@
import { NormalisedAutomation, TimerLifeCycle, Trigger, TriggerDTO } from 'ontime-types'; import { NormalisedAutomation, TimerLifeCycle, TriggerDTO } from 'ontime-types';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
@@ -6,21 +6,30 @@ import { addTrigger, editTrigger } from '../../../../common/api/automation';
import { maybeAxiosError } from '../../../../common/api/utils'; import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button'; import Button from '../../../../common/components/buttons/Button';
import Input from '../../../../common/components/input/input/Input'; import Input from '../../../../common/components/input/input/Input';
import Modal from '../../../../common/components/modal/Modal';
import Select from '../../../../common/components/select/Select'; import Select from '../../../../common/components/select/Select';
import { preventEscape } from '../../../../common/utils/keyEvent';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import { cycles } from './automationUtils'; import { cycles } from './automationUtils';
const formId = 'trigger-form';
interface TriggerFormProps { interface TriggerFormProps {
automations: NormalisedAutomation; automations: NormalisedAutomation;
trigger: Trigger | null; initialId?: string;
initialTitle?: string;
initialAutomationId?: string;
initialTrigger?: TimerLifeCycle;
onCancel: () => void; onCancel: () => void;
postSubmit: () => void; postSubmit: () => void;
} }
export default function TriggerForm({ automations, trigger, onCancel, postSubmit }: TriggerFormProps) { export default function TriggerForm({
automations,
initialId,
initialTitle,
initialAutomationId,
initialTrigger,
onCancel,
postSubmit,
}: TriggerFormProps) {
const { const {
handleSubmit, handleSubmit,
register, register,
@@ -31,9 +40,9 @@ export default function TriggerForm({ automations, trigger, onCancel, postSubmit
formState: { errors, isSubmitting, isValid, isDirty }, formState: { errors, isSubmitting, isValid, isDirty },
} = useForm<TriggerDTO>({ } = useForm<TriggerDTO>({
defaultValues: { defaultValues: {
title: trigger?.title, title: initialTitle,
trigger: trigger?.trigger ?? (cycles[0].value as TimerLifeCycle | undefined), trigger: initialTrigger ?? (cycles[0].value as TimerLifeCycle | undefined),
automationId: trigger?.automationId ?? automations?.[Object.keys(automations)[0]]?.id, automationId: initialAutomationId ?? automations?.[Object.keys(automations)[0]]?.id,
}, },
resetOptions: { resetOptions: {
keepDirtyValues: true, keepDirtyValues: true,
@@ -46,9 +55,10 @@ export default function TriggerForm({ automations, trigger, onCancel, postSubmit
}, [setFocus]); }, [setFocus]);
const onSubmit = async (values: TriggerDTO) => { const onSubmit = async (values: TriggerDTO) => {
if (trigger) { // if we were passed an ID we are editing a Trigger
if (initialId) {
try { try {
await editTrigger(trigger.id, { id: trigger.id, ...values }); await editTrigger(initialId, { id: initialId, ...values });
postSubmit(); postSubmit();
} catch (error) { } catch (error) {
setError('root', { message: `Failed to save changes to trigger ${maybeAxiosError(error)}` }); setError('root', { message: `Failed to save changes to trigger ${maybeAxiosError(error)}` });
@@ -75,63 +85,56 @@ export default function TriggerForm({ automations, trigger, onCancel, postSubmit
const canSubmit = isDirty && isValid; const canSubmit = isDirty && isValid;
return ( return (
<Modal <Panel.Indent
isOpen as='form'
onClose={onCancel} name='trigger-form'
showBackdrop onSubmit={handleSubmit(onSubmit)}
showCloseButton onKeyDown={(event) => preventEscape(event, onCancel)}
size='compact' >
title={trigger ? 'Edit trigger' : 'Create trigger'} <Panel.SubHeader>{initialId ? 'Edit trigger' : 'Create trigger'}</Panel.SubHeader>
bodyElements={ <label>
<form id={formId} onSubmit={handleSubmit(onSubmit)}> Title
<label> <Input
Title {...register('title', { required: { value: true, message: 'Required field' } })}
<Input fluid
{...register('title', { required: { value: true, message: 'Required field' } })} defaultValue={initialTitle}
fluid />
defaultValue={trigger?.title} <Panel.Error>{errors.title?.message}</Panel.Error>
/> </label>
<Panel.Error>{errors.title?.message}</Panel.Error> <label>
</label> Lifecycle trigger
<label> <Select
Lifecycle trigger value={watch('trigger')}
<Select onValueChange={(value) => {
value={watch('trigger')} if (value === null) return;
onValueChange={(value) => { setValue('trigger', value as TimerLifeCycle, { shouldDirty: true });
if (value === null) return; }}
setValue('trigger', value as TimerLifeCycle, { shouldDirty: true }); options={cycles.map((cycle) => ({ value: cycle.value, label: cycle.label }))}
}} aria-label='Lifecycle trigger'
options={cycles.map((cycle) => ({ value: cycle.value, label: cycle.label }))} />
aria-label='Lifecycle trigger' <Panel.Error>{errors.trigger?.message}</Panel.Error>
/> </label>
<Panel.Error>{errors.trigger?.message}</Panel.Error> <label>
</label> Automation title
<label> <Select
Automation title value={watch('automationId')}
<Select onValueChange={(value: string | null) => {
value={watch('automationId')} if (value === null) return;
onValueChange={(value: string | null) => { setValue('automationId', value, { shouldDirty: true });
if (value === null) return; }}
setValue('automationId', value, { shouldDirty: true }); options={automationSelect}
}} aria-label='Automation title'
options={automationSelect} />
aria-label='Automation title' <Panel.Error>{errors.automationId?.message}</Panel.Error>
/> </label>
<Panel.Error>{errors.automationId?.message}</Panel.Error> <Panel.InlineElements align='end'>
</label> <Button disabled={isSubmitting} onClick={onCancel}>
</form> Cancel
} </Button>
footerElements={ <Button type='submit' variant='primary' disabled={!canSubmit} loading={isSubmitting}>
<> Save
{errors.root && <Panel.Error>{errors.root.message}</Panel.Error>} </Button>
<Button disabled={isSubmitting} onClick={onCancel}> </Panel.InlineElements>
Cancel </Panel.Indent>
</Button>
<Button type='submit' form={formId} variant='primary' disabled={!canSubmit} loading={isSubmitting}>
Save
</Button>
</>
}
/>
); );
} }
@@ -9,33 +9,23 @@ import Info from '../../../../common/components/info/Info';
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings'; import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import { checkDuplicates } from './automationUtils'; import { checkDuplicates } from './automationUtils';
import TriggerForm from './TriggerForm'; import AutomationForm from './TriggerForm';
import TriggersListItem from './TriggersListItem'; import TriggersListItem from './TriggersListItem';
type FormState = {
isOpen: boolean;
trigger?: Trigger;
};
interface TriggersListProps { interface TriggersListProps {
triggers: Trigger[]; triggers: Trigger[];
automations: NormalisedAutomation; automations: NormalisedAutomation;
enabledAutomations?: boolean; enabledAutomations?: boolean;
isLoading: boolean;
} }
export default function TriggersList({ triggers, automations, enabledAutomations, isLoading }: TriggersListProps) { export default function TriggersList(props: TriggersListProps) {
const [formState, setFormState] = useState<FormState>({ isOpen: false, trigger: undefined }); const { triggers, automations, enabledAutomations } = props;
const [showForm, setShowForm] = useState(false);
const { refetch } = useAutomationSettings(); const { refetch } = useAutomationSettings();
const [deleteError, setDeleteError] = useState<string | null>(null); const [deleteError, setDeleteError] = useState<string | null>(null);
const openNewForm = () => setFormState({ isOpen: true });
const openEditForm = (trigger: Trigger) => setFormState({ isOpen: true, trigger });
const closeForm = () => setFormState({ isOpen: false, trigger: undefined });
const handleDelete = async (id: string) => { const handleDelete = async (id: string) => {
try { try {
setDeleteError(null);
await deleteTrigger(id); await deleteTrigger(id);
} catch (error) { } catch (error) {
setDeleteError(maybeAxiosError(error)); setDeleteError(maybeAxiosError(error));
@@ -45,7 +35,7 @@ export default function TriggersList({ triggers, automations, enabledAutomations
}; };
const postSubmit = () => { const postSubmit = () => {
closeForm(); setShowForm(false);
refetch(); refetch();
}; };
@@ -55,86 +45,70 @@ export default function TriggersList({ triggers, automations, enabledAutomations
const canAdd = Object.keys(automations).length > 0; const canAdd = Object.keys(automations).length > 0;
return ( return (
<Panel.Section> <Panel.Card>
<Panel.Card> <Panel.SubHeader>
{formState.isOpen && ( Manage triggers
<TriggerForm <Button type='submit' form='trigger-form' disabled={!canAdd} loading={false} onClick={() => setShowForm(true)}>
automations={automations} New <IoAdd />
trigger={formState.trigger ?? null} </Button>
onCancel={closeForm} </Panel.SubHeader>
postSubmit={postSubmit} <Panel.Divider />
/> <Panel.Section>
{enabledAutomations === false && (
<Info>
Automations are disabled. You can still manage triggers here, but they will not run until enabled.
</Info>
)} )}
<Panel.SubHeader> {duplicates && (
Manage triggers <Panel.Error>
<Button disabled={!canAdd} onClick={openNewForm}> You have created multiple links between the same trigger and automation which can performance issues.
New <IoAdd /> </Panel.Error>
</Button> )}
</Panel.SubHeader> {showForm && (
<Panel.Divider /> <AutomationForm automations={automations} onCancel={() => setShowForm(false)} postSubmit={postSubmit} />
<Panel.Section> )}
{enabledAutomations === false && ( <Panel.Table>
<Info> <thead>
Automations are disabled. You can still manage triggers here, but they will not run until enabled. <tr>
</Info> <th style={{ width: '35%' }}>Title</th>
)} <th style={{ width: '25%' }}>Lifecycle trigger</th>
{duplicates && ( <th style={{ width: '25%' }}>Automation</th>
<Panel.Error> <th />
You have created multiple links between the same trigger and automation which can cause performance </tr>
issues. </thead>
</Panel.Error> <tbody>
)} {!showForm && triggers.length === 0 && (
<Panel.Table> <Panel.TableEmpty
<thead> label='Create an automation to attach triggers to'
<tr> handleClick={canAdd ? () => setShowForm(true) : undefined}
<th style={{ width: '35%' }}>Title</th> />
<th style={{ width: '25%' }}>Lifecycle trigger</th> )}
<th style={{ width: '25%' }}>Automation</th> {triggers.map((trigger, index) => {
<th /> return (
</tr> <Fragment key={trigger.id}>
</thead> <TriggersListItem
<tbody> automations={automations}
{!isLoading && triggers.length === 0 && ( id={trigger.id}
<Panel.TableEmpty title={trigger.title}
title='No triggers yet' trigger={trigger.trigger}
description={ automationId={trigger.automationId}
canAdd duplicate={duplicates?.includes(index)}
? 'Triggers run an automation at a given point of the timer lifecycle, like when an event starts or finishes.' handleDelete={() => handleDelete(trigger.id)}
: 'Create an automation first, then add a trigger to decide when it should run.' postSubmit={postSubmit}
} />
action={ {deleteError && (
canAdd && ( <tr>
<Button variant='primary' onClick={openNewForm}> <td colSpan={5}>
Create trigger <IoAdd /> <Panel.Error>{deleteError}</Panel.Error>
</Button> </td>
) </tr>
} )}
/> </Fragment>
)} );
{triggers.map((trigger, index) => { })}
return ( </tbody>
<Fragment key={trigger.id}> </Panel.Table>
<TriggersListItem </Panel.Section>
automations={automations} </Panel.Card>
trigger={trigger}
duplicate={duplicates?.includes(index)}
handleEdit={() => openEditForm(trigger)}
handleDelete={() => handleDelete(trigger.id)}
/>
</Fragment>
);
})}
{deleteError && (
<tr>
<td colSpan={5}>
<Panel.Error>{deleteError}</Panel.Error>
</td>
</tr>
)}
</tbody>
</Panel.Table>
</Panel.Section>
</Panel.Card>
</Panel.Section>
); );
} }
@@ -1,21 +1,48 @@
import { NormalisedAutomation, Trigger } from 'ontime-types'; import { NormalisedAutomation, TimerLifeCycle } from 'ontime-types';
import { useState } from 'react';
import { IoPencil, IoTrash, IoWarningOutline } from 'react-icons/io5'; import { IoPencil, IoTrash, IoWarningOutline } from 'react-icons/io5';
import IconButton from '../../../../common/components/buttons/IconButton'; import IconButton from '../../../../common/components/buttons/IconButton';
import Tag from '../../../../common/components/tag/Tag'; import Tag from '../../../../common/components/tag/Tag';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import { cycles } from './automationUtils'; import { cycles } from './automationUtils';
import AutomationForm from './TriggerForm';
interface TriggersListItemProps { interface TriggersListItemProps {
automations: NormalisedAutomation; automations: NormalisedAutomation;
trigger: Trigger; id: string;
title: string;
trigger: TimerLifeCycle;
automationId: string;
duplicate?: boolean; duplicate?: boolean;
handleEdit: () => void;
handleDelete: () => void; handleDelete: () => void;
postSubmit: () => void;
} }
export default function TriggersListItem(props: TriggersListItemProps) { export default function TriggersListItem(props: TriggersListItemProps) {
const { automations, trigger, duplicate, handleEdit, handleDelete } = props; const { automations, id, title, trigger, automationId, duplicate, handleDelete, postSubmit } = props;
const [isEditing, setIsEditing] = useState(false);
if (isEditing) {
return (
<tr>
<td colSpan={99}>
<AutomationForm
automations={automations}
initialId={id}
initialTitle={title}
initialTrigger={trigger}
initialAutomationId={automationId}
onCancel={() => setIsEditing(false)}
postSubmit={() => {
setIsEditing(false);
postSubmit();
}}
/>
</td>
</tr>
);
}
return ( return (
<tr data-warn={duplicate}> <tr data-warn={duplicate}>
@@ -25,16 +52,16 @@ export default function TriggersListItem(props: TriggersListItemProps) {
color='#FFBC56' // $orange-500 color='#FFBC56' // $orange-500
/> />
)} )}
{trigger.title} {title}
</Panel.InlineElements> </Panel.InlineElements>
<td> <td>
<Tag>{cycles.find((cycle) => cycle.value === trigger.trigger)?.label}</Tag> <Tag>{cycles.find((cycle) => cycle.value === trigger)?.label}</Tag>
</td> </td>
<td> <td>
<Tag>{automations?.[trigger.automationId]?.title}</Tag> <Tag>{automations?.[automationId]?.title}</Tag>
</td> </td>
<Panel.InlineElements align='end' relation='inner' as='td'> <Panel.InlineElements align='end' relation='inner' as='td'>
<IconButton variant='ghosted-white' aria-label='Edit entry' onClick={handleEdit}> <IconButton variant='ghosted-white' aria-label='Edit entry' onClick={() => setIsEditing(true)}>
<IoPencil /> <IoPencil />
</IconButton> </IconButton>
<IconButton variant='ghosted-destructive' aria-label='Delete entry' onClick={handleDelete}> <IconButton variant='ghosted-destructive' aria-label='Delete entry' onClick={handleDelete}>
@@ -1,67 +1,29 @@
.inputShell { .wrapper {
position: relative; position: relative;
} }
.fluid { .suggestions {
width: 100%;
}
.input {
padding-right: 2rem;
}
.expandButton {
position: absolute;
top: 50%;
right: 0.25rem;
transform: translateY(-50%);
}
.positioner {
outline: 0;
}
.popup {
box-sizing: border-box;
min-width: var(--anchor-width);
max-width: var(--available-width);
border: 1px solid $gray-1000;
border-radius: $component-border-radius-md;
background: $gray-1250; background: $gray-1250;
color: $label-gray; color: $ui-white;
}
.list { position: absolute;
box-sizing: border-box; top: 100%;
max-height: min(20rem, var(--available-height)); left: 0;
overflow-y: auto; width: 100%;
overscroll-behavior: contain; margin: 0;
z-index: $zindex-floating;
padding-block: 0.25rem; padding-block: 0.25rem;
outline: 0;
}
.item { max-height: 200px;
box-sizing: border-box; overflow-y: auto;
padding: 0.25rem 0.5rem; color: $label-gray;
outline: 0;
cursor: default;
user-select: none;
overflow-wrap: anywhere;
&[data-highlighted] { li {
padding: 0.25rem;
}
li:hover {
color: $ui-white; color: $ui-white;
background: $blue-700; background: $blue-700;
} }
} }
.expandedEditor {
min-height: min(18rem, 45vh);
font-family: monospace;
line-height: 1.4;
}
.footerHint {
margin-right: auto;
color: $label-gray;
font-size: calc(1rem - 2px);
}
@@ -1,258 +1,69 @@
import { Autocomplete as BaseAutocomplete } from '@base-ui/react/autocomplete'; import { mergeRefs, useClickOutside } from '@mantine/hooks';
import type { ChangeEvent, ReactNode, Ref } from 'react'; import { forwardRef, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { IoExpandOutline } from 'react-icons/io5';
import Button from '../../../../../common/components/buttons/Button'; import Input, { type InputProps } from '../../../../../common/components/input/input/Input';
import IconButton from '../../../../../common/components/buttons/IconButton';
import { type InputProps } from '../../../../../common/components/input/input/Input';
import Textarea from '../../../../../common/components/input/textarea/Textarea';
import Modal from '../../../../../common/components/modal/Modal';
import useCustomFields from '../../../../../common/hooks-query/useCustomFields'; import useCustomFields from '../../../../../common/hooks-query/useCustomFields';
import { cx } from '../../../../../common/utils/styleUtils'; import { makeAutoCompleteList, matchRemaining, selectFromLastTemplate } from './templateInput.utils';
import { makeAutoCompleteList } from './templateInput.utils';
import { useTemplateAutocomplete } from './useTemplateAutocomplete';
import inputStyle from '../../../../../common/components/input/input/Input.module.scss';
import style from './TemplateInput.module.scss'; import style from './TemplateInput.module.scss';
interface TemplateInputProps extends Omit<InputProps, 'value'> { interface TemplateInputProps extends InputProps {}
ref?: Ref<HTMLInputElement>;
value?: string;
}
interface TemplateEditorModalProps { const TemplateInput = forwardRef(function TemplateInput(props: TemplateInputProps, ref) {
autocompleteList: string[]; const { value, onChange, ...rest } = props;
isOpen: boolean;
onClose: () => void;
onSave: (value: string) => void;
value: string;
}
type TemplateAutocompleteState = ReturnType<typeof useTemplateAutocomplete<HTMLInputElement>>;
interface TemplateAutocompleteRootProps {
autocomplete: TemplateAutocompleteState;
children: ReactNode;
value: string;
}
function assignRef<T>(ref: Ref<T> | undefined, value: T | null) {
if (typeof ref === 'function') {
ref(value);
} else if (ref) {
ref.current = value;
}
}
function emitInputChange(name: string | undefined, value: string, onChange: InputProps['onChange']) {
onChange?.({
target: { name, value },
currentTarget: { name, value },
} as ChangeEvent<HTMLInputElement>);
}
export default function TemplateInput({
className,
disabled,
fluid,
height = 'medium',
onChange,
readOnly,
ref,
value,
variant = 'subtle',
...rest
}: TemplateInputProps) {
const { data } = useCustomFields(); const { data } = useCustomFields();
const inputRef = useRef<HTMLInputElement | null>(null); const localRef = useClickOutside(() => setShowSuggestions(false));
const [inputValue, setInputValue] = useState(value || '');
const [isExpanded, setIsExpanded] = useState(false);
const autocompleteList = useMemo(() => { const autocompleteList = useMemo(() => {
return makeAutoCompleteList(data); return makeAutoCompleteList(data);
}, [data]); }, [data]);
const updateInputValue = useCallback( const [inputValue, setInputValue] = useState(value || '');
(nextValue: string) => { const [suggestions, setSuggestions] = useState<string[]>([]);
setInputValue(nextValue); const [showSuggestions, setShowSuggestions] = useState(false);
emitInputChange(rest.name, nextValue, onChange);
},
[onChange, rest.name],
);
const autocomplete = useTemplateAutocomplete(inputValue, autocompleteList, inputRef, updateInputValue); const updateSuggestions = (value: string) => {
const { setCursorForValue } = autocomplete; const template = selectFromLastTemplate(value);
return autocompleteList.filter((suggestion) => suggestion.startsWith(template));
// Keep the local autocomplete input in sync when react-hook-form resets or swaps field-array values.
useEffect(() => {
const nextValue = value || '';
setInputValue(nextValue);
setCursorForValue(nextValue, nextValue.length);
}, [setCursorForValue, value]);
const setInputRef = useCallback(
(element: HTMLInputElement | null) => {
inputRef.current = element;
assignRef(ref, element);
},
[ref],
);
const openExpandedEditor = () => {
autocomplete.setShowSuggestions(false);
setIsExpanded(true);
}; };
const closeExpandedEditor = () => { const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setIsExpanded(false); setInputValue(event.target.value);
};
const saveExpandedEditor = (nextValue: string) => { if (event.target.value.endsWith('{')) {
updateInputValue(nextValue); setShowSuggestions(true);
autocomplete.setCursorForValue(nextValue, nextValue.length); setSuggestions(updateSuggestions(event.target.value));
setIsExpanded(false); } else if (event.target.value === '' || event.target.value.endsWith('}}')) {
}; setShowSuggestions(false);
} else if (showSuggestions) {
return ( setSuggestions(updateSuggestions(event.target.value));
<>
<TemplateAutocompleteRoot autocomplete={autocomplete} value={inputValue}>
<div className={cx([style.inputShell, fluid && style.fluid])}>
<BaseAutocomplete.Input
ref={setInputRef}
className={cx([
inputStyle.input,
inputStyle[variant],
inputStyle[height],
fluid && inputStyle.fluid,
style.input,
className,
])}
{...rest}
disabled={disabled}
onClick={autocomplete.updateCursor}
onFocus={autocomplete.updateCursor}
onKeyUp={autocomplete.updateCursor}
onSelect={autocomplete.updateCursor}
readOnly={readOnly}
/>
<IconButton
aria-label='Expand template editor'
className={style.expandButton}
disabled={disabled || readOnly}
onClick={openExpandedEditor}
size='small'
title='Expand template editor'
variant='ghosted-white'
>
<IoExpandOutline />
</IconButton>
</div>
</TemplateAutocompleteRoot>
<TemplateEditorModal
autocompleteList={autocompleteList}
isOpen={isExpanded}
onClose={closeExpandedEditor}
onSave={saveExpandedEditor}
value={inputValue}
/>
</>
);
}
function TemplateAutocompleteRoot({ autocomplete, children, value }: TemplateAutocompleteRootProps) {
return (
<BaseAutocomplete.Root
items={autocomplete.suggestions}
autoHighlight
highlightItemOnHover
mode='none'
open={autocomplete.open}
value={value}
onOpenChange={autocomplete.setShowSuggestions}
onValueChange={autocomplete.handleValueChange}
>
{children}
<TemplateSuggestionPopup />
</BaseAutocomplete.Root>
);
}
function TemplateEditorModal({ autocompleteList, isOpen, onClose, onSave, value }: TemplateEditorModalProps) {
const expandedInputRef = useRef<HTMLTextAreaElement | null>(null);
const [draftValue, setDraftValue] = useState(value);
const autocomplete = useTemplateAutocomplete(draftValue, autocompleteList, expandedInputRef, setDraftValue);
const { setShowSuggestions } = autocomplete;
// Reset the draft whenever the modal opens so cancel never leaks unsaved changes.
useEffect(() => {
if (!isOpen) {
return;
} }
setDraftValue(value); onChange?.(event);
setShowSuggestions(false);
}, [isOpen, setShowSuggestions, value]);
const handleClose = () => {
setShowSuggestions(false);
onClose();
}; };
const handleSave = () => { const handleSuggestion = (value: string) => {
setInputValue((prev) => {
const remaining = matchRemaining(prev as string, value);
return prev + remaining;
});
setShowSuggestions(false); setShowSuggestions(false);
onSave(draftValue);
}; };
return ( return (
<Modal <div className={style.wrapper} ref={mergeRefs(localRef, ref)}>
isOpen={isOpen} <Input value={inputValue} {...rest} onChange={handleInputChange} fluid />
onClose={handleClose} {showSuggestions && suggestions.length > 0 && (
showBackdrop <ul className={style.suggestions}>
showCloseButton {suggestions.map((suggestion) => (
title='Edit template' <li key={suggestion} onClick={() => handleSuggestion(suggestion)}>
bodyElements={ {suggestion}
<TemplateAutocompleteRoot autocomplete={autocomplete} value={draftValue}> </li>
<BaseAutocomplete.Input ))}
autoFocus </ul>
className={style.expandedEditor} )}
onClick={autocomplete.updateCursor} </div>
onFocus={autocomplete.updateCursor}
onKeyUp={autocomplete.updateCursor}
onSelect={autocomplete.updateCursor}
render={<Textarea ref={expandedInputRef} fluid resize='none' rows={8} />}
/>
</TemplateAutocompleteRoot>
}
footerElements={
<>
<span className={style.footerHint}>Start a template with {'{{'} to see autocomplete.</span>
<Button onClick={handleClose}>Cancel</Button>
<Button onClick={handleSave} variant='primary'>
Save
</Button>
</>
}
/>
); );
} });
function TemplateSuggestionPopup() { export default TemplateInput;
return (
<BaseAutocomplete.Portal>
<BaseAutocomplete.Positioner side='bottom' align='start' className={style.positioner}>
<BaseAutocomplete.Popup className={style.popup}>
<BaseAutocomplete.List className={style.list}>
<BaseAutocomplete.Collection>
{(suggestion: string) => (
<BaseAutocomplete.Item key={suggestion} value={suggestion} className={style.item}>
{suggestion}
</BaseAutocomplete.Item>
)}
</BaseAutocomplete.Collection>
</BaseAutocomplete.List>
</BaseAutocomplete.Popup>
</BaseAutocomplete.Positioner>
</BaseAutocomplete.Portal>
);
}
@@ -1,4 +1,4 @@
import { completeTemplateAtCursor, matchRemaining, selectActiveTemplate } from '../templateInput.utils'; import { matchRemaining } from '../templateInput.utils';
describe('matchRemaining()', () => { describe('matchRemaining()', () => {
it('should return a partial string needed for autocomplete', () => { it('should return a partial string needed for autocomplete', () => {
@@ -15,54 +15,3 @@ describe('matchRemaining()', () => {
expect(matchRemaining('test', 'test')).toBe(''); expect(matchRemaining('test', 'test')).toBe('');
}); });
}); });
describe('selectActiveTemplate()', () => {
it('returns the last unclosed template fragment', () => {
expect(selectActiveTemplate('send {{event')).toBe('{{event');
expect(selectActiveTemplate('send {{eventNow.title}} and {{event')).toBe('{{event');
});
it('ignores single braces and closed templates', () => {
expect(selectActiveTemplate('send {')).toBe('');
expect(selectActiveTemplate('send {{eventNow.title}}')).toBe('');
});
it('only considers templates before the cursor', () => {
expect(selectActiveTemplate('send {{event}} then {{timer', 14)).toBe('');
expect(selectActiveTemplate('send {{event}} then {{timer', 27)).toBe('{{timer');
});
it('selects a partial template when the cursor is inside a completed template', () => {
expect(selectActiveTemplate('send {{timer.current}} after', 12)).toBe('{{timer');
});
});
describe('completeTemplateAtCursor()', () => {
it('completes the active template before the cursor', () => {
expect(completeTemplateAtCursor('send {{timer after', '{{timer.current}}', 12)).toEqual({
value: 'send {{timer.current}} after',
cursorIndex: 22,
});
});
it('preserves text before and after the cursor', () => {
expect(completeTemplateAtCursor('before {{event after', '{{eventNow.title}}', 14)).toEqual({
value: 'before {{eventNow.title}} after',
cursorIndex: 25,
});
});
it('preserves a following template when completing between templates', () => {
expect(completeTemplateAtCursor('{{clock}} and {{timer then {{eventNow.title}}', '{{timer.current}}', 21)).toEqual({
value: '{{clock}} and {{timer.current}} then {{eventNow.title}}',
cursorIndex: 31,
});
});
it('replaces the whole template when the cursor is inside a completed template', () => {
expect(completeTemplateAtCursor('before {{timer.current}} after', '{{timer.duration}}', 15)).toEqual({
value: 'before {{timer.duration}} after',
cursorIndex: 25,
});
});
});
@@ -54,16 +54,6 @@ const eventStaticPropertiesNext = [
'{{eventNext.delay}}', '{{eventNext.delay}}',
]; ];
const groupStaticPropertiesNow = [
'{{groupNow.id}}',
'{{groupNow.title}}',
'{{groupNow.note}}',
'{{groupNow.colour}}',
'{{groupNow.timeStart}}',
'{{groupNow.timeEnd}}',
'{{groupNow.duration}}',
];
const staticAuxProperties = (index: 1 | 2 | 3) => [ const staticAuxProperties = (index: 1 | 2 | 3) => [
`{{auxtimer${index}.current}}`, `{{auxtimer${index}.current}}`,
`{{auxtimer${index}.duration}}`, `{{auxtimer${index}.duration}}`,
@@ -85,19 +75,12 @@ export function makeAutoCompleteList(customFields: CustomFields): string[] {
...Object.entries(customFields).map(([key]) => `{{eventNow.custom.${key}}}`), ...Object.entries(customFields).map(([key]) => `{{eventNow.custom.${key}}}`),
...eventStaticPropertiesNext, ...eventStaticPropertiesNext,
...Object.entries(customFields).map(([key]) => `{{eventNext.custom.${key}}}`), ...Object.entries(customFields).map(([key]) => `{{eventNext.custom.${key}}}`),
...groupStaticPropertiesNow,
...Object.entries(customFields).map(([key]) => `{{groupNow.custom.${key}}}`),
...staticAuxProperties(1), ...staticAuxProperties(1),
...staticAuxProperties(2), ...staticAuxProperties(2),
...staticAuxProperties(3), ...staticAuxProperties(3),
]; ];
} }
interface TemplateCompletion {
cursorIndex: number;
value: string;
}
/** /**
* Returns the partial string b needed to autocomplete string a * Returns the partial string b needed to autocomplete string a
* @example matchRemaining('te', 'test') -> 'st' * @example matchRemaining('te', 'test') -> 'st'
@@ -128,54 +111,13 @@ export function matchRemaining(a: string, b: string) {
return ''; return '';
} }
function getActiveTemplateRange(text: string, cursorIndex = text.length) {
const textBeforeCursor = text.slice(0, cursorIndex);
const start = textBeforeCursor.lastIndexOf('{{');
if (start === -1) {
return null;
}
const closeBeforeCursor = textBeforeCursor.lastIndexOf('}}');
if (closeBeforeCursor > start) {
return null;
}
const closeAfterStart = text.indexOf('}}', start);
const nextStartAfterCursor = text.indexOf('{{', cursorIndex);
const closesBeforeNextTemplate = nextStartAfterCursor === -1 || closeAfterStart < nextStartAfterCursor;
const end = closeAfterStart !== -1 && closesBeforeNextTemplate ? closeAfterStart + 2 : cursorIndex;
return {
end,
start,
template: text.slice(start, cursorIndex),
};
}
/** /**
* Selects the last unclosed starting template before the cursor. * Selects the last starting template in a string
*/ */
export function selectActiveTemplate(text: string, cursorIndex = text.length) { export function selectFromLastTemplate(text: string) {
return getActiveTemplateRange(text, cursorIndex)?.template ?? ''; const lastBraceIndex = text.lastIndexOf('{{');
} if (lastBraceIndex !== -1) {
return text.slice(lastBraceIndex);
/**
* Replaces the active template fragment before the cursor with the selected suggestion.
*/
export function completeTemplateAtCursor(
text: string,
suggestion: string,
cursorIndex = text.length,
): TemplateCompletion {
const activeTemplateRange = getActiveTemplateRange(text, cursorIndex);
if (!activeTemplateRange) {
const value = text + matchRemaining(text, suggestion);
return { value, cursorIndex: value.length };
} }
return '';
const value = `${text.slice(0, activeTemplateRange.start)}${suggestion}${text.slice(activeTemplateRange.end)}`;
return {
value,
cursorIndex: activeTemplateRange.start + suggestion.length,
};
} }
@@ -1,72 +0,0 @@
import { Autocomplete as BaseAutocomplete } from '@base-ui/react/autocomplete';
import { useCallback, useMemo, useState, type RefObject } from 'react';
import { completeTemplateAtCursor, selectActiveTemplate } from './templateInput.utils';
type TemplateElement = HTMLInputElement | HTMLTextAreaElement;
function getCursorIndex(element: TemplateElement | null, fallback: string) {
return element?.selectionStart ?? fallback.length;
}
function focusCursor(element: TemplateElement | null, cursorIndex: number) {
requestAnimationFrame(() => {
element?.focus();
element?.setSelectionRange(cursorIndex, cursorIndex);
});
}
export function useTemplateAutocomplete<T extends TemplateElement>(
value: string,
autocompleteList: string[],
elementRef: RefObject<T | null>,
onValueChange: (value: string) => void,
) {
const [showSuggestions, setShowSuggestions] = useState(false);
const [cursor, setCursor] = useState(value.length);
const activeTemplate = selectActiveTemplate(value, cursor);
const suggestions = useMemo(() => {
if (!activeTemplate) {
return [];
}
return autocompleteList.filter((suggestion) => suggestion.startsWith(activeTemplate));
}, [activeTemplate, autocompleteList]);
const setCursorForValue = useCallback((nextValue: string, cursorIndex: number) => {
setCursor(cursorIndex);
setShowSuggestions(Boolean(selectActiveTemplate(nextValue, cursorIndex)));
}, []);
const updateCursor = useCallback(() => {
const cursorIndex = getCursorIndex(elementRef.current, value);
setCursorForValue(value, cursorIndex);
}, [elementRef, setCursorForValue, value]);
const handleValueChange = useCallback(
(nextValue: string, eventDetails: BaseAutocomplete.Root.ChangeEventDetails) => {
if (eventDetails.reason === 'item-press') {
eventDetails.cancel();
const completed = completeTemplateAtCursor(value, nextValue, cursor);
setCursorForValue(completed.value, completed.cursorIndex);
onValueChange(completed.value);
focusCursor(elementRef.current, completed.cursorIndex);
return;
}
const cursorIndex = getCursorIndex(elementRef.current, nextValue);
setCursorForValue(nextValue, cursorIndex);
onValueChange(nextValue);
},
[cursor, elementRef, onValueChange, setCursorForValue, value],
);
return {
handleValueChange,
open: showSuggestions && suggestions.length > 0,
setCursorForValue,
setShowSuggestions,
suggestions,
updateCursor,
};
}
@@ -66,10 +66,7 @@ export default function ReportSettings() {
</thead> </thead>
<tbody> <tbody>
{combinedReport.length === 0 && ( {combinedReport.length === 0 && (
<Panel.TableEmpty <Panel.TableEmpty label='Reports are generated when running through the show.' />
title='No report data yet'
description='Reports are generated as you run through the show, comparing scheduled times against what actually happened.'
/>
)} )}
{combinedReport.map((entry) => { {combinedReport.map((entry) => {
@@ -1,8 +1,7 @@
import { OntimeView, URLPreset } from 'ontime-types'; import { URLPreset } from 'ontime-types';
import { useState } from 'react'; import { useState } from 'react';
import { IoAdd, IoOpenOutline, IoPencil, IoTrash } from 'react-icons/io5'; import { IoAdd, IoOpenOutline, IoPencil, IoTrash } from 'react-icons/io5';
import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button'; import Button from '../../../../common/components/buttons/Button';
import IconButton from '../../../../common/components/buttons/IconButton'; import IconButton from '../../../../common/components/buttons/IconButton';
import Info from '../../../../common/components/info/Info'; import Info from '../../../../common/components/info/Info';
@@ -23,27 +22,16 @@ const urlPresetsDocs = 'https://docs.getontime.no/features/url-presets/';
export default function URLPresets() { export default function URLPresets() {
const [formState, setFormState] = useState<FormState>({ isOpen: false, preset: undefined }); const [formState, setFormState] = useState<FormState>({ isOpen: false, preset: undefined });
const [actionError, setActionError] = useState<string | null>(null);
const { data, status } = useUrlPresets(); const { data, status } = useUrlPresets();
const { updatePreset, deletePreset, isMutating } = useUpdateUrlPreset(); const { deletePreset, isMutating } = useUpdateUrlPreset();
const openNewForm = () => setFormState({ isOpen: true }); const openNewForm = () => setFormState({ isOpen: true });
const openEditForm = (preset: URLPreset) => setFormState({ isOpen: true, preset }); const openEditForm = (preset: URLPreset) => setFormState({ isOpen: true, preset });
const closeForm = () => setFormState({ isOpen: false, preset: undefined }); const closeForm = () => setFormState({ isOpen: false, preset: undefined });
const persistPreset = async (preset: URLPreset) => {
setActionError(null);
try {
await updatePreset(preset.alias, preset);
} catch (error) {
setActionError(maybeAxiosError(error));
}
};
return ( return (
<Panel.Section> <Panel.Section>
<Panel.Card> <Panel.Card>
{formState.isOpen && <URLPresetForm urlPreset={formState.preset} onClose={closeForm} />}
<Panel.SubHeader> <Panel.SubHeader>
URL presets URL presets
<Button onClick={openNewForm}> <Button onClick={openNewForm}>
@@ -64,47 +52,23 @@ export default function URLPresets() {
</Panel.Section> </Panel.Section>
<Panel.Section> <Panel.Section>
<Panel.Loader isLoading={status === 'pending'} /> <Panel.Loader isLoading={status === 'pending'} />
{actionError && <Panel.Error>{actionError}</Panel.Error>} {formState.isOpen && <URLPresetForm urlPreset={formState.preset} onClose={closeForm} />}
<Panel.Table> <Panel.Table>
<thead> <thead>
<tr> <tr>
<th>Enabled</th> <th>Enabled</th>
<th>Show in nav</th>
<th>Target view</th> <th>Target view</th>
<th>Alias</th> <th>Alias</th>
<th /> <th />
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{data.length === 0 && ( {data.length === 0 && <Panel.TableEmpty handleClick={openNewForm} />}
<Panel.TableEmpty
title='No URL presets yet'
description='A preset gives one of your views a short, memorable address, with all of its options baked in.'
action={
<Button variant='primary' onClick={openNewForm}>
Create preset <IoAdd />
</Button>
}
/>
)}
{data.map((preset, index) => { {data.map((preset, index) => {
const isCuesheet = preset.target === OntimeView.Cuesheet;
return ( return (
<tr key={preset.alias}> <tr key={preset.alias}>
<td> <td>
<Switch <Switch defaultChecked={preset.enabled} onCheckedChange={() => {}} />
checked={preset.enabled}
onCheckedChange={(enabled) => persistPreset({ ...preset, enabled })}
disabled={isMutating}
aria-label='Toggle preset enabled'
/>
</td>
<td>
<Switch
checked={preset.displayInNav}
onCheckedChange={(checked) => persistPreset({ ...preset, displayInNav: checked })}
disabled={isMutating || isCuesheet}
/>
</td> </td>
<td> <td>
<Tag>{preset.target}</Tag> <Tag>{preset.target}</Tag>
@@ -1,18 +1,17 @@
import { OntimeView, OntimeViewPresettable, URLPreset } from 'ontime-types'; import { OntimeView, OntimeViewPresettable, URLPreset } from 'ontime-types';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { maybeAxiosError, unwrapError } from '../../../../../common/api/utils'; import { maybeAxiosError, unwrapError } from '../../../../../common/api/utils';
import Button from '../../../../../common/components/buttons/Button'; import Button from '../../../../../common/components/buttons/Button';
import Input from '../../../../../common/components/input/input/Input'; import Input from '../../../../../common/components/input/input/Input';
import Textarea from '../../../../../common/components/input/textarea/Textarea'; import Textarea from '../../../../../common/components/input/textarea/Textarea';
import Modal from '../../../../../common/components/modal/Modal';
import Select, { SelectOption } from '../../../../../common/components/select/Select'; import Select, { SelectOption } from '../../../../../common/components/select/Select';
import { useUpdateUrlPreset } from '../../../../../common/hooks-query/useUrlPresets'; import { useUpdateUrlPreset } from '../../../../../common/hooks-query/useUrlPresets';
import { preventEscape } from '../../../../../common/utils/keyEvent';
import { isUrlSafe } from '../../../../../common/utils/regex'; import { isUrlSafe } from '../../../../../common/utils/regex';
import { enDash } from '../../../../../common/utils/styleUtils'; import { enDash } from '../../../../../common/utils/styleUtils';
import { generateUrlPresetOptions } from '../../../../../common/utils/urlPresets'; import { generateUrlPresetOptions } from '../../../../../common/utils/urlPresets';
import CuesheetLinkOptions, { CuesheetPermissionValues } from '../../../../sharing/composite/CuesheetLinkOptions';
import * as Panel from '../../../panel-utils/PanelUtils'; import * as Panel from '../../../panel-utils/PanelUtils';
import style from './URLPresetForm.module.scss'; import style from './URLPresetForm.module.scss';
@@ -28,14 +27,11 @@ const targetOptions: SelectOption<OntimeViewPresettable>[] = [
{ value: OntimeView.ProjectInfo, label: 'Project Info' }, { value: OntimeView.ProjectInfo, label: 'Project Info' },
]; ];
const formId = 'url-preset-form';
const defaultValues: URLPreset = { const defaultValues: URLPreset = {
alias: '', alias: '',
target: OntimeView.Timer, target: OntimeView.Timer,
search: '', search: '',
enabled: true, enabled: true,
displayInNav: false,
}; };
interface URLPresetFormProps { interface URLPresetFormProps {
@@ -65,42 +61,12 @@ export default function URLPresetForm({ urlPreset, onClose }: URLPresetFormProps
}); });
const urlRef = useRef<HTMLInputElement>(null); const urlRef = useRef<HTMLInputElement>(null);
// Cuesheet read/write permissions live outside react-hook-form
const initialPermissions = useRef<CuesheetPermissionValues>({
read: urlPreset?.options?.read ?? 'full',
write: urlPreset?.options?.write ?? 'full',
});
const [cuesheetPermissions, setCuesheetPermissions] = useState<CuesheetPermissionValues>(initialPermissions.current);
// update initial permissions on mount
useEffect(() => {
initialPermissions.current = {
read: urlPreset?.options?.read ?? 'full',
write: urlPreset?.options?.write ?? 'full',
};
setCuesheetPermissions(initialPermissions.current);
// oxlint-disable-next-line eslint-plugin-react-hooks/exhaustive-deps -- run on mount
}, []);
const isEditingCuesheet = urlPreset && urlPreset.target === OntimeView.Cuesheet;
const isCuesheet = watch('target') === OntimeView.Cuesheet;
const permissionsDirty =
isCuesheet &&
(cuesheetPermissions.read !== initialPermissions.current.read ||
cuesheetPermissions.write !== initialPermissions.current.write);
const noReadAccess = isCuesheet && cuesheetPermissions.read === '-';
const setupSubmit = async (data: URLPreset) => { const setupSubmit = async (data: URLPreset) => {
try { try {
// Preserve / apply cuesheet permissions, which are not part of the form fields
const payload: URLPreset =
data.target === OntimeView.Cuesheet
? { ...data, target: OntimeView.Cuesheet, options: cuesheetPermissions }
: data;
if (urlPreset) { if (urlPreset) {
await updatePreset(urlPreset.alias, payload); await updatePreset(urlPreset.alias, data);
} else { } else {
await addPreset(payload); await addPreset(data);
} }
onClose(); onClose();
} catch (error) { } catch (error) {
@@ -108,7 +74,6 @@ export default function URLPresetForm({ urlPreset, onClose }: URLPresetFormProps
} }
}; };
// focus on alias when the form opens
useEffect(() => { useEffect(() => {
setFocus('alias'); setFocus('alias');
}, [setFocus]); }, [setFocus]);
@@ -136,101 +101,73 @@ export default function URLPresetForm({ urlPreset, onClose }: URLPresetFormProps
}; };
return ( return (
<Modal <Panel.Indent
isOpen as='form'
onClose={onClose} onSubmit={handleSubmit(setupSubmit)}
showBackdrop onKeyDown={(event) => preventEscape(event, onClose)}
showCloseButton className={style.column}
size='compact' >
title={urlPreset ? 'Edit URL preset' : 'Create URL preset'} <input hidden name='enabled' value='true' />
bodyElements={
<form id={formId} onSubmit={handleSubmit(setupSubmit)} className={style.column}>
<input hidden name='enabled' value='true' />
<div>1. Enter URL and let Ontime generate the preset options</div> <div>1. Enter URL and let Ontime generate the preset options</div>
<Panel.InlineElements>
<div>
<Panel.Description>Alias</Panel.Description>
<Input
{...register('alias', {
required: 'Alias is required',
pattern: {
value: isUrlSafe,
message: 'Field can only contain URL safe characters (a-z, 0-9, _ and -)',
},
})}
/>
</div>
<div className={style.expand}>
<Panel.Description>Generate options (paste URL to generate options)</Panel.Description>
<Panel.InlineElements> <Panel.InlineElements>
<div> <Input placeholder='Paste URL' fluid ref={urlRef} />
<Panel.Description>Alias</Panel.Description> <Button onClick={generateOptions}>Generate</Button>
<Input
{...register('alias', {
required: 'Alias is required',
pattern: {
value: isUrlSafe,
message: 'Field can only contain URL safe characters (a-z, 0-9, _ and -)',
},
})}
/>
</div>
<div className={style.expand}>
<Panel.Description>Generate options (paste URL to generate options)</Panel.Description>
<Panel.InlineElements>
<Input placeholder='Paste URL' fluid ref={urlRef} disabled={isEditingCuesheet} />
<Button onClick={generateOptions} disabled={isEditingCuesheet}>
Generate
</Button>
</Panel.InlineElements>
</div>
</Panel.InlineElements> </Panel.InlineElements>
{errors.alias?.message && <Panel.Error>{errors.alias.message}</Panel.Error>} </div>
{!isEditingCuesheet && ( </Panel.InlineElements>
<> {errors.alias?.message && <Panel.Error>{errors.alias.message}</Panel.Error>}
<div> <div>
{enDash} or {enDash} {enDash} or {enDash}
</div> </div>
<div>2. Choose a view and its parameters</div> <div>2. Choose a view and its parameters</div>
<div> <div>
<Panel.Description>Target</Panel.Description> <Panel.Description>Target</Panel.Description>
<Select <Select
options={targetOptions} options={targetOptions}
{...register('target', { required: 'Target is required' })} {...register('target', { required: 'Target is required' })}
value={watch('target')} value={watch('target')}
onValueChange={(value: OntimeViewPresettable | null) => { onValueChange={(value: OntimeViewPresettable | null) => {
if (value === null) return; if (value === null) return;
setValue('target', value, { shouldDirty: true }); setValue('target', value, { shouldDirty: true });
}} }}
/> />
</div> </div>
<div> <div>
<Panel.Description>Parameters</Panel.Description> <Panel.Description>Parameters</Panel.Description>
<Textarea <Textarea
fluid fluid
rows={3} rows={3}
{...register('search', { {...register('search', {
validate: validateParams, validate: validateParams,
})} })}
/> />
<Panel.Error>{errors.search?.message}</Panel.Error> <Panel.Error>{errors.search?.message}</Panel.Error>
</div> </div>
</> <div>
)} <Panel.Error>{errors.root?.message}</Panel.Error>
<Panel.InlineElements align='end'>
{isCuesheet && (
<div>
<Panel.Description>Permissions</Panel.Description>
<CuesheetLinkOptions
initialRead={initialPermissions.current.read}
initialWrite={initialPermissions.current.write}
onChange={setCuesheetPermissions}
/>
</div>
)}
</form>
}
footerElements={
<>
<Panel.Error>{errors.root?.message}</Panel.Error>
<Button onClick={onClose}>Cancel</Button> <Button onClick={onClose}>Cancel</Button>
<Button <Button variant='primary' type='submit' disabled={!isValid || !isDirty} loading={isSubmitting || isMutating}>
variant='primary'
type='submit'
form={formId}
disabled={!isValid || (!isDirty && !permissionsDirty) || noReadAccess}
loading={isSubmitting || isMutating}
>
Save Save
</Button> </Button>
</> </Panel.InlineElements>
} </div>
/> </Panel.Indent>
); );
} }
@@ -13,30 +13,27 @@ import * as Panel from '../../panel-utils/PanelUtils';
import CustomFieldEntry from './composite/CustomFieldEntry'; import CustomFieldEntry from './composite/CustomFieldEntry';
import CustomFieldForm from './composite/CustomFieldForm'; import CustomFieldForm from './composite/CustomFieldForm';
type CustomFieldEntity = CustomField & { key: CustomFieldKey };
type FormState = {
isOpen: boolean;
field?: CustomFieldEntity;
};
export default function CustomFieldSettings() { export default function CustomFieldSettings() {
const { data, refetch } = useCustomFields(); const { data, refetch } = useCustomFields();
const [formState, setFormState] = useState<FormState>({ isOpen: false, field: undefined }); const [isAdding, setIsAdding] = useState(false);
const openNewForm = () => setFormState({ isOpen: true }); const handleInitiateCreate = () => {
const openEditForm = (field: CustomFieldEntity) => setFormState({ isOpen: true, field }); setIsAdding(true);
const closeForm = () => setFormState({ isOpen: false, field: undefined }); };
const handleSubmit = async (customField: CustomField) => { const handleCancel = () => {
const editing = formState.field; setIsAdding(false);
if (editing) { };
await editCustomField(editing.key, customField);
} else { const handleCreate = async (customField: CustomField) => {
await postCustomField(customField); await postCustomField(customField);
} refetch();
setIsAdding(false);
};
const handleEditField = async (key: CustomFieldKey, customField: CustomField) => {
await editCustomField(key, customField);
refetch(); refetch();
closeForm();
}; };
const handleDelete = async (key: CustomFieldKey) => { const handleDelete = async (key: CustomFieldKey) => {
@@ -48,24 +45,12 @@ export default function CustomFieldSettings() {
} }
}; };
const entries = Object.entries(data);
return ( return (
<Panel.Section> <Panel.Section>
<Panel.Card> <Panel.Card>
{formState.isOpen && (
<CustomFieldForm
onSubmit={handleSubmit}
onCancel={closeForm}
initialColour={formState.field?.colour}
initialLabel={formState.field?.label}
initialKey={formState.field?.key}
initialType={formState.field?.type}
/>
)}
<Panel.SubHeader> <Panel.SubHeader>
Custom fields Custom fields
<Button onClick={openNewForm}> <Button onClick={handleInitiateCreate}>
New <IoAdd /> New <IoAdd />
</Button> </Button>
</Panel.SubHeader> </Panel.SubHeader>
@@ -81,6 +66,7 @@ export default function CustomFieldSettings() {
</Info> </Info>
</Panel.Section> </Panel.Section>
<Panel.Section> <Panel.Section>
{isAdding && <CustomFieldForm onSubmit={handleCreate} onCancel={handleCancel} />}
<Panel.Table> <Panel.Table>
<thead> <thead>
<tr> <tr>
@@ -92,18 +78,7 @@ export default function CustomFieldSettings() {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{entries.length === 0 && ( {Object.entries(data).map(([key, { colour, label, type }]) => {
<Panel.TableEmpty
title='No custom fields yet'
description='Custom fields add your own columns to the rundown, and can be shown in views or used in automations.'
action={
<Button variant='primary' onClick={openNewForm}>
Create custom field <IoAdd />
</Button>
}
/>
)}
{entries.map(([key, { colour, label, type }]) => {
return ( return (
<CustomFieldEntry <CustomFieldEntry
key={key} key={key}
@@ -111,7 +86,7 @@ export default function CustomFieldSettings() {
colour={colour} colour={colour}
label={label} label={label}
type={type} type={type}
onEdit={() => openEditForm({ key, colour, label, type })} onEdit={handleEditField}
onDelete={handleDelete} onDelete={handleDelete}
/> />
); );
@@ -5,14 +5,11 @@ import { uploadCustomView } from '../../../../common/api/customViews';
import { maybeAxiosError } from '../../../../common/api/utils'; import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button'; import Button from '../../../../common/components/buttons/Button';
import Input from '../../../../common/components/input/input/Input'; import Input from '../../../../common/components/input/input/Input';
import Modal from '../../../../common/components/modal/Modal';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import { getFileError, getSlugError, getViewUrl, maxUploadLabel } from './customViews.utils'; import { getFileError, getSlugError, getViewUrl, maxUploadLabel } from './customViews.utils';
import style from './CustomViews.module.scss'; import style from './CustomViews.module.scss';
const formId = 'custom-view-form';
interface CustomViewFormProps { interface CustomViewFormProps {
onComplete: () => void; onComplete: () => void;
onClose: () => void; onClose: () => void;
@@ -56,72 +53,62 @@ export default function CustomViewForm({ onComplete, onClose }: CustomViewFormPr
}; };
return ( return (
<Modal <Panel.Indent as='form' onSubmit={handleUpload} className={style.uploadForm}>
isOpen <input
onClose={onClose} ref={fileInputRef}
showBackdrop style={{ display: 'none' }}
showCloseButton type='file'
size='compact' onChange={handleSelectFile}
title='Upload custom view' accept='.html,text/html'
bodyElements={ />
<form id={formId} onSubmit={handleUpload} className={style.uploadForm}>
<input
ref={fileInputRef}
style={{ display: 'none' }}
type='file'
onChange={handleSelectFile}
accept='.html,text/html'
/>
<div className={style.step}> <div className={style.step}>
<div className={style.stepTitle}>1. Choose a name</div> <div className={style.stepTitle}>1. Choose a name</div>
<Panel.Description>Name</Panel.Description> <Panel.Description>Name</Panel.Description>
<Input <Input
value={slug} value={slug}
onChange={(event) => { onChange={(event) => {
setSlug(event.target.value); setSlug(event.target.value);
setSlugDirty(true); setSlugDirty(true);
}} }}
placeholder='my-view' placeholder='my-view'
aria-label='Custom view name' aria-label='Custom view name'
autoCapitalize='off' autoCapitalize='off'
autoComplete='off' autoComplete='off'
fluid fluid
/> />
<Panel.Description> <Panel.Description>
Use lowercase letters, numbers, and dashes. Example: <Panel.Highlight>my-view</Panel.Highlight> Use lowercase letters, numbers, and dashes. Example: <Panel.Highlight>my-view</Panel.Highlight>
</Panel.Description> </Panel.Description>
<Panel.Description> <Panel.Description>
Preview URL: <Panel.Highlight>{previewUrl}</Panel.Highlight> Preview URL: <Panel.Highlight>{previewUrl}</Panel.Highlight>
</Panel.Description> </Panel.Description>
{slugDirty && slugError && <Panel.Error>{slugError}</Panel.Error>} {slugDirty && slugError && <Panel.Error>{slugError}</Panel.Error>}
</div> </div>
<div className={style.step}> <div className={style.step}>
<div className={style.stepTitle}>2. Select index.html</div> <div className={style.stepTitle}>2. Select index.html</div>
<Panel.Description>Upload file</Panel.Description> <Panel.Description>Upload file</Panel.Description>
<Panel.InlineElements wrap='wrap' className={style.filePicker}> <Panel.InlineElements wrap='wrap' className={style.filePicker}>
<Button onClick={() => fileInputRef.current?.click()}> <Button onClick={() => fileInputRef.current?.click()}>
{selectedFile ? 'Replace index.html' : 'Choose index.html'} {selectedFile ? 'Replace index.html' : 'Choose index.html'}
</Button>
<span className={style.fileName}>
{selectedFile ? `${selectedFile.name} (${Math.ceil(selectedFile.size / 1024)} KB)` : 'No file selected'}
</span>
</Panel.InlineElements>
<Panel.Description>Accepted: index.html only, maximum {maxUploadLabel}.</Panel.Description>
{fileDirty && fileError && <Panel.Error>{fileError}</Panel.Error>}
</div>
</form>
}
footerElements={
<>
{error && <Panel.Error>{error}</Panel.Error>}
<Button onClick={onClose}>Cancel</Button>
<Button variant='primary' type='submit' form={formId} loading={isUploading} disabled={!canUpload}>
Upload view <IoCloudUploadOutline />
</Button> </Button>
</> <span className={style.fileName}>
} {selectedFile ? `${selectedFile.name} (${Math.ceil(selectedFile.size / 1024)} KB)` : 'No file selected'}
/> </span>
</Panel.InlineElements>
<Panel.Description>Accepted: index.html only, maximum {maxUploadLabel}.</Panel.Description>
{fileDirty && fileError && <Panel.Error>{fileError}</Panel.Error>}
</div>
{error && <Panel.Error>{error}</Panel.Error>}
<Panel.InlineElements align='end'>
<Button onClick={onClose}>Cancel</Button>
<Button variant='primary' type='submit' loading={isUploading} disabled={!canUpload}>
Upload view <IoCloudUploadOutline />
</Button>
</Panel.InlineElements>
</Panel.Indent>
); );
} }
@@ -37,14 +37,13 @@ export default function CustomViews() {
return ( return (
<Panel.Section> <Panel.Section>
<Panel.Card> <Panel.Card>
{isUploadOpen && <CustomViewForm onComplete={handleUploadComplete} onClose={() => setIsUploadOpen(false)} />}
<Panel.SubHeader> <Panel.SubHeader>
Custom views Custom views
<Panel.InlineElements> <Panel.InlineElements>
<Button variant='ghosted' onClick={handleRestoreDemo} disabled={hasDemoView}> <Button variant='ghosted' onClick={handleRestoreDemo} disabled={hasDemoView}>
Restore demo Restore demo
</Button> </Button>
<Button onClick={() => setIsUploadOpen(true)}> <Button onClick={() => setIsUploadOpen(true)} disabled={isUploadOpen}>
New <IoAdd /> New <IoAdd />
</Button> </Button>
</Panel.InlineElements> </Panel.InlineElements>
@@ -64,6 +63,8 @@ export default function CustomViews() {
<Panel.Section> <Panel.Section>
<Panel.Loader isLoading={status === 'pending'} /> <Panel.Loader isLoading={status === 'pending'} />
{isUploadOpen && <CustomViewForm onComplete={handleUploadComplete} onClose={() => setIsUploadOpen(false)} />}
{actionError && <Panel.Error>{actionError}</Panel.Error>} {actionError && <Panel.Error>{actionError}</Panel.Error>}
<CustomViewsList <CustomViewsList
@@ -1,6 +1,5 @@
import { CustomViewSummary } from 'ontime-types'; import { CustomViewSummary } from 'ontime-types';
import { useState } from 'react'; import { useState } from 'react';
import { IoCloudUploadOutline } from 'react-icons/io5';
import { deleteCustomView } from '../../../../common/api/customViews'; import { deleteCustomView } from '../../../../common/api/customViews';
import { maybeAxiosError } from '../../../../common/api/utils'; import { maybeAxiosError } from '../../../../common/api/utils';
@@ -54,17 +53,7 @@ export default function CustomViewsList({ views, onOpenUpload, onMutate, onError
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{views.length === 0 && ( {views.length === 0 && <Panel.TableEmpty handleClick={onOpenUpload} label='No custom views yet' />}
<Panel.TableEmpty
title='No custom views yet'
description='Upload a self-contained index.html to serve your own view alongside the built-in ones.'
action={
<Button variant='primary' onClick={onOpenUpload}>
Upload view <IoCloudUploadOutline />
</Button>
}
/>
)}
{views.map((view, index) => ( {views.map((view, index) => (
<CustomViewsListItem <CustomViewsListItem
key={view.slug} key={view.slug}
@@ -12,13 +12,6 @@
gap: 1rem; gap: 1rem;
} }
.formColumn {
display: flex;
flex-direction: column;
gap: 1rem;
}
.current { .current {
// marker consumed by the shared Panel.Table active-row treatment background-color: $blue-1100 !important; // fighting zebra styles
background-color: inherit;
} }
@@ -3,12 +3,9 @@ import { useForm } from 'react-hook-form';
import Button from '../../../../common/components/buttons/Button'; import Button from '../../../../common/components/buttons/Button';
import Input from '../../../../common/components/input/input/Input'; import Input from '../../../../common/components/input/input/Input';
import Modal from '../../../../common/components/modal/Modal';
import { useMutateProjectRundowns } from '../../../../common/hooks-query/useProjectRundowns'; import { useMutateProjectRundowns } from '../../../../common/hooks-query/useProjectRundowns';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
const formId = 'new-rundown-form';
type NewRundownFormState = { type NewRundownFormState = {
title: string; title: string;
}; };
@@ -45,32 +42,22 @@ export function ManageRundownForm({ onClose }: ManageRundownForm) {
}, [setFocus]); }, [setFocus]);
return ( return (
<Modal <Panel.Indent as='form' onSubmit={handleSubmit(createRundown)}>
isOpen <Panel.Section>
onClose={onClose} <label>
showBackdrop <Panel.Description>Rundown title</Panel.Description>
showCloseButton <Input {...register('title')} fluid placeholder='Your rundown name' />
size='small' </label>
title='Create rundown' </Panel.Section>
bodyElements={ <Panel.InlineElements relation='inner' align='end'>
<form id={formId} onSubmit={handleSubmit(createRundown)}> <Button variant='ghosted' disabled={isSubmitting} onClick={onClose}>
<label> Cancel
<Panel.Description>Rundown title</Panel.Description> </Button>
<Input {...register('title')} fluid placeholder='Your rundown name' maxLength={64} /> <Button type='submit' variant='primary' disabled={isSubmitting}>
</label> Create rundown
</form> </Button>
} </Panel.InlineElements>
footerElements={ {errors.root && <Panel.Error>{errors.root.message}</Panel.Error>}
<> </Panel.Indent>
{errors.root && <Panel.Error>{errors.root.message}</Panel.Error>}
<Button variant='ghosted' disabled={isSubmitting} onClick={onClose}>
Cancel
</Button>
<Button type='submit' form={formId} variant='primary' disabled={isSubmitting}>
Create rundown
</Button>
</>
}
/>
); );
} }
@@ -9,7 +9,6 @@ import {
IoPencilOutline, IoPencilOutline,
IoTrash, IoTrash,
} from 'react-icons/io5'; } from 'react-icons/io5';
import { TbGhost3 } from 'react-icons/tb';
import { downloadAsExcel } from '../../../../common/api/excel'; import { downloadAsExcel } from '../../../../common/api/excel';
import { maybeAxiosError } from '../../../../common/api/utils'; import { maybeAxiosError } from '../../../../common/api/utils';
@@ -20,7 +19,6 @@ import { DropdownMenu } from '../../../../common/components/dropdown-menu/Dropdo
import Tag from '../../../../common/components/tag/Tag'; import Tag from '../../../../common/components/tag/Tag';
import { useMutateProjectRundowns, useProjectRundowns } from '../../../../common/hooks-query/useProjectRundowns'; import { useMutateProjectRundowns, useProjectRundowns } from '../../../../common/hooks-query/useProjectRundowns';
import { cx } from '../../../../common/utils/styleUtils'; import { cx } from '../../../../common/utils/styleUtils';
import { useDirectLinkToBackgroundEdit } from '../../../../views/cuesheet/useCuesheetRundownSelection';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import RundownRenameForm from './composite/RundownRenameForm'; import RundownRenameForm from './composite/RundownRenameForm';
import { ManageRundownForm } from './ManageRundownForm'; import { ManageRundownForm } from './ManageRundownForm';
@@ -99,8 +97,6 @@ export default function ManageRundowns() {
await downloadAsExcel(rundownId, title); await downloadAsExcel(rundownId, title);
}; };
const navigateToCuesheet = useDirectLinkToBackgroundEdit();
return ( return (
<> <>
<Panel.Section> <Panel.Section>
@@ -153,11 +149,11 @@ export default function ManageRundowns() {
<tr key={id} className={cx([isLoaded && style.current])}> <tr key={id} className={cx([isLoaded && style.current])}>
<td>{numEntries}</td> <td>{numEntries}</td>
<td> <td>
{title} {isLoaded && <Tag variant='active'>Loaded</Tag>} {title} {isLoaded && <Tag>Loaded</Tag>}
</td> </td>
<td> <td>
<DropdownMenu <DropdownMenu
render={<IconButton variant='ghosted-white' data-testId='rundown_menu' />} render={<IconButton variant='ghosted-white' />}
items={[ items={[
{ {
type: 'item', type: 'item',
@@ -184,12 +180,6 @@ export default function ManageRundowns() {
label: 'Duplicate', label: 'Duplicate',
onClick: () => submitRundownDuplicate(id), onClick: () => submitRundownDuplicate(id),
}, },
{
type: 'item',
icon: TbGhost3,
label: 'Edit in cuesheet',
onClick: () => navigateToCuesheet(id),
},
{ type: 'divider' }, { type: 'divider' },
{ {
type: 'destructive', type: 'destructive',
@@ -16,7 +16,6 @@ export default function RundownDefaultSettings() {
defaultDangerTime, defaultDangerTime,
defaultTimerType, defaultTimerType,
defaultEndAction, defaultEndAction,
inheritGroupColour,
setDefaultDuration, setDefaultDuration,
setLinkPrevious, setLinkPrevious,
setTimeStrategy, setTimeStrategy,
@@ -24,7 +23,6 @@ export default function RundownDefaultSettings() {
setDangerTime, setDangerTime,
setDefaultTimerType, setDefaultTimerType,
setDefaultEndAction, setDefaultEndAction,
setInheritGroupColour,
} = useEditorSettings((state) => state); } = useEditorSettings((state) => state);
const durationInMs = parseUserTime(defaultDuration); const durationInMs = parseUserTime(defaultDuration);
@@ -46,13 +44,6 @@ export default function RundownDefaultSettings() {
/> />
<Switch size='large' checked={linkPrevious} onCheckedChange={setLinkPrevious} /> <Switch size='large' checked={linkPrevious} onCheckedChange={setLinkPrevious} />
</Panel.ListItem> </Panel.ListItem>
<Panel.ListItem>
<Panel.Field
title='Inherit group colour'
description='Whether new events and milestones inherit the colour of their parent group'
/>
<Switch size='large' checked={inheritGroupColour} onCheckedChange={setInheritGroupColour} />
</Panel.ListItem>
<Panel.ListItem> <Panel.ListItem>
<Panel.Field <Panel.Field
title='Timer strategy' title='Timer strategy'
@@ -1,4 +1,5 @@
import { CustomField, CustomFieldKey } from 'ontime-types'; import { CustomField, CustomFieldKey } from 'ontime-types';
import { useState } from 'react';
import { IoPencil, IoTrash } from 'react-icons/io5'; import { IoPencil, IoTrash } from 'react-icons/io5';
import IconButton from '../../../../../common/components/buttons/IconButton'; import IconButton from '../../../../../common/components/buttons/IconButton';
@@ -6,6 +7,7 @@ import CopyTag from '../../../../../common/components/copy-tag/CopyTag';
import Swatch from '../../../../../common/components/input/colour-input/Swatch'; import Swatch from '../../../../../common/components/input/colour-input/Swatch';
import Tag from '../../../../../common/components/tag/Tag'; import Tag from '../../../../../common/components/tag/Tag';
import * as Panel from '../../../panel-utils/PanelUtils'; import * as Panel from '../../../panel-utils/PanelUtils';
import CustomFieldForm from './CustomFieldForm';
import style from '../ManagePanel.module.scss'; import style from '../ManagePanel.module.scss';
@@ -14,12 +16,35 @@ interface CustomFieldEntryProps {
label: string; label: string;
fieldKey: string; fieldKey: string;
type: CustomField['type']; type: CustomField['type'];
onEdit: () => void; onEdit: (key: CustomFieldKey, patch: CustomField) => Promise<void>;
onDelete: (key: CustomFieldKey) => Promise<void>; onDelete: (key: CustomFieldKey) => Promise<void>;
} }
export default function CustomFieldEntry(props: CustomFieldEntryProps) { export default function CustomFieldEntry(props: CustomFieldEntryProps) {
const { colour, label, fieldKey, type, onEdit, onDelete } = props; const { colour, label, fieldKey, type, onEdit, onDelete } = props;
const [isEditing, setIsEditing] = useState(false);
const handleEdit = async (patch: CustomField) => {
await onEdit(fieldKey, patch);
setIsEditing(false);
};
if (isEditing) {
return (
<tr>
<td colSpan={99}>
<CustomFieldForm
onCancel={() => setIsEditing(false)}
onSubmit={handleEdit}
initialColour={colour}
initialLabel={label}
initialKey={fieldKey}
initialType={type}
/>
</td>
</tr>
);
}
return ( return (
<tr> <tr>
@@ -36,7 +61,7 @@ export default function CustomFieldEntry(props: CustomFieldEntryProps) {
</CopyTag> </CopyTag>
</td> </td>
<Panel.InlineElements relation='inner' as='td'> <Panel.InlineElements relation='inner' as='td'>
<IconButton variant='ghosted-white' aria-label='Edit entry' onClick={onEdit}> <IconButton variant='ghosted-white' aria-label='Edit entry' onClick={() => setIsEditing(true)}>
<IoPencil /> <IoPencil />
</IconButton> </IconButton>
<IconButton variant='ghosted-destructive' aria-label='Delete entry' onClick={() => onDelete(fieldKey)}> <IconButton variant='ghosted-destructive' aria-label='Delete entry' onClick={() => onDelete(fieldKey)}>
@@ -8,15 +8,13 @@ import Button from '../../../../../common/components/buttons/Button';
import Info from '../../../../../common/components/info/Info'; import Info from '../../../../../common/components/info/Info';
import SwatchSelect from '../../../../../common/components/input/colour-input/SwatchSelect'; import SwatchSelect from '../../../../../common/components/input/colour-input/SwatchSelect';
import Input from '../../../../../common/components/input/input/Input'; import Input from '../../../../../common/components/input/input/Input';
import Modal from '../../../../../common/components/modal/Modal';
import RadioGroup from '../../../../../common/components/radio-group/RadioGroup'; import RadioGroup from '../../../../../common/components/radio-group/RadioGroup';
import useCustomFields from '../../../../../common/hooks-query/useCustomFields'; import useCustomFields from '../../../../../common/hooks-query/useCustomFields';
import { preventEscape } from '../../../../../common/utils/keyEvent';
import * as Panel from '../../../panel-utils/PanelUtils'; import * as Panel from '../../../panel-utils/PanelUtils';
import style from '../ManagePanel.module.scss'; import style from '../ManagePanel.module.scss';
const formId = 'custom-field-form';
interface CustomFieldsFormProps { interface CustomFieldsFormProps {
onSubmit: (field: CustomField) => Promise<void>; onSubmit: (field: CustomField) => Promise<void>;
onCancel: () => void; onCancel: () => void;
@@ -87,77 +85,65 @@ export default function CustomFieldForm({
const isEditMode = initialKey !== undefined; const isEditMode = initialKey !== undefined;
return ( return (
<Modal <Panel.Indent as='form' onSubmit={handleSubmit(setupSubmit)} onKeyDown={(event) => preventEscape(event, onCancel)}>
isOpen <Info>
onClose={onCancel} Please note that images can quickly deteriorate your app&apos;s performance.
showBackdrop <br />
showCloseButton Prefer using small, and compressed images.
size='compact' </Info>
title={isEditMode ? 'Edit custom field' : 'Create custom field'} <div>
bodyElements={ <Panel.Description>Type</Panel.Description>
<form id={formId} onSubmit={handleSubmit(setupSubmit)} className={style.formColumn}> <RadioGroup
<Info> orientation='horizontal'
Please note that images can quickly deteriorate your app&apos;s performance. disabled={isEditMode}
<br /> onValueChange={(value) => setValue('type', value, { shouldDirty: true })}
Prefer using small, and compressed images. value={watch('type')}
</Info> items={[
<div> { value: 'text', label: 'Text' },
<Panel.Description>Type</Panel.Description> { value: 'image', label: 'Image' },
<RadioGroup ]}
orientation='horizontal' />
disabled={isEditMode} </div>
onValueChange={(value) => setValue('type', value, { shouldDirty: true })} <div className={style.twoCols}>
value={watch('type')} <label>
items={[ <Panel.Description>Label (only alphanumeric characters are allowed)</Panel.Description>
{ value: 'text', label: 'Text' }, {errors.label && <Panel.Error>{errors.label.message}</Panel.Error>}
{ value: 'image', label: 'Image' }, <Input
]} {...register('label', {
/> required: { value: true, message: 'Required field' },
</div> onChange: () => setValue('key', customFieldLabelToKey(getValues('label')) ?? 'N/A'),
<div className={style.twoCols}> validate: (value) => {
<label> if (value.trim().length === 0) return 'Required field';
<Panel.Description>Label (only alphanumeric characters are allowed)</Panel.Description> if (!checkRegex.isAlphanumericWithSpace(value))
{errors.label && <Panel.Error>{errors.label.message}</Panel.Error>} return 'Only alphanumeric characters and space are allowed';
<Input if (!isEditMode) {
{...register('label', { if (isEditMode && Object.keys(data).includes(value)) return 'Custom fields must be unique';
required: { value: true, message: 'Required field' }, }
onChange: () => setValue('key', customFieldLabelToKey(getValues('label')) ?? 'N/A'), return true;
validate: (value) => { },
if (value.trim().length === 0) return 'Required field'; })}
if (!checkRegex.isAlphanumericWithSpace(value)) fluid
return 'Only alphanumeric characters and space are allowed'; />
if (!isEditMode && Object.hasOwn(data, customFieldLabelToKey(value) ?? '')) { </label>
return 'Custom fields must be unique';
}
return true;
},
})}
fluid
/>
</label>
<label> <label>
<Panel.Description>Key (use in Integrations and API)</Panel.Description> <Panel.Description>Key (use in Integrations and API)</Panel.Description>
<Input {...register('key')} variant='ghosted' readOnly fluid /> <Input {...register('key')} variant='ghosted' readOnly fluid />
</label> </label>
</div> </div>
<label> <label>
<Panel.Description>Colour</Panel.Description> <Panel.Description>Colour</Panel.Description>
<SwatchSelect name='colour' value={colour} handleChange={(_field, value) => handleSelectColour(value)} /> <SwatchSelect name='colour' value={colour} handleChange={(_field, value) => handleSelectColour(value)} />
</label> </label>
</form> {errors.root && <Panel.Error>{errors.root.message}</Panel.Error>}
} <Panel.InlineElements relation='inner' align='end'>
footerElements={ <Button variant='ghosted' onClick={onCancel}>
<> Cancel
{errors.root && <Panel.Error>{errors.root.message}</Panel.Error>} </Button>
<Button variant='ghosted' onClick={onCancel}> <Button type='submit' variant='primary' disabled={!canSubmit} loading={isSubmitting}>
Cancel Save
</Button> </Button>
<Button type='submit' form={formId} variant='primary' disabled={!canSubmit} loading={isSubmitting}> </Panel.InlineElements>
Save </Panel.Indent>
</Button>
</>
}
/>
); );
} }

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