refactor(settings): unify empty states, move entity forms to modals, add sidebar search

The settings UI had accumulated three inconsistencies. This addresses all
three without renaming any group or breaking `?settings=` deep links.

Empty states
- Add `Panel.EmptyState` / `Panel.TableEmpty` (title + description + action),
  promoting the treatment previously local to the spreadsheet PreviewTable.
- Add missing empty states to Custom fields, Projects, Rundowns and both
  Client tables, which previously rendered bare column headers.
- Convert the remaining `TableEmpty` call sites off the generic "No data yet".
- Rename `.empty` to `.loaderBox` in ProjectPanel, where it meant a loader box
  rather than an empty state.

Entity forms in modals
- Add `useEntityModal`, replacing six different open/close mechanisms.
- Move the multi-field create/edit forms into `Modal`: automations (wide),
  triggers, URL presets, custom fields, custom views, new rundown and project
  merge. Single-field row renames stay inline.
- Create and edit now share one surface instead of rendering above the table
  and in-place respectively, and the list is no longer interactive underneath.
- Remove the duplicate project create form; the "New" button now routes to the
  existing QuickStart modal.

Sidebar
- Add a search box with per-section keywords, so sections are reachable by the
  term users have in mind (osc, alias, google sheet, pin...).
- Nav items are real buttons in a nav landmark with aria-current; secondary
  items were previously unreachable by keyboard.
- Fix duplicated `manage__sheets` id, which highlighted two entries at once.

Also
- Re-scroll to a section when its nav item is selected again, and align to the
  top rather than the centre.
- Close button no longer floats over content; drop the 300px padding hack.
- Tables scroll with the panel instead of owning a nested scroll area.
- Derive divider/table/list padding from a card padding custom property.
- Fix an unreachable branch that stopped duplicate custom field labels from
  being rejected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UP4knuna8kLe79NAuNDhUJ
This commit is contained in:
Claude
2026-07-25 14:30:18 +00:00
parent 5cf36f049a
commit 76b4ae8b39
44 changed files with 1317 additions and 1069 deletions
@@ -1,15 +1,19 @@
import { useEffect, useRef } from 'react';
export default function useScrollIntoView<T extends HTMLElement>(name: string, location?: string) {
/**
* Scrolls the returned ref into view whenever the given location becomes active.
* The nonce allows re-triggering the scroll when the user selects the same location again.
*/
export default function useScrollIntoView<T extends HTMLElement>(name: string, location?: string, nonce?: number) {
const ref = useRef<T>(null);
useEffect(() => {
if (location && ref.current) {
if (location === name) {
ref.current.scrollIntoView({ behavior: 'smooth', block: 'center' });
ref.current.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}
}, [location, name]);
}, [location, name, nonce]);
return ref;
}
@@ -16,7 +16,7 @@ import useAppSettingsNavigation from './useAppSettingsNavigation';
import style from './AppSettings.module.scss';
export default function AppSettings() {
const { close, panel, location, setLocation } = useAppSettingsNavigation();
const { close, panel, location, nonce, setLocation } = useAppSettingsNavigation();
useKeyDown(close, 'Escape');
return (
@@ -24,12 +24,12 @@ export default function AppSettings() {
<ErrorBoundary>
<PanelList selectedPanel={panel} location={location} />
<PanelContent onClose={close}>
{panel === 'settings' && <SettingsPanel location={location} />}
{panel === 'project' && <ProjectPanel location={location} setLocation={setLocation} />}
{panel === 'manage' && <ManagePanel location={location} />}
{panel === 'automation' && <AutomationPanel location={location} />}
{panel === 'sharing' && <FeaturePanel location={location} />}
{panel === 'network' && <NetworkLogPanel location={location} />}
{panel === 'settings' && <SettingsPanel location={location} nonce={nonce} />}
{panel === 'project' && <ProjectPanel location={location} nonce={nonce} setLocation={setLocation} />}
{panel === 'manage' && <ManagePanel location={location} nonce={nonce} />}
{panel === 'automation' && <AutomationPanel location={location} nonce={nonce} />}
{panel === 'sharing' && <FeaturePanel location={location} nonce={nonce} />}
{panel === 'network' && <NetworkLogPanel location={location} nonce={nonce} />}
{panel === 'about' && <AboutPanel />}
{panel === 'shutdown' && <ShutdownPanel />}
</PanelContent>
@@ -1,10 +1,3 @@
.corner {
position: fixed;
top: 6rem;
right: 4rem;
z-index: $zindex-floating;
}
.contentWrapper {
display: flex;
flex-direction: column;
@@ -14,9 +7,17 @@
position: relative;
}
.corner {
flex: 0 0 auto;
display: flex;
justify-content: flex-end;
padding: 0 1rem 0.5rem;
}
.content {
margin: 1rem;
margin: 0 1rem 1rem;
overflow-y: auto;
flex-grow: 1;
padding-bottom: 300px;
// room for the last section to scroll to the top of the viewport
padding-bottom: 40vh;
}
@@ -12,12 +12,12 @@ interface PanelContentProps {
export default function PanelContent({ onClose, children }: PropsWithChildren<PanelContentProps>) {
return (
<div className={style.contentWrapper}>
<div className={style.content}>{children}</div>
<div className={style.corner}>
<Button size='large' onClick={onClose}>
Close settings <IoClose />
</Button>
</div>
<div className={style.content}>{children}</div>
</div>
);
}
@@ -1,3 +1,14 @@
.nav {
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;
padding-right: 1rem;
}
.tabs,
ul {
list-style: none;
@@ -6,27 +17,52 @@ ul {
}
.tabs {
width: min(30vw, 300px);
flex: 0 0 min(30vw, 300px);
min-width: min(30vw, 300px);
display: flex;
flex-direction: column;
overflow-y: auto;
}
.search {
position: relative;
display: flex;
align-items: center;
flex: 0 0 auto;
}
.searchIcon {
position: absolute;
left: 0.5rem;
color: $gray-400;
pointer-events: none;
}
.searchInput {
padding-left: 2rem;
padding-right: 2rem;
}
.searchClear {
position: absolute;
right: 0.25rem;
}
.primary,
.secondary {
width: 100%;
text-align: left;
background: none;
border: none;
font: inherit;
color: inherit;
padding: 0.25rem 1rem;
margin-right: 1rem;
cursor: pointer;
&:focus {
&:focus-visible {
background-color: $gray-1000;
outline: 0;
outline: 1px solid $blue-500;
}
&:hover {
background-color: $gray-1000;
cursor: pointer;
}
}
@@ -57,6 +93,7 @@ ul {
.secondary {
margin-left: 1rem;
width: calc(100% - 1rem);
color: $secondary-text-gray;
border-left: 1px solid $white-10;
font-size: $inner-section-text-size;
@@ -1,38 +1,112 @@
import { Fragment } from 'react';
import { useHotkeys } from '@mantine/hooks';
import { Fragment, KeyboardEvent, useMemo, useRef, useState } 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 Tooltip from '../../../common/components/tooltip/Tooltip';
import { isKeyEnter } from '../../../common/utils/keyEvent';
import { cx } from '../../../common/utils/styleUtils';
import { SettingsOption, SettingsOptionId, useAppSettingsMenu } from '../useAppSettingsMenu';
import * as Panel from '../panel-utils/PanelUtils';
import { filterSettingsOptions, SettingsOption, SettingsOptionId, useAppSettingsMenu } from '../useAppSettingsMenu';
import useAppSettingsNavigation from '../useAppSettingsNavigation';
import style from './PanelList.module.scss';
export interface PanelBaseProps {
location?: string;
/** bumped on every navigation request so panels can re-scroll to a section */
nonce?: number;
}
interface PanelListProps extends PanelBaseProps {
selectedPanel: string;
}
/** returns the destination a search result should navigate to */
function getFirstResultId(results: SettingsOption[]): SettingsOptionId | null {
const firstGroup = results[0];
if (!firstGroup) {
return null;
}
return (firstGroup.secondary?.[0]?.id ?? firstGroup.id) as SettingsOptionId;
}
export default function PanelList({ selectedPanel, location }: PanelListProps) {
const { options } = useAppSettingsMenu();
const { setLocation } = useAppSettingsNavigation();
const [query, setQuery] = useState('');
const searchRef = useRef<HTMLInputElement>(null);
useHotkeys([['mod + f', () => searchRef.current?.focus()]]);
const results = useMemo(() => filterSettingsOptions(options, query), [options, query]);
const isSearching = query.trim().length > 0;
const handleSearchKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Escape' && isSearching) {
// do not let the settings panel close while the user is clearing a search
event.stopPropagation();
setQuery('');
return;
}
if (event.key === 'Enter') {
const target = getFirstResultId(results);
if (target) {
setLocation(target);
setQuery('');
}
}
};
return (
<ul className={style.tabs}>
{options.map((panel) => {
const isSelected = selectedPanel === panel.id;
if (panel.highlight) {
return (
<Tooltip key={panel.id} text={panel.highlight} render={<span />}>
<PanelListItem panel={panel} location={location} isSelected={isSelected} />
</Tooltip>
);
}
return <PanelListItem key={panel.id} panel={panel} location={location} isSelected={isSelected} />;
})}
</ul>
<nav className={style.nav} aria-label='Settings'>
<div className={style.search}>
<IoSearch className={style.searchIcon} />
<Input
ref={searchRef}
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={handleSearchKeyDown}
placeholder='Search settings'
aria-label='Search settings'
className={style.searchInput}
fluid
/>
{isSearching && (
<IconButton
variant='ghosted-white'
size='small'
aria-label='Clear search'
className={style.searchClear}
onClick={() => {
setQuery('');
searchRef.current?.focus();
}}
>
<IoClose />
</IconButton>
)}
</div>
{results.length === 0 ? (
<Panel.EmptyState title='No settings match' description={`Nothing found for "${query.trim()}"`} />
) : (
<ul className={style.tabs}>
{results.map((panel) => {
const isSelected = selectedPanel === panel.id;
if (panel.highlight) {
return (
<Tooltip key={panel.id} text={panel.highlight} render={<span />}>
<PanelListItem panel={panel} location={location} isSelected={isSelected} />
</Tooltip>
);
}
return <PanelListItem key={panel.id} panel={panel} location={location} isSelected={isSelected} />;
})}
</ul>
)}
</nav>
);
}
@@ -48,36 +122,29 @@ function PanelListItem({ panel, isSelected, location }: PanelListItemProps) {
return (
<Fragment key={panel.id}>
<li
key={panel.id}
onClick={() => setLocation(panel.id as SettingsOptionId)}
onKeyDown={(event) => {
if (isKeyEnter(event)) {
setLocation(panel.id as SettingsOptionId);
}
}}
className={classes}
tabIndex={0}
role='button'
>
{panel.label}
<li>
<button
type='button'
className={classes}
aria-current={isSelected && !location ? 'page' : undefined}
onClick={() => setLocation(panel.id as SettingsOptionId)}
>
{panel.label}
</button>
</li>
{panel.secondary?.map((secondary, index) => {
{panel.secondary?.map((secondary) => {
const id = secondary.id.split('__')[1];
const secondaryClasses = cx([style.secondary, isSelected && location === id ? style.active : null]);
const isActive = isSelected && location === id;
return (
<li
key={secondary.id + index}
onClick={() => setLocation(secondary.id as SettingsOptionId)}
onKeyDown={(event) => {
if (isKeyEnter(event)) {
setLocation(secondary.id as SettingsOptionId);
}
}}
className={secondaryClasses}
role='button'
>
{secondary.label}
<li key={secondary.id}>
<button
type='button'
className={cx([style.secondary, isActive && style.active])}
aria-current={isActive ? 'page' : undefined}
onClick={() => setLocation(secondary.id as SettingsOptionId)}
>
{secondary.label}
</button>
</li>
);
})}
@@ -1,24 +1,26 @@
$inner-padding: 1rem;
$card-padding: 2rem;
.header {
font-size: 2rem;
font-size: 1.5rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid $white-10;
font-weight: 600;
}
.subheader {
font-size: 1.5rem;
font-size: 1.125rem;
padding-bottom: 0.5rem;
font-weight: 600;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.title {
font-size: 1.375rem;
padding: 0 2rem;
font-size: 1rem;
padding: 0 var(--panel-card-padding, #{$card-padding});
font-weight: 600;
display: flex;
align-items: center;
@@ -39,6 +41,8 @@ $inner-padding: 1rem;
position: relative;
margin-top: 2rem;
max-width: 1024px;
// keeps a section clear of the sticky close button when scrolled into view
scroll-margin-top: 1rem;
}
.indent {
@@ -51,8 +55,10 @@ $inner-padding: 1rem;
}
.card {
--panel-card-padding: #{$card-padding};
position: relative;
padding: 2rem;
padding: var(--panel-card-padding);
background-color: $white-3;
border: 1px solid $gray-1100;
border-radius: 3px;
@@ -76,10 +82,10 @@ $inner-padding: 1rem;
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 {
padding: 0 2rem;
max-height: 550px;
overflow-y: scroll;
padding: 0 var(--panel-card-padding, #{$card-padding});
}
.table {
@@ -120,7 +126,7 @@ $inner-padding: 1rem;
}
.listGroup {
padding: 0 2rem;
padding: 0 var(--panel-card-padding, #{$card-padding});
> li:not(:last-child) {
border-bottom: 1px solid $white-10;
@@ -163,7 +169,7 @@ $inner-padding: 1rem;
.divider {
border: none;
border-top: 1px solid $white-10;
margin: 1rem -2rem;
margin: 1rem calc(var(--panel-card-padding, #{$card-padding}) * -1);
}
.overlay {
@@ -188,19 +194,35 @@ $inner-padding: 1rem;
animation: animloader 1s ease-in infinite;
}
.empty {
background-color: $black-10;
.emptyState {
padding: 3rem 1.5rem;
text-align: center;
color: $muted-gray;
}
td {
padding-block: 5rem;
}
.emptyMessage {
width: min(30rem, 100%);
margin-inline: auto;
}
button {
margin-top: 1rem;
margin-inline: auto;
}
.emptyTitle {
margin-bottom: 0.25rem;
color: rgba($gray-200, 0.72);
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 {
@@ -239,14 +261,3 @@ $inner-padding: 1rem;
opacity: 0;
}
}
@keyframes animloader {
0% {
transform: scale(0);
opacity: 0.6;
}
100% {
transform: scale(1);
opacity: 0;
}
}
@@ -1,7 +1,5 @@
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 style from './PanelUtils.module.scss';
@@ -62,16 +60,29 @@ export function Table({ className, children }: { className?: string; children: R
);
}
export function TableEmpty({ label, handleClick }: { label?: string; handleClick?: () => void }) {
export interface EmptyStateProps {
title: string;
description?: ReactNode;
action?: ReactNode;
}
export function EmptyState({ title, description, action }: EmptyStateProps) {
return (
<tr className={style.empty}>
<div className={style.emptyState}>
<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}>
<div>{label ?? 'No data yet'}</div>
{handleClick && (
<Button onClick={handleClick} disabled={!handleClick} variant='primary'>
New <IoAdd />
</Button>
)}
<EmptyState {...props} />
</td>
</tr>
);
@@ -0,0 +1,29 @@
import { useCallback, useState } from 'react';
interface EntityModalState<T> {
isOpen: boolean;
entity: T | null;
}
/**
* Drives a create / edit modal for a list of entities.
* Opening with an entity means edit, opening without means create.
*
* Consumers mount the modal only while `isOpen` is true, which keeps the
* form state of the underlying react-hook-form fresh on every open.
*/
export function useEntityModal<T>() {
const [state, setState] = useState<EntityModalState<T>>({ isOpen: false, entity: null });
const openCreate = useCallback(() => setState({ isOpen: true, entity: null }), []);
const openEdit = useCallback((entity: T) => setState({ isOpen: true, entity }), []);
const close = useCallback(() => setState({ isOpen: false, entity: null }), []);
return {
isOpen: state.isOpen,
entity: state.entity,
openCreate,
openEdit,
close,
};
}
@@ -1,6 +1,14 @@
.outerColumn {
display: flex;
flex-direction: column;
gap: 2rem;
margin-bottom: 2rem;
font-size: calc(1rem - 1px);
color: $ui-white;
// the wide modal hands us a fixed height area, we own the scroll
height: 100%;
overflow-y: auto;
padding-block: 0.5rem;
h3 {
font-size: 1rem;
@@ -19,12 +19,12 @@ import IconButton from '../../../../common/components/buttons/IconButton';
import Info from '../../../../common/components/info/Info';
import Input from '../../../../common/components/input/input/Input';
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 Select from '../../../../common/components/select/Select';
import Tag from '../../../../common/components/tag/Tag';
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
import useCustomFields from '../../../../common/hooks-query/useCustomFields';
import { preventEscape } from '../../../../common/utils/keyEvent';
import { startsWithHttp } from '../../../../common/utils/regex';
import * as Panel from '../../panel-utils/PanelUtils';
import { isAutomation, makeFieldList } from './automationUtils';
@@ -34,6 +34,7 @@ import TemplateInput from './template-input/TemplateInput';
import style from './AutomationForm.module.scss';
const integrationsDocsUrl = 'https://docs.getontime.no/api/automation/#using-variables-in-automation';
const formId = 'automation-form';
interface AutomationFormProps {
automation: Automation | AutomationDTO;
@@ -185,291 +186,322 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
const canSubmit = !isSubmitting && isDirty && isValid;
return (
<Panel.Indent
as='form'
name='automation-form'
onSubmit={handleSubmit(onSubmit)}
className={style.outerColumn}
onKeyDown={(event) => preventEscape(event, onClose)}
>
<Panel.SubHeader>{isEdit ? 'Edit automation' : 'Create automation'}</Panel.SubHeader>
<div className={style.innerSection}>
<h3>Automation options</h3>
<div className={style.titleSection}>
<label>
Title
<Input
{...register('title', { required: { value: true, message: 'Required field' } })}
fluid
placeholder='Load preset'
/>
</label>
<Panel.Error>{errors.title?.message}</Panel.Error>
</div>
</div>
<div className={style.innerSection}>
<h3>Filters (optional)</h3>
<div className={style.ruleSection}>
<label>
Trigger outputs if
<RadioGroup
orientation='horizontal'
value={watch('filterRule')}
onValueChange={(value) => setValue('filterRule', value, { shouldDirty: true })}
items={[
{ value: 'all', label: 'All filters pass' },
{ value: 'any', label: 'Any filter passes' },
]}
/>
</label>
{fieldFilters.map((field, index) => {
const key = `filters.${index}.field.${field.id}`;
return (
<div key={key} className={style.filterSection}>
<label>
Runtime data source
<Select<string | null>
// need to normalize '' to null for the Select to show the placeholder
value={watch(`filters.${index}.field`) || null}
onValueChange={(value) => {
if (value === null) return;
setValue(`filters.${index}.field`, value, { shouldDirty: true });
}}
options={fieldList.map(({ value, label }) => ({
value,
label,
disabled: value === null,
}))}
aria-label='Event field'
/>
<Panel.Error>{errors.filters?.[index]?.field?.message}</Panel.Error>
</label>
<label>
Matching condition
<Select
value={watch(`filters.${index}.operator`)}
onValueChange={(value: string | null) => {
if (value === null) return;
setValue(
`filters.${index}.operator`,
value as 'equals' | 'not_equals' | 'greater_than' | 'less_than' | 'contains' | 'not_contains',
{ shouldDirty: true },
);
}}
options={[
{ value: 'equals', label: 'equals' },
{ value: 'not_equals', label: 'not equals' },
{ value: 'contains', label: 'contains' },
]}
aria-label='Operator'
/>
<Panel.Error>{errors.filters?.[index]?.operator?.message}</Panel.Error>
</label>
<label>
Value to match
<Input {...register(`filters.${index}.value`)} fluid placeholder='<empty / no value>' />
</label>
<div>
<span>&nbsp;</span>
<div>
<IconButton aria-label='Delete' variant='ghosted-destructive' onClick={() => removeFilter(index)}>
<IoTrash />
</IconButton>
</div>
</div>
</div>
);
})}
<div>
<Button onClick={handleAddNewFilter}>
Add filter <IoAdd />
</Button>
<Modal
isOpen
onClose={onClose}
showBackdrop
showCloseButton
size='wide'
title={isEdit ? 'Edit automation' : 'Create automation'}
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>
</>
}
bodyElements={
<form id={formId} onSubmit={handleSubmit(onSubmit)} className={style.outerColumn}>
<div className={style.innerSection}>
<h3>Automation options</h3>
<div className={style.titleSection}>
<label>
Title
<Input
{...register('title', { required: { value: true, message: 'Required field' } })}
fluid
placeholder='Load preset'
/>
</label>
<Panel.Error>{errors.title?.message}</Panel.Error>
</div>
</div>
</div>
</div>
<div className={style.innerColumn}>
<h3>Outputs</h3>
<Info>
Automation outputs can be used to send data from Ontime to external software <br />
or to change properties of Ontime itself. <br /> <br />
Use Ontime runtime data in these fields with template strings. Type {'{{'} to see autocomplete, or{' '}
<ExternalLink href={integrationsDocsUrl}>read the docs</ExternalLink>
</Info>
{fieldOutputs.map((output, index) => {
if (isOSCOutput(output)) {
const rowErrors = errors.outputs?.[index] as
| {
targetIP?: { message?: string };
targetPort?: { message?: string };
address?: { message?: string };
args?: { message?: string };
}
| undefined;
return (
<div key={output.id} className={style.outputCard}>
<Tag>OSC</Tag>
<div className={style.oscSection}>
<label>
Target IP
<Input
{...register(`outputs.${index}.targetIP`, {
required: { value: true, message: 'Required field' },
})}
fluid
placeholder='127.0.0.1'
/>
<Panel.Error>{rowErrors?.targetIP?.message}</Panel.Error>
</label>
<label>
Target Port
<Input
{...register(`outputs.${index}.targetPort`, {
required: { value: true, message: 'Required field' },
setValueAs: (value) => (value === '' ? 0 : Number(value)),
max: { value: 65535, message: 'Port must be within range 1024 - 65535' },
min: { value: 1024, message: 'Port must be within range 1024 - 65535' },
})}
fluid
type='number'
maxLength={5}
placeholder='8000'
/>
<Panel.Error>{rowErrors?.targetPort?.message}</Panel.Error>
</label>
<label>
Address
<TemplateInput
{...register(`outputs.${index}.address`)}
value={output.address}
fluid
placeholder='/cue/start'
/>
<Panel.Error>{rowErrors?.address?.message}</Panel.Error>
</label>
<label>
Arguments
<TemplateInput {...register(`outputs.${index}.args`)} value={output.args} fluid placeholder='1' />
<Panel.Error>{rowErrors?.args?.message}</Panel.Error>
</label>
<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 className={style.innerSection}>
<h3>Filters (optional)</h3>
<div className={style.ruleSection}>
<label>
Trigger outputs if
<RadioGroup
orientation='horizontal'
value={watch('filterRule')}
onValueChange={(value) => setValue('filterRule', value, { shouldDirty: true })}
items={[
{ value: 'all', label: 'All filters pass' },
{ value: 'any', label: 'Any filter passes' },
]}
/>
</label>
{fieldFilters.map((field, index) => {
const key = `filters.${index}.field.${field.id}`;
return (
<div key={key} className={style.filterSection}>
<label>
Runtime data source
<Select<string | null>
// need to normalize '' to null for the Select to show the placeholder
value={watch(`filters.${index}.field`) || null}
onValueChange={(value) => {
if (value === null) return;
setValue(`filters.${index}.field`, value, { shouldDirty: true });
}}
options={fieldList.map(({ value, label }) => ({
value,
label,
disabled: value === null,
}))}
aria-label='Event field'
/>
<Panel.Error>{errors.filters?.[index]?.field?.message}</Panel.Error>
</label>
<label>
Matching condition
<Select
value={watch(`filters.${index}.operator`)}
onValueChange={(value: string | null) => {
if (value === null) return;
setValue(
`filters.${index}.operator`,
value as
| 'equals'
| 'not_equals'
| 'greater_than'
| 'less_than'
| 'contains'
| 'not_contains',
{ shouldDirty: true },
);
}}
options={[
{ value: 'equals', label: 'equals' },
{ value: 'not_equals', label: 'not equals' },
{ value: 'contains', label: 'contains' },
]}
aria-label='Operator'
/>
<Panel.Error>{errors.filters?.[index]?.operator?.message}</Panel.Error>
</label>
<label>
Value to match
<Input {...register(`filters.${index}.value`)} fluid placeholder='<empty / no value>' />
</label>
<div>
<span>&nbsp;</span>
<div>
<IconButton
aria-label='Delete'
variant='ghosted-destructive'
onClick={() => removeFilter(index)}
>
<IoTrash />
</IconButton>
</div>
</div>
</div>
</div>
);
})}
<div>
<Button onClick={handleAddNewFilter}>
Add filter <IoAdd />
</Button>
</div>
);
}
if (isHTTPOutput(output)) {
const rowErrors = errors.outputs?.[index] as
| {
url?: { message?: string };
}
| undefined;
return (
<div key={output.id} className={style.outputCard}>
<Tag>HTTP</Tag>
<div className={style.httpSection}>
<label>
Target URL
<TemplateInput
{...register(`outputs.${index}.url`, {
required: { value: true, message: 'Required field' },
pattern: {
value: startsWithHttp,
message: 'HTTP messages should target http:// or https://',
},
})}
value={output.url}
fluid
placeholder='http://127.0.0.1/start/1'
/>
<Panel.Error>{rowErrors?.url?.message}</Panel.Error>
</label>
<div>
<span>&nbsp;</span>
<Panel.InlineElements relation='inner'>
<Button variant='ghosted-white' onClick={() => handleTestHTTPOutput(index)}>
Test
</Button>
<IconButton aria-label='Delete' variant='ghosted-destructive' onClick={() => removeOutput(index)}>
<IoTrash />
</IconButton>
</Panel.InlineElements>
</div>
</div>
<div className={style.innerColumn}>
<h3>Outputs</h3>
<Info>
Automation outputs can be used to send data from Ontime to external software <br />
or to change properties of Ontime itself. <br /> <br />
Use Ontime runtime data in these fields with template strings. Type {'{{'} to see autocomplete, or{' '}
<ExternalLink href={integrationsDocsUrl}>read the docs</ExternalLink>
</Info>
{fieldOutputs.map((output, index) => {
if (isOSCOutput(output)) {
const rowErrors = errors.outputs?.[index] as
| {
targetIP?: { message?: string };
targetPort?: { message?: string };
address?: { message?: string };
args?: { message?: string };
}
| undefined;
return (
<div key={output.id} className={style.outputCard}>
<Tag>OSC</Tag>
<div className={style.oscSection}>
<label>
Target IP
<Input
{...register(`outputs.${index}.targetIP`, {
required: { value: true, message: 'Required field' },
})}
fluid
placeholder='127.0.0.1'
/>
<Panel.Error>{rowErrors?.targetIP?.message}</Panel.Error>
</label>
<label>
Target Port
<Input
{...register(`outputs.${index}.targetPort`, {
required: { value: true, message: 'Required field' },
setValueAs: (value) => (value === '' ? 0 : Number(value)),
max: { value: 65535, message: 'Port must be within range 1024 - 65535' },
min: { value: 1024, message: 'Port must be within range 1024 - 65535' },
})}
fluid
type='number'
maxLength={5}
placeholder='8000'
/>
<Panel.Error>{rowErrors?.targetPort?.message}</Panel.Error>
</label>
<label>
Address
<TemplateInput
{...register(`outputs.${index}.address`)}
value={output.address}
fluid
placeholder='/cue/start'
/>
<Panel.Error>{rowErrors?.address?.message}</Panel.Error>
</label>
<label>
Arguments
<TemplateInput
{...register(`outputs.${index}.args`)}
value={output.args}
fluid
placeholder='1'
/>
<Panel.Error>{rowErrors?.args?.message}</Panel.Error>
</label>
<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>
);
}
);
}
if (isHTTPOutput(output)) {
const rowErrors = errors.outputs?.[index] as
| {
url?: { message?: string };
}
| undefined;
return (
<div key={output.id} className={style.outputCard}>
<Tag>HTTP</Tag>
<div className={style.httpSection}>
<label>
Target URL
<TemplateInput
{...register(`outputs.${index}.url`, {
required: { value: true, message: 'Required field' },
pattern: {
value: startsWithHttp,
message: 'HTTP messages should target http:// or https://',
},
})}
value={output.url}
fluid
placeholder='http://127.0.0.1/start/1'
/>
<Panel.Error>{rowErrors?.url?.message}</Panel.Error>
</label>
<div>
<span>&nbsp;</span>
<Panel.InlineElements relation='inner'>
<Button variant='ghosted-white' onClick={() => handleTestHTTPOutput(index)}>
Test
</Button>
<IconButton
aria-label='Delete'
variant='ghosted-destructive'
onClick={() => removeOutput(index)}
>
<IoTrash />
</IconButton>
</Panel.InlineElements>
</div>
</div>
</div>
);
}
if (isOntimeAction(output)) {
const rowErrors = errors.outputs?.[index] as
| {
action?: { message?: string };
time?: { message?: string };
text?: { message?: string };
visible?: { message?: string };
secondarySource?: { message?: string };
}
| undefined;
return (
<div key={output.id} className={style.outputCard}>
<Tag>Ontime action</Tag>
<OntimeActionForm
value={output.action}
index={index}
register={register}
rowErrors={rowErrors}
setValue={setValue}
watch={watch}
>
<span>&nbsp;</span>
<Panel.InlineElements relation='inner'>
<Button variant='ghosted-white' onClick={() => handleTestOntimeAction(index)}>
Test
</Button>
<IconButton aria-label='Delete' variant='ghosted-destructive' onClick={() => removeOutput(index)}>
<IoTrash />
</IconButton>
</Panel.InlineElements>
</OntimeActionForm>
</div>
);
}
if (isOntimeAction(output)) {
const rowErrors = errors.outputs?.[index] as
| {
action?: { message?: string };
time?: { message?: string };
text?: { message?: string };
visible?: { message?: string };
secondarySource?: { message?: string };
}
| undefined;
return (
<div key={output.id} className={style.outputCard}>
<Tag>Ontime action</Tag>
<OntimeActionForm
value={output.action}
index={index}
register={register}
rowErrors={rowErrors}
setValue={setValue}
watch={watch}
>
<span>&nbsp;</span>
<Panel.InlineElements relation='inner'>
<Button variant='ghosted-white' onClick={() => handleTestOntimeAction(index)}>
Test
</Button>
<IconButton
aria-label='Delete'
variant='ghosted-destructive'
onClick={() => removeOutput(index)}
>
<IoTrash />
</IconButton>
</Panel.InlineElements>
</OntimeActionForm>
</div>
);
}
// there should be no other output types
return null;
})}
<Panel.InlineElements relation='inner'>
<Button onClick={handleAddNewOSCOutput}>
OSC <IoAdd />
</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>
// there should be no other output types
return null;
})}
<Panel.InlineElements relation='inner'>
<Button onClick={handleAddNewOSCOutput}>
OSC <IoAdd />
</Button>
<Button onClick={handleAddNewHTTPOutput}>
HTTP <IoAdd />
</Button>
<Button onClick={handleAddnewOntimeAction}>
Ontime action <IoAdd />
</Button>
</Panel.InlineElements>
</div>
</form>
}
/>
);
}
@@ -6,11 +6,11 @@ import AutomationSettingsForm from './AutomationSettingsForm';
import AutomationsList from './AutomationsList';
import TriggersList from './TriggersList';
export default function AutomationPanel({ location }: PanelBaseProps) {
export default function AutomationPanel({ location, nonce }: PanelBaseProps) {
const { data, status } = useAutomationSettings();
const settingsRef = useScrollIntoView<HTMLDivElement>('settings', location);
const triggersRef = useScrollIntoView<HTMLDivElement>('triggers', location);
const automationsRef = useScrollIntoView<HTMLDivElement>('automations', location);
const settingsRef = useScrollIntoView<HTMLDivElement>('settings', location, nonce);
const triggersRef = useScrollIntoView<HTMLDivElement>('triggers', location, nonce);
const automationsRef = useScrollIntoView<HTMLDivElement>('automations', location, nonce);
const isLoading = status === 'pending';
const automationState = isLoading ? undefined : data.enabledAutomations;
@@ -1,4 +1,4 @@
import { AutomationDTO, NormalisedAutomation } from 'ontime-types';
import { Automation, AutomationDTO, NormalisedAutomation } from 'ontime-types';
import { Fragment, useState } from 'react';
import { IoAdd, IoPencil, IoTrash } from 'react-icons/io5';
@@ -10,6 +10,7 @@ import Info from '../../../../common/components/info/Info';
import Tag from '../../../../common/components/tag/Tag';
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
import * as Panel from '../../panel-utils/PanelUtils';
import { useEntityModal } from '../../panel-utils/useEntityModal';
import AutomationForm from './AutomationForm';
const automationPlaceholder: AutomationDTO = {
@@ -27,7 +28,7 @@ interface AutomationsListProps {
export default function AutomationsList(props: AutomationsListProps) {
const { automations, enabledAutomations } = props;
const { refetch } = useAutomationSettings();
const [automationFormData, setAutomationFormData] = useState<AutomationDTO | null>(null);
const automationModal = useEntityModal<Automation>();
const [deleteError, setDeleteError] = useState<string | null>(null);
const handleDelete = async (id: string) => {
@@ -45,13 +46,12 @@ export default function AutomationsList(props: AutomationsListProps) {
return (
<Panel.Card>
{automationModal.isOpen && (
<AutomationForm automation={automationModal.entity ?? automationPlaceholder} onClose={automationModal.close} />
)}
<Panel.SubHeader>
Manage automations
<Button
type='submit'
disabled={Boolean(automationFormData)}
onClick={() => setAutomationFormData(automationPlaceholder)}
>
<Button onClick={automationModal.openCreate}>
New <IoAdd />
</Button>
</Panel.SubHeader>
@@ -65,10 +65,6 @@ export default function AutomationsList(props: AutomationsListProps) {
</Info>
)}
{automationFormData !== null && (
<AutomationForm automation={automationFormData} onClose={() => setAutomationFormData(null)} />
)}
<Panel.Table>
<thead>
<tr>
@@ -82,7 +78,13 @@ export default function AutomationsList(props: AutomationsListProps) {
<tbody>
{arrayAutomations.length === 0 && (
<Panel.TableEmpty
handleClick={!automationFormData ? () => setAutomationFormData(automationPlaceholder) : undefined}
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={automationModal.openCreate}>
Create automation <IoAdd />
</Button>
}
/>
)}
{arrayAutomations.map((automationId) => {
@@ -102,7 +104,7 @@ export default function AutomationsList(props: AutomationsListProps) {
<IconButton
variant='ghosted-white'
aria-label='Edit entry'
onClick={() => setAutomationFormData(automations[automationId])}
onClick={() => automationModal.openEdit(automations[automationId])}
>
<IoPencil />
</IconButton>
@@ -1,4 +1,4 @@
import { NormalisedAutomation, TimerLifeCycle, TriggerDTO } from 'ontime-types';
import { NormalisedAutomation, TimerLifeCycle, Trigger, TriggerDTO } from 'ontime-types';
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
@@ -6,30 +6,23 @@ import { addTrigger, editTrigger } from '../../../../common/api/automation';
import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button';
import Input from '../../../../common/components/input/input/Input';
import Modal from '../../../../common/components/modal/Modal';
import Select from '../../../../common/components/select/Select';
import { preventEscape } from '../../../../common/utils/keyEvent';
import * as Panel from '../../panel-utils/PanelUtils';
import { cycles } from './automationUtils';
const formId = 'trigger-form';
interface TriggerFormProps {
automations: NormalisedAutomation;
initialId?: string;
initialTitle?: string;
initialAutomationId?: string;
initialTrigger?: TimerLifeCycle;
onCancel: () => void;
trigger: Trigger | null;
onClose: () => void;
postSubmit: () => void;
}
export default function TriggerForm({
automations,
initialId,
initialTitle,
initialAutomationId,
initialTrigger,
onCancel,
postSubmit,
}: TriggerFormProps) {
export default function TriggerForm({ automations, trigger, onClose, postSubmit }: TriggerFormProps) {
const isEdit = trigger !== null;
const {
handleSubmit,
register,
@@ -40,9 +33,9 @@ export default function TriggerForm({
formState: { errors, isSubmitting, isValid, isDirty },
} = useForm<TriggerDTO>({
defaultValues: {
title: initialTitle,
trigger: initialTrigger ?? (cycles[0].value as TimerLifeCycle | undefined),
automationId: initialAutomationId ?? automations?.[Object.keys(automations)[0]]?.id,
title: trigger?.title,
trigger: trigger?.trigger ?? (cycles[0].value as TimerLifeCycle | undefined),
automationId: trigger?.automationId ?? automations?.[Object.keys(automations)[0]]?.id,
},
resetOptions: {
keepDirtyValues: true,
@@ -55,10 +48,10 @@ export default function TriggerForm({
}, [setFocus]);
const onSubmit = async (values: TriggerDTO) => {
// if we were passed an ID we are editing a Trigger
if (initialId) {
// if we were passed a trigger we are editing it
if (trigger) {
try {
await editTrigger(initialId, { id: initialId, ...values });
await editTrigger(trigger.id, { id: trigger.id, ...values });
postSubmit();
} catch (error) {
setError('root', { message: `Failed to save changes to trigger ${maybeAxiosError(error)}` });
@@ -66,7 +59,7 @@ export default function TriggerForm({
return;
}
// otherwise we are creating a new automation
// otherwise we are creating a new trigger
try {
await addTrigger(values);
postSubmit();
@@ -85,56 +78,60 @@ export default function TriggerForm({
const canSubmit = isDirty && isValid;
return (
<Panel.Indent
as='form'
name='trigger-form'
onSubmit={handleSubmit(onSubmit)}
onKeyDown={(event) => preventEscape(event, onCancel)}
>
<Panel.SubHeader>{initialId ? 'Edit trigger' : 'Create trigger'}</Panel.SubHeader>
<label>
Title
<Input
{...register('title', { required: { value: true, message: 'Required field' } })}
fluid
defaultValue={initialTitle}
/>
<Panel.Error>{errors.title?.message}</Panel.Error>
</label>
<label>
Lifecycle trigger
<Select
value={watch('trigger')}
onValueChange={(value) => {
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'
/>
<Panel.Error>{errors.trigger?.message}</Panel.Error>
</label>
<label>
Automation title
<Select
value={watch('automationId')}
onValueChange={(value: string | null) => {
if (value === null) return;
setValue('automationId', value, { shouldDirty: true });
}}
options={automationSelect}
aria-label='Automation title'
/>
<Panel.Error>{errors.automationId?.message}</Panel.Error>
</label>
<Panel.InlineElements align='end'>
<Button disabled={isSubmitting} onClick={onCancel}>
Cancel
</Button>
<Button type='submit' variant='primary' disabled={!canSubmit} loading={isSubmitting}>
Save
</Button>
</Panel.InlineElements>
</Panel.Indent>
<Modal
isOpen
onClose={onClose}
showBackdrop
showCloseButton
title={isEdit ? 'Edit trigger' : 'Create trigger'}
bodyElements={
<form id={formId} onSubmit={handleSubmit(onSubmit)}>
<Panel.Section>
<label>
<Panel.Description>Title</Panel.Description>
<Input {...register('title', { required: { value: true, message: 'Required field' } })} fluid />
<Panel.Error>{errors.title?.message}</Panel.Error>
</label>
<label>
<Panel.Description>Lifecycle trigger</Panel.Description>
<Select
value={watch('trigger')}
onValueChange={(value) => {
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'
/>
<Panel.Error>{errors.trigger?.message}</Panel.Error>
</label>
<label>
<Panel.Description>Automation title</Panel.Description>
<Select
value={watch('automationId')}
onValueChange={(value: string | null) => {
if (value === null) return;
setValue('automationId', value, { shouldDirty: true });
}}
options={automationSelect}
aria-label='Automation title'
/>
<Panel.Error>{errors.automationId?.message}</Panel.Error>
</label>
</Panel.Section>
</form>
}
footerElements={
<>
{errors.root && <Panel.Error>{errors.root.message}</Panel.Error>}
<Button disabled={isSubmitting} onClick={onClose}>
Cancel
</Button>
<Button type='submit' form={formId} variant='primary' disabled={!canSubmit} loading={isSubmitting}>
Save
</Button>
</>
}
/>
);
}
@@ -8,8 +8,9 @@ import Button from '../../../../common/components/buttons/Button';
import Info from '../../../../common/components/info/Info';
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
import * as Panel from '../../panel-utils/PanelUtils';
import { useEntityModal } from '../../panel-utils/useEntityModal';
import { checkDuplicates } from './automationUtils';
import AutomationForm from './TriggerForm';
import TriggerForm from './TriggerForm';
import TriggersListItem from './TriggersListItem';
interface TriggersListProps {
@@ -20,7 +21,7 @@ interface TriggersListProps {
export default function TriggersList(props: TriggersListProps) {
const { triggers, automations, enabledAutomations } = props;
const [showForm, setShowForm] = useState(false);
const triggerModal = useEntityModal<Trigger>();
const { refetch } = useAutomationSettings();
const [deleteError, setDeleteError] = useState<string | null>(null);
@@ -35,7 +36,7 @@ export default function TriggersList(props: TriggersListProps) {
};
const postSubmit = () => {
setShowForm(false);
triggerModal.close();
refetch();
};
@@ -46,9 +47,17 @@ export default function TriggersList(props: TriggersListProps) {
return (
<Panel.Card>
{triggerModal.isOpen && (
<TriggerForm
automations={automations}
trigger={triggerModal.entity}
onClose={triggerModal.close}
postSubmit={postSubmit}
/>
)}
<Panel.SubHeader>
Manage triggers
<Button type='submit' form='trigger-form' disabled={!canAdd} loading={false} onClick={() => setShowForm(true)}>
<Button disabled={!canAdd} onClick={triggerModal.openCreate}>
New <IoAdd />
</Button>
</Panel.SubHeader>
@@ -64,9 +73,6 @@ export default function TriggersList(props: TriggersListProps) {
You have created multiple links between the same trigger and automation which can performance issues.
</Panel.Error>
)}
{showForm && (
<AutomationForm automations={automations} onCancel={() => setShowForm(false)} postSubmit={postSubmit} />
)}
<Panel.Table>
<thead>
<tr>
@@ -77,10 +83,21 @@ export default function TriggersList(props: TriggersListProps) {
</tr>
</thead>
<tbody>
{!showForm && triggers.length === 0 && (
{triggers.length === 0 && (
<Panel.TableEmpty
label='Create a trigger to run an automation'
handleClick={canAdd ? () => setShowForm(true) : undefined}
title='No triggers yet'
description={
canAdd
? 'Triggers run an automation at a given point of the timer lifecycle, like when an event starts or finishes.'
: 'Create an automation first, then add a trigger to decide when it should run.'
}
action={
canAdd && (
<Button variant='primary' onClick={triggerModal.openCreate}>
Create trigger <IoAdd />
</Button>
)
}
/>
)}
{triggers.map((trigger, index) => {
@@ -88,13 +105,10 @@ export default function TriggersList(props: TriggersListProps) {
<Fragment key={trigger.id}>
<TriggersListItem
automations={automations}
id={trigger.id}
title={trigger.title}
trigger={trigger.trigger}
automationId={trigger.automationId}
trigger={trigger}
duplicate={duplicates?.includes(index)}
handleEdit={() => triggerModal.openEdit(trigger)}
handleDelete={() => handleDelete(trigger.id)}
postSubmit={postSubmit}
/>
{deleteError && (
<tr>
@@ -1,48 +1,21 @@
import { NormalisedAutomation, TimerLifeCycle } from 'ontime-types';
import { useState } from 'react';
import { NormalisedAutomation, Trigger } from 'ontime-types';
import { IoPencil, IoTrash, IoWarningOutline } from 'react-icons/io5';
import IconButton from '../../../../common/components/buttons/IconButton';
import Tag from '../../../../common/components/tag/Tag';
import * as Panel from '../../panel-utils/PanelUtils';
import { cycles } from './automationUtils';
import AutomationForm from './TriggerForm';
interface TriggersListItemProps {
automations: NormalisedAutomation;
id: string;
title: string;
trigger: TimerLifeCycle;
automationId: string;
trigger: Trigger;
duplicate?: boolean;
handleEdit: () => void;
handleDelete: () => void;
postSubmit: () => void;
}
export default function TriggersListItem(props: TriggersListItemProps) {
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>
);
}
const { automations, trigger, duplicate, handleEdit, handleDelete } = props;
return (
<tr data-warn={duplicate}>
@@ -52,16 +25,16 @@ export default function TriggersListItem(props: TriggersListItemProps) {
color='#FFBC56' // $orange-500
/>
)}
{title}
{trigger.title}
</Panel.InlineElements>
<td>
<Tag>{cycles.find((cycle) => cycle.value === trigger)?.label}</Tag>
<Tag>{cycles.find((cycle) => cycle.value === trigger.trigger)?.label}</Tag>
</td>
<td>
<Tag>{automations?.[automationId]?.title}</Tag>
<Tag>{automations?.[trigger.automationId]?.title}</Tag>
</td>
<Panel.InlineElements align='end' relation='inner' as='td'>
<IconButton variant='ghosted-white' aria-label='Edit entry' onClick={() => setIsEditing(true)}>
<IconButton variant='ghosted-white' aria-label='Edit entry' onClick={handleEdit}>
<IoPencil />
</IconButton>
<IconButton variant='ghosted-destructive' aria-label='Delete entry' onClick={handleDelete}>
@@ -7,10 +7,10 @@ import InfoNif from '../network-panel/NetworkInterfaces';
import ReportSettings from './ReportSettings';
import URLPresets from './URLPresets';
export default function FeaturePanel({ location }: PanelBaseProps) {
const presetsRef = useScrollIntoView<HTMLDivElement>('presets', location);
const linkRef = useScrollIntoView<HTMLDivElement>('link', location);
const reportRef = useScrollIntoView<HTMLDivElement>('report', location);
export default function FeaturePanel({ location, nonce }: PanelBaseProps) {
const presetsRef = useScrollIntoView<HTMLDivElement>('presets', location, nonce);
const linkRef = useScrollIntoView<HTMLDivElement>('link', location, nonce);
const reportRef = useScrollIntoView<HTMLDivElement>('report', location, nonce);
return (
<>
@@ -66,7 +66,10 @@ export default function ReportSettings() {
</thead>
<tbody>
{combinedReport.length === 0 && (
<Panel.TableEmpty label='Reports are generated when running through the show.' />
<Panel.TableEmpty
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) => {
@@ -12,25 +12,17 @@ import Tag from '../../../../common/components/tag/Tag';
import useUrlPresets, { useUpdateUrlPreset } from '../../../../common/hooks-query/useUrlPresets';
import { handleLinks } from '../../../../common/utils/linkUtils';
import * as Panel from '../../panel-utils/PanelUtils';
import { useEntityModal } from '../../panel-utils/useEntityModal';
import URLPresetForm from './composite/URLPresetForm';
type FormState = {
isOpen: boolean;
preset?: URLPreset;
};
const urlPresetsDocs = 'https://docs.getontime.no/features/url-presets/';
export default function URLPresets() {
const [formState, setFormState] = useState<FormState>({ isOpen: false, preset: undefined });
const presetModal = useEntityModal<URLPreset>();
const [actionError, setActionError] = useState<string | null>(null);
const { data, status } = useUrlPresets();
const { updatePreset, deletePreset, isMutating } = useUpdateUrlPreset();
const openNewForm = () => setFormState({ isOpen: true });
const openEditForm = (preset: URLPreset) => setFormState({ isOpen: true, preset });
const closeForm = () => setFormState({ isOpen: false, preset: undefined });
const persistPreset = async (preset: URLPreset) => {
setActionError(null);
try {
@@ -43,9 +35,12 @@ export default function URLPresets() {
return (
<Panel.Section>
<Panel.Card>
{presetModal.isOpen && (
<URLPresetForm urlPreset={presetModal.entity ?? undefined} onClose={presetModal.close} />
)}
<Panel.SubHeader>
URL presets
<Button onClick={openNewForm}>
<Button onClick={presetModal.openCreate}>
New <IoAdd />
</Button>
</Panel.SubHeader>
@@ -63,7 +58,6 @@ export default function URLPresets() {
</Panel.Section>
<Panel.Section>
<Panel.Loader isLoading={status === 'pending'} />
{formState.isOpen && <URLPresetForm urlPreset={formState.preset} onClose={closeForm} />}
{actionError && <Panel.Error>{actionError}</Panel.Error>}
<Panel.Table>
<thead>
@@ -76,7 +70,17 @@ export default function URLPresets() {
</tr>
</thead>
<tbody>
{data.length === 0 && <Panel.TableEmpty handleClick={openNewForm} />}
{data.length === 0 && (
<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={presetModal.openCreate}>
Create preset <IoAdd />
</Button>
}
/>
)}
{data.map((preset, index) => {
const isCuesheet = preset.target === OntimeView.Cuesheet;
return (
@@ -109,7 +113,7 @@ export default function URLPresets() {
<IoOpenOutline />
</IconButton>
<IconButton
onClick={() => openEditForm(preset)}
onClick={() => presetModal.openEdit(preset)}
variant='ghosted-white'
aria-label='Edit entry'
data-testid={`field__edit_${index}`}
@@ -6,9 +6,9 @@ import { maybeAxiosError, unwrapError } from '../../../../../common/api/utils';
import Button from '../../../../../common/components/buttons/Button';
import Input from '../../../../../common/components/input/input/Input';
import Textarea from '../../../../../common/components/input/textarea/Textarea';
import Modal from '../../../../../common/components/modal/Modal';
import Select, { SelectOption } from '../../../../../common/components/select/Select';
import { useUpdateUrlPreset } from '../../../../../common/hooks-query/useUrlPresets';
import { preventEscape } from '../../../../../common/utils/keyEvent';
import { isUrlSafe } from '../../../../../common/utils/regex';
import { enDash } from '../../../../../common/utils/styleUtils';
import { generateUrlPresetOptions } from '../../../../../common/utils/urlPresets';
@@ -28,6 +28,8 @@ const targetOptions: SelectOption<OntimeViewPresettable>[] = [
{ value: OntimeView.ProjectInfo, label: 'Project Info' },
];
const formId = 'url-preset-form';
const defaultValues: URLPreset = {
alias: '',
target: OntimeView.Timer,
@@ -134,95 +136,100 @@ export default function URLPresetForm({ urlPreset, onClose }: URLPresetFormProps
};
return (
<Panel.Indent
as='form'
onSubmit={handleSubmit(setupSubmit)}
onKeyDown={(event) => preventEscape(event, onClose)}
className={style.column}
>
<input hidden name='enabled' value='true' />
<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>
<Input placeholder='Paste URL' fluid ref={urlRef} disabled={isEditingCuesheet} />
<Button onClick={generateOptions} disabled={isEditingCuesheet}>
Generate
</Button>
</Panel.InlineElements>
</div>
</Panel.InlineElements>
{errors.alias?.message && <Panel.Error>{errors.alias.message}</Panel.Error>}
{!isEditingCuesheet && (
<Modal
isOpen
onClose={onClose}
showBackdrop
showCloseButton
title={urlPreset ? 'Edit URL preset' : 'Create URL preset'}
footerElements={
<>
<div>
{enDash} or {enDash}
</div>
<div>2. Choose a view and its parameters</div>
<div>
<Panel.Description>Target</Panel.Description>
<Select
options={targetOptions}
{...register('target', { required: 'Target is required' })}
value={watch('target')}
onValueChange={(value: OntimeViewPresettable | null) => {
if (value === null) return;
setValue('target', value, { shouldDirty: true });
}}
/>
</div>
<div>
<Panel.Description>Parameters</Panel.Description>
<Textarea
fluid
rows={3}
{...register('search', {
validate: validateParams,
})}
/>
<Panel.Error>{errors.search?.message}</Panel.Error>
</div>
</>
)}
{isCuesheet && (
<div>
<Panel.Description>Permissions</Panel.Description>
<CuesheetLinkOptions
initialRead={initialPermissions.current.read}
initialWrite={initialPermissions.current.write}
onChange={setCuesheetPermissions}
/>
</div>
)}
<div>
<Panel.Error>{errors.root?.message}</Panel.Error>
<Panel.InlineElements align='end'>
<Panel.Error>{errors.root?.message}</Panel.Error>
<Button onClick={onClose}>Cancel</Button>
<Button
variant='primary'
type='submit'
form={formId}
disabled={!isValid || (!isDirty && !permissionsDirty) || noReadAccess}
loading={isSubmitting || isMutating}
>
Save
</Button>
</Panel.InlineElements>
</div>
</Panel.Indent>
</>
}
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>
<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>
<Input placeholder='Paste URL' fluid ref={urlRef} disabled={isEditingCuesheet} />
<Button onClick={generateOptions} disabled={isEditingCuesheet}>
Generate
</Button>
</Panel.InlineElements>
</div>
</Panel.InlineElements>
{errors.alias?.message && <Panel.Error>{errors.alias.message}</Panel.Error>}
{!isEditingCuesheet && (
<>
<div>
{enDash} or {enDash}
</div>
<div>2. Choose a view and its parameters</div>
<div>
<Panel.Description>Target</Panel.Description>
<Select
options={targetOptions}
{...register('target', { required: 'Target is required' })}
value={watch('target')}
onValueChange={(value: OntimeViewPresettable | null) => {
if (value === null) return;
setValue('target', value, { shouldDirty: true });
}}
/>
</div>
<div>
<Panel.Description>Parameters</Panel.Description>
<Textarea
fluid
rows={3}
{...register('search', {
validate: validateParams,
})}
/>
<Panel.Error>{errors.search?.message}</Panel.Error>
</div>
</>
)}
{isCuesheet && (
<div>
<Panel.Description>Permissions</Panel.Description>
<CuesheetLinkOptions
initialRead={initialPermissions.current.read}
initialWrite={initialPermissions.current.write}
onChange={setCuesheetPermissions}
/>
</div>
)}
</form>
}
/>
);
}
@@ -1,5 +1,4 @@
import { CustomField, CustomFieldKey } from 'ontime-types';
import { useState } from 'react';
import { IoAdd } from 'react-icons/io5';
import { deleteCustomField, editCustomField, postCustomField } from '../../../../common/api/customFields';
@@ -10,30 +9,25 @@ import ExternalLink from '../../../../common/components/link/external-link/Exter
import useCustomFields from '../../../../common/hooks-query/useCustomFields';
import { customFieldsDocsUrl } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils';
import { useEntityModal } from '../../panel-utils/useEntityModal';
import CustomFieldEntry from './composite/CustomFieldEntry';
import CustomFieldForm from './composite/CustomFieldForm';
type CustomFieldEntity = CustomField & { key: CustomFieldKey };
export default function CustomFieldSettings() {
const { data, refetch } = useCustomFields();
const [isAdding, setIsAdding] = useState(false);
const fieldModal = useEntityModal<CustomFieldEntity>();
const handleInitiateCreate = () => {
setIsAdding(true);
};
const handleCancel = () => {
setIsAdding(false);
};
const handleCreate = async (customField: CustomField) => {
await postCustomField(customField);
refetch();
setIsAdding(false);
};
const handleEditField = async (key: CustomFieldKey, customField: CustomField) => {
await editCustomField(key, customField);
const handleSubmit = async (customField: CustomField) => {
const editing = fieldModal.entity;
if (editing) {
await editCustomField(editing.key, customField);
} else {
await postCustomField(customField);
}
refetch();
fieldModal.close();
};
const handleDelete = async (key: CustomFieldKey) => {
@@ -45,12 +39,24 @@ export default function CustomFieldSettings() {
}
};
const entries = Object.entries(data);
return (
<Panel.Section>
<Panel.Card>
{fieldModal.isOpen && (
<CustomFieldForm
onSubmit={handleSubmit}
onCancel={fieldModal.close}
initialColour={fieldModal.entity?.colour}
initialLabel={fieldModal.entity?.label}
initialKey={fieldModal.entity?.key}
initialType={fieldModal.entity?.type}
/>
)}
<Panel.SubHeader>
Custom fields
<Button onClick={handleInitiateCreate}>
<Button onClick={fieldModal.openCreate}>
New <IoAdd />
</Button>
</Panel.SubHeader>
@@ -66,7 +72,6 @@ export default function CustomFieldSettings() {
</Info>
</Panel.Section>
<Panel.Section>
{isAdding && <CustomFieldForm onSubmit={handleCreate} onCancel={handleCancel} />}
<Panel.Table>
<thead>
<tr>
@@ -78,7 +83,18 @@ export default function CustomFieldSettings() {
</tr>
</thead>
<tbody>
{Object.entries(data).map(([key, { colour, label, type }]) => {
{entries.length === 0 && (
<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={fieldModal.openCreate}>
Create custom field <IoAdd />
</Button>
}
/>
)}
{entries.map(([key, { colour, label, type }]) => {
return (
<CustomFieldEntry
key={key}
@@ -86,7 +102,7 @@ export default function CustomFieldSettings() {
colour={colour}
label={label}
type={type}
onEdit={handleEditField}
onEdit={() => fieldModal.openEdit({ key, colour, label, type })}
onDelete={handleDelete}
/>
);
@@ -5,11 +5,14 @@ import { uploadCustomView } from '../../../../common/api/customViews';
import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button';
import Input from '../../../../common/components/input/input/Input';
import Modal from '../../../../common/components/modal/Modal';
import * as Panel from '../../panel-utils/PanelUtils';
import { getFileError, getSlugError, getViewUrl, maxUploadLabel } from './customViews.utils';
import style from './CustomViews.module.scss';
const formId = 'custom-view-form';
interface CustomViewFormProps {
onComplete: () => void;
onClose: () => void;
@@ -53,62 +56,71 @@ export default function CustomViewForm({ onComplete, onClose }: CustomViewFormPr
};
return (
<Panel.Indent as='form' 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.stepTitle}>1. Choose a name</div>
<Panel.Description>Name</Panel.Description>
<Input
value={slug}
onChange={(event) => {
setSlug(event.target.value);
setSlugDirty(true);
}}
placeholder='my-view'
aria-label='Custom view name'
autoCapitalize='off'
autoComplete='off'
fluid
/>
<Panel.Description>
Use lowercase letters, numbers, and dashes. Example: <Panel.Highlight>my-view</Panel.Highlight>
</Panel.Description>
<Panel.Description>
Preview URL: <Panel.Highlight>{previewUrl}</Panel.Highlight>
</Panel.Description>
{slugDirty && slugError && <Panel.Error>{slugError}</Panel.Error>}
</div>
<div className={style.step}>
<div className={style.stepTitle}>2. Select index.html</div>
<Panel.Description>Upload file</Panel.Description>
<Panel.InlineElements wrap='wrap' className={style.filePicker}>
<Button onClick={() => fileInputRef.current?.click()}>
{selectedFile ? 'Replace index.html' : 'Choose index.html'}
<Modal
isOpen
onClose={onClose}
showBackdrop
showCloseButton
title='Upload custom view'
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>
<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>
</>
}
bodyElements={
<form id={formId} onSubmit={handleUpload} className={style.uploadForm}>
<input
ref={fileInputRef}
style={{ display: 'none' }}
type='file'
onChange={handleSelectFile}
accept='.html,text/html'
/>
{error && <Panel.Error>{error}</Panel.Error>}
<div className={style.step}>
<div className={style.stepTitle}>1. Choose a name</div>
<Panel.Description>Name</Panel.Description>
<Input
value={slug}
onChange={(event) => {
setSlug(event.target.value);
setSlugDirty(true);
}}
placeholder='my-view'
aria-label='Custom view name'
autoCapitalize='off'
autoComplete='off'
fluid
/>
<Panel.Description>
Use lowercase letters, numbers, and dashes. Example: <Panel.Highlight>my-view</Panel.Highlight>
</Panel.Description>
<Panel.Description>
Preview URL: <Panel.Highlight>{previewUrl}</Panel.Highlight>
</Panel.Description>
{slugDirty && slugError && <Panel.Error>{slugError}</Panel.Error>}
</div>
<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>
<div className={style.step}>
<div className={style.stepTitle}>2. Select index.html</div>
<Panel.Description>Upload file</Panel.Description>
<Panel.InlineElements wrap='wrap' className={style.filePicker}>
<Button onClick={() => fileInputRef.current?.click()}>
{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>
}
/>
);
}
@@ -37,13 +37,14 @@ export default function CustomViews() {
return (
<Panel.Section>
<Panel.Card>
{isUploadOpen && <CustomViewForm onComplete={handleUploadComplete} onClose={() => setIsUploadOpen(false)} />}
<Panel.SubHeader>
Custom views
<Panel.InlineElements>
<Button variant='ghosted' onClick={handleRestoreDemo} disabled={hasDemoView}>
Restore demo
</Button>
<Button onClick={() => setIsUploadOpen(true)} disabled={isUploadOpen}>
<Button onClick={() => setIsUploadOpen(true)}>
New <IoAdd />
</Button>
</Panel.InlineElements>
@@ -63,8 +64,6 @@ export default function CustomViews() {
<Panel.Section>
<Panel.Loader isLoading={status === 'pending'} />
{isUploadOpen && <CustomViewForm onComplete={handleUploadComplete} onClose={() => setIsUploadOpen(false)} />}
{actionError && <Panel.Error>{actionError}</Panel.Error>}
<CustomViewsList
@@ -1,5 +1,6 @@
import { CustomViewSummary } from 'ontime-types';
import { useState } from 'react';
import { IoCloudUploadOutline } from 'react-icons/io5';
import { deleteCustomView } from '../../../../common/api/customViews';
import { maybeAxiosError } from '../../../../common/api/utils';
@@ -53,7 +54,17 @@ export default function CustomViewsList({ views, onOpenUpload, onMutate, onError
</tr>
</thead>
<tbody>
{views.length === 0 && <Panel.TableEmpty handleClick={onOpenUpload} label='No custom views yet' />}
{views.length === 0 && (
<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) => (
<CustomViewsListItem
key={view.slug}
@@ -12,6 +12,12 @@
gap: 1rem;
}
.formColumn {
display: flex;
flex-direction: column;
gap: 1rem;
}
.current {
background-color: $blue-1100 !important; // fighting zebra styles
}
@@ -6,11 +6,11 @@ import ManageRundowns from './ManageRundowns';
import RundownDefaultSettings from './RundownDefaultSettings';
import SourcesPanel from './sources-panel/SourcesPanel';
export default function ManagePanel({ location }: PanelBaseProps) {
const defaultsRef = useScrollIntoView<HTMLDivElement>('defaults', location);
const customRef = useScrollIntoView<HTMLDivElement>('custom', location);
const rundownsRef = useScrollIntoView<HTMLDivElement>('rundowns', location);
const sheetsRef = useScrollIntoView<HTMLDivElement>('sheets', location);
export default function ManagePanel({ location, nonce }: PanelBaseProps) {
const defaultsRef = useScrollIntoView<HTMLDivElement>('defaults', location, nonce);
const customRef = useScrollIntoView<HTMLDivElement>('custom', location, nonce);
const rundownsRef = useScrollIntoView<HTMLDivElement>('rundowns', location, nonce);
const sheetsRef = useScrollIntoView<HTMLDivElement>('sheets', location, nonce);
return (
<>
@@ -3,9 +3,12 @@ import { useForm } from 'react-hook-form';
import Button from '../../../../common/components/buttons/Button';
import Input from '../../../../common/components/input/input/Input';
import Modal from '../../../../common/components/modal/Modal';
import { useMutateProjectRundowns } from '../../../../common/hooks-query/useProjectRundowns';
import * as Panel from '../../panel-utils/PanelUtils';
const formId = 'new-rundown-form';
type NewRundownFormState = {
title: string;
};
@@ -42,22 +45,33 @@ export function ManageRundownForm({ onClose }: ManageRundownForm) {
}, [setFocus]);
return (
<Panel.Indent as='form' onSubmit={handleSubmit(createRundown)}>
<Panel.Section>
<label>
<Panel.Description>Rundown title</Panel.Description>
<Input {...register('title')} fluid placeholder='Your rundown name' />
</label>
</Panel.Section>
<Panel.InlineElements relation='inner' align='end'>
<Button variant='ghosted' disabled={isSubmitting} onClick={onClose}>
Cancel
</Button>
<Button type='submit' variant='primary' disabled={isSubmitting}>
Create rundown
</Button>
</Panel.InlineElements>
{errors.root && <Panel.Error>{errors.root.message}</Panel.Error>}
</Panel.Indent>
<Modal
isOpen
onClose={onClose}
showBackdrop
showCloseButton
title='Create rundown'
footerElements={
<>
{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' loading={isSubmitting}>
Create rundown
</Button>
</>
}
bodyElements={
<form id={formId} onSubmit={handleSubmit(createRundown)}>
<Panel.Section>
<label>
<Panel.Description>Rundown title</Panel.Description>
<Input {...register('title')} fluid placeholder='Your rundown name' />
</label>
</Panel.Section>
</form>
}
/>
);
}
@@ -131,6 +131,23 @@ export default function ManageRundowns() {
</tr>
</thead>
<tbody>
{data?.rundowns?.length === 0 && (
<Panel.TableEmpty
title='No rundowns yet'
description='A project can hold several rundowns. Create one to start building your show.'
action={
<Button
variant='primary'
onClick={() => {
setActionError(null);
newHandlers.open();
}}
>
Create rundown <IoAdd />
</Button>
}
/>
)}
{data?.rundowns?.map(({ id, numEntries, title }) => {
const isLoaded = data.loaded === id;
const isRenaming = renamingRundown === id;
@@ -1,5 +1,4 @@
import { CustomField, CustomFieldKey } from 'ontime-types';
import { useState } from 'react';
import { IoPencil, IoTrash } from 'react-icons/io5';
import IconButton from '../../../../../common/components/buttons/IconButton';
@@ -7,7 +6,6 @@ import CopyTag from '../../../../../common/components/copy-tag/CopyTag';
import Swatch from '../../../../../common/components/input/colour-input/Swatch';
import Tag from '../../../../../common/components/tag/Tag';
import * as Panel from '../../../panel-utils/PanelUtils';
import CustomFieldForm from './CustomFieldForm';
import style from '../ManagePanel.module.scss';
@@ -16,35 +14,12 @@ interface CustomFieldEntryProps {
label: string;
fieldKey: string;
type: CustomField['type'];
onEdit: (key: CustomFieldKey, patch: CustomField) => Promise<void>;
onEdit: () => void;
onDelete: (key: CustomFieldKey) => Promise<void>;
}
export default function CustomFieldEntry(props: CustomFieldEntryProps) {
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 (
<tr>
@@ -61,7 +36,7 @@ export default function CustomFieldEntry(props: CustomFieldEntryProps) {
</CopyTag>
</td>
<Panel.InlineElements relation='inner' as='td'>
<IconButton variant='ghosted-white' aria-label='Edit entry' onClick={() => setIsEditing(true)}>
<IconButton variant='ghosted-white' aria-label='Edit entry' onClick={onEdit}>
<IoPencil />
</IconButton>
<IconButton variant='ghosted-destructive' aria-label='Delete entry' onClick={() => onDelete(fieldKey)}>
@@ -8,13 +8,15 @@ import Button from '../../../../../common/components/buttons/Button';
import Info from '../../../../../common/components/info/Info';
import SwatchSelect from '../../../../../common/components/input/colour-input/SwatchSelect';
import Input from '../../../../../common/components/input/input/Input';
import Modal from '../../../../../common/components/modal/Modal';
import RadioGroup from '../../../../../common/components/radio-group/RadioGroup';
import useCustomFields from '../../../../../common/hooks-query/useCustomFields';
import { preventEscape } from '../../../../../common/utils/keyEvent';
import * as Panel from '../../../panel-utils/PanelUtils';
import style from '../ManagePanel.module.scss';
const formId = 'custom-field-form';
interface CustomFieldsFormProps {
onSubmit: (field: CustomField) => Promise<void>;
onCancel: () => void;
@@ -85,65 +87,76 @@ export default function CustomFieldForm({
const isEditMode = initialKey !== undefined;
return (
<Panel.Indent as='form' onSubmit={handleSubmit(setupSubmit)} onKeyDown={(event) => preventEscape(event, onCancel)}>
<Info>
Please note that images can quickly deteriorate your app&apos;s performance.
<br />
Prefer using small, and compressed images.
</Info>
<div>
<Panel.Description>Type</Panel.Description>
<RadioGroup
orientation='horizontal'
disabled={isEditMode}
onValueChange={(value) => setValue('type', value, { shouldDirty: true })}
value={watch('type')}
items={[
{ value: 'text', label: 'Text' },
{ value: 'image', label: 'Image' },
]}
/>
</div>
<div className={style.twoCols}>
<label>
<Panel.Description>Label (only alphanumeric characters are allowed)</Panel.Description>
{errors.label && <Panel.Error>{errors.label.message}</Panel.Error>}
<Input
{...register('label', {
required: { value: true, message: 'Required field' },
onChange: () => setValue('key', customFieldLabelToKey(getValues('label')) ?? 'N/A'),
validate: (value) => {
if (value.trim().length === 0) return 'Required field';
if (!checkRegex.isAlphanumericWithSpace(value))
return 'Only alphanumeric characters and space are allowed';
if (!isEditMode) {
if (isEditMode && Object.keys(data).includes(value)) return 'Custom fields must be unique';
}
return true;
},
})}
fluid
/>
</label>
<Modal
isOpen
onClose={onCancel}
showBackdrop
showCloseButton
title={isEditMode ? 'Edit custom field' : 'Create custom field'}
footerElements={
<>
{errors.root && <Panel.Error>{errors.root.message}</Panel.Error>}
<Button variant='ghosted' onClick={onCancel}>
Cancel
</Button>
<Button type='submit' form={formId} variant='primary' disabled={!canSubmit} loading={isSubmitting}>
Save
</Button>
</>
}
bodyElements={
<form id={formId} onSubmit={handleSubmit(setupSubmit)} className={style.formColumn}>
<Info>
Please note that images can quickly deteriorate your app&apos;s performance.
<br />
Prefer using small, and compressed images.
</Info>
<div>
<Panel.Description>Type</Panel.Description>
<RadioGroup
orientation='horizontal'
disabled={isEditMode}
onValueChange={(value) => setValue('type', value, { shouldDirty: true })}
value={watch('type')}
items={[
{ value: 'text', label: 'Text' },
{ value: 'image', label: 'Image' },
]}
/>
</div>
<div className={style.twoCols}>
<label>
<Panel.Description>Label (only alphanumeric characters are allowed)</Panel.Description>
{errors.label && <Panel.Error>{errors.label.message}</Panel.Error>}
<Input
{...register('label', {
required: { value: true, message: 'Required field' },
onChange: () => setValue('key', customFieldLabelToKey(getValues('label')) ?? 'N/A'),
validate: (value) => {
if (value.trim().length === 0) return 'Required field';
if (!checkRegex.isAlphanumericWithSpace(value))
return 'Only alphanumeric characters and space are allowed';
if (!isEditMode && Object.hasOwn(data, customFieldLabelToKey(value) ?? '')) {
return 'Custom fields must be unique';
}
return true;
},
})}
fluid
/>
</label>
<label>
<Panel.Description>Key (use in Integrations and API)</Panel.Description>
<Input {...register('key')} variant='ghosted' readOnly fluid />
</label>
</div>
<label>
<Panel.Description>Colour</Panel.Description>
<SwatchSelect name='colour' value={colour} handleChange={(_field, value) => handleSelectColour(value)} />
</label>
{errors.root && <Panel.Error>{errors.root.message}</Panel.Error>}
<Panel.InlineElements relation='inner' align='end'>
<Button variant='ghosted' onClick={onCancel}>
Cancel
</Button>
<Button type='submit' variant='primary' disabled={!canSubmit} loading={isSubmitting}>
Save
</Button>
</Panel.InlineElements>
</Panel.Indent>
<label>
<Panel.Description>Key (use in Integrations and API)</Panel.Description>
<Input {...register('key')} variant='ghosted' readOnly fluid />
</label>
</div>
<label>
<Panel.Description>Colour</Panel.Description>
<SwatchSelect name='colour' value={colour} handleChange={(_field, value) => handleSelectColour(value)} />
</label>
</form>
}
/>
);
}
@@ -1,30 +1,3 @@
.emptyState {
padding: 3rem 1.5rem;
text-align: center;
}
.emptyMessage {
width: min(30rem, 100%);
margin-inline: auto;
}
.emptyTitle {
margin-bottom: 0.25rem;
color: rgba($gray-200, 0.72);
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 {
margin: 1rem auto 0;
}
.table {
width: 100%;
border-collapse: separate;
@@ -6,6 +6,7 @@ import { useMemo } from 'react';
import Button from '../../../../../../../common/components/buttons/Button';
import Tag from '../../../../../../../common/components/tag/Tag';
import { getRundownMetadata } from '../../../../../../../common/utils/rundownMetadata';
import * as Panel from '../../../../../panel-utils/PanelUtils';
import { getCellValue } from './previewTableUtils';
import style from './PreviewTable.module.scss';
@@ -123,23 +124,17 @@ export default function PreviewTable({
}
return (
<div className={style.emptyState}>
<div className={style.emptyMessage}>
<div className={style.emptyTitle}>{emptyTitle}</div>
<div className={style.emptyBody}>{emptyContent}</div>
{needsPreviewRefresh && (
<Button
className={style.emptyAction}
variant='primary'
onClick={onRefresh}
disabled={!canRefresh}
loading={isRefreshing}
>
<Panel.EmptyState
title={emptyTitle}
description={emptyContent}
action={
needsPreviewRefresh && (
<Button variant='primary' onClick={onRefresh} disabled={!canRefresh} loading={isRefreshing}>
Refresh preview
</Button>
)}
</div>
</div>
)
}
/>
);
}
@@ -10,9 +10,9 @@ import * as Panel from '../../panel-utils/PanelUtils';
import ClientControlPanel from './client-control/ClientControlPanel';
import LogExport from './NetworkLogExport';
export default function NetworkLogPanel({ location }: PanelBaseProps) {
const clientsRef = useScrollIntoView<HTMLDivElement>('clients', location);
const logRef = useScrollIntoView<HTMLDivElement>('log', location);
export default function NetworkLogPanel({ location, nonce }: PanelBaseProps) {
const clientsRef = useScrollIntoView<HTMLDivElement>('clients', location, nonce);
const logRef = useScrollIntoView<HTMLDivElement>('log', location, nonce);
return (
<>
@@ -68,6 +68,12 @@ export default function ClientList() {
</tr>
</thead>
<tbody>
{ontimeClients.length === 0 && (
<Panel.TableEmpty
title='No Ontime clients connected'
description='Editor, cuesheet and operator windows appear here as they connect.'
/>
)}
{ontimeClients.map(([key, client]) => {
const { identify, name, path } = client;
const isCurrent = id === key;
@@ -125,6 +131,12 @@ export default function ClientList() {
</tr>
</thead>
<tbody>
{otherClients.length === 0 && (
<Panel.TableEmpty
title='No other clients connected'
description='Views and integrations connected to this server are listed here.'
/>
)}
{otherClients.map(([key, client]) => {
const { name, type } = client;
@@ -1,29 +1,21 @@
import { ChangeEvent, useRef, useState } from 'react';
import { IoAdd } from 'react-icons/io5';
import { useSearchParams } from 'react-router';
import { uploadProjectFile } from '../../../../common/api/db';
import { invalidateAllCaches, maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button';
import { validateProjectFile } from '../../../../common/utils/uploadUtils';
import * as Panel from '../../panel-utils/PanelUtils';
import ProjectCreateForm from './ProjectCreateForm';
import useAppSettingsNavigation from '../../useAppSettingsNavigation';
import ProjectList from './ProjectList';
export default function ManageProjects() {
const [searchParams, setSearchParams] = useSearchParams();
const { setLocation } = useAppSettingsNavigation();
const [error, setError] = useState('');
const [loading, setLoading] = useState<'import' | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const isCreatingProject = searchParams.get('new') === 'true';
const handleToggleCreate = () => {
searchParams.set('new', isCreatingProject ? 'false' : 'true');
setSearchParams(searchParams);
};
const handleSelectFile = () => {
fileInputRef.current?.click();
};
@@ -49,11 +41,6 @@ export default function ManageProjects() {
setLoading(null);
};
const handleCloseForm = () => {
searchParams.delete('new');
setSearchParams(searchParams);
};
return (
<Panel.Section>
<input
@@ -68,21 +55,16 @@ export default function ManageProjects() {
<Panel.SubHeader>
Manage projects
<Panel.InlineElements>
<Button
onClick={handleSelectFile}
disabled={Boolean(loading) || isCreatingProject}
loading={loading === 'import'}
>
<Button onClick={handleSelectFile} disabled={Boolean(loading)} loading={loading === 'import'}>
Import
</Button>
<Button onClick={handleToggleCreate} disabled={Boolean(loading) || isCreatingProject}>
<Button onClick={() => setLocation('project__create')} disabled={Boolean(loading)}>
New <IoAdd />
</Button>
</Panel.InlineElements>
</Panel.SubHeader>
{error && <Panel.Error>{error}</Panel.Error>}
<Panel.Divider />
{isCreatingProject && <ProjectCreateForm onClose={handleCloseForm} />}
<ProjectList />
</Panel.Card>
</Panel.Section>
@@ -1,84 +0,0 @@
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { createProject } from '../../../../common/api/db';
import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button';
import Input from '../../../../common/components/input/input/Input';
import { preventEscape } from '../../../../common/utils/keyEvent';
import * as Panel from '../../panel-utils/PanelUtils';
import style from './ProjectPanel.module.scss';
interface ProjectCreateFromProps {
onClose: () => void;
}
type ProjectCreateFormValues = {
title?: string;
description?: string;
info?: string;
url?: string;
custom?: { title: string; value: string }[];
};
export default function ProjectCreateForm({ onClose }: ProjectCreateFromProps) {
const [error, setError] = useState<string | null>(null);
const {
handleSubmit,
register,
formState: { isSubmitting, isValid },
setFocus,
} = useForm<ProjectCreateFormValues>({
defaultValues: { title: '' },
values: { title: '' },
resetOptions: {
keepDirtyValues: true,
},
});
// set focus to first field
useEffect(() => {
setFocus('title');
}, [setFocus]);
const handleSubmitCreate = async (values: ProjectCreateFormValues) => {
try {
setError(null);
const filename = values.title ?? 'untitled';
await createProject({ filename });
onClose();
} catch (error) {
setError(maybeAxiosError(error));
}
};
return (
<Panel.Indent
as='form'
onSubmit={handleSubmit(handleSubmitCreate)}
onKeyDown={(event) => preventEscape(event, onClose)}
>
<Panel.Title>
Create new project
<Panel.InlineElements>
<Button onClick={onClose} variant='ghosted' disabled={isSubmitting}>
Cancel
</Button>
<Button disabled={!isValid} type='submit' loading={isSubmitting} variant='primary'>
Create project
</Button>
</Panel.InlineElements>
</Panel.Title>
{error && <Panel.Error>{error}</Panel.Error>}
<Panel.Section className={style.innerColumn}>
<Panel.Description>Project title</Panel.Description>
<Input fluid placeholder='Your project name' {...register('title')} />
</Panel.Section>
</Panel.Indent>
);
}
@@ -40,7 +40,7 @@ export default function ProjectList() {
if (status === 'pending') {
return (
<div className={style.empty}>
<div className={style.loaderBox}>
<Panel.Loader isLoading />
</div>
);
@@ -74,6 +74,12 @@ export default function ProjectList() {
</tr>
</thead>
<tbody>
{numProjects === 0 && (
<Panel.TableEmpty
title='No projects yet'
description='Projects are the show files Ontime saves your rundown and settings into. Create one to get started.'
/>
)}
{data.reorderedProjectFiles.map((project) => (
<ProjectListItem
key={project.filename}
@@ -158,13 +158,7 @@ export default function ProjectListItem({
</>
)}
</tr>
{showMergeForm && (
<tr>
<td colSpan={99}>
<ProjectMergeForm onClose={handleCancel} fileName={filename} />
</td>
</tr>
)}
{showMergeForm && <ProjectMergeForm onClose={handleCancel} fileName={filename} />}
<Dialog
isOpen={isDeleteOpen}
onClose={() => setDeleteOpen(false)}
@@ -7,6 +7,7 @@ import { getDb, patchData } from '../../../../common/api/db';
import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button';
import Info from '../../../../common/components/info/Info';
import Modal from '../../../../common/components/modal/Modal';
import Switch from '../../../../common/components/switch/Switch';
import { cx } from '../../../../common/utils/styleUtils';
import * as Panel from '../../panel-utils/PanelUtils';
@@ -14,6 +15,8 @@ import { makeProjectPatch } from './project.utils';
import style from './ProjectPanel.module.scss';
const formId = 'project-merge-form';
interface ProjectMergeFromProps {
onClose: () => void;
fileName: string;
@@ -77,68 +80,76 @@ export default function ProjectMergeForm({ onClose, fileName }: ProjectMergeFrom
};
return (
<Panel.Section as='form' onSubmit={handleSubmit(handleSubmitCreate)}>
<Panel.Title>
Partial project merge
<Panel.InlineElements>
<Modal
isOpen
onClose={onClose}
showBackdrop
showCloseButton
title='Partial project merge'
footerElements={
<>
{error && <Panel.Error>{error}</Panel.Error>}
<Button onClick={onClose} variant='ghosted' disabled={isSubmitting}>
Cancel
</Button>
<Button type='submit' disabled={!isValid || !isDirty} loading={isSubmitting} variant='primary'>
<Button type='submit' form={formId} disabled={!isValid || !isDirty} loading={isSubmitting} variant='primary'>
Merge
</Button>
</Panel.InlineElements>
</Panel.Title>
{error && <Panel.Error>{error}</Panel.Error>}
<Panel.Section className={cx([style.innerColumn, style.inlineLabels])}>
<Panel.Description>
Select data from <i>{`"${fileName}"`}</i> to merge into the current project.
</Panel.Description>
<Info type='warning'>
This process is irreversible and can result in data loss. <br />
You may want to create a duplicate backup beforehand.
</Info>
<label>
<Switch
size='large'
checked={watch('project')}
onCheckedChange={(value: boolean) => setValue('project', value, { shouldDirty: true })}
/>
Project data
</label>
<label>
<Switch
size='large'
checked={watch('rundowns')}
onCheckedChange={(value: boolean) => setValue('rundowns', value, { shouldDirty: true })}
/>
Rundown + Custom Fields
</label>
<label>
<Switch
size='large'
checked={watch('viewSettings')}
onCheckedChange={(value: boolean) => setValue('viewSettings', value, { shouldDirty: true })}
/>
View Settings
</label>
<label>
<Switch
size='large'
checked={watch('urlPresets')}
onCheckedChange={(value: boolean) => setValue('urlPresets', value, { shouldDirty: true })}
/>
URL Presets
</label>
<label>
<Switch
size='large'
checked={watch('automation')}
onCheckedChange={(value: boolean) => setValue('automation', value, { shouldDirty: true })}
/>
Automation Settings
</label>
</Panel.Section>
</Panel.Section>
</>
}
bodyElements={
<form id={formId} onSubmit={handleSubmit(handleSubmitCreate)}>
<Panel.Section className={cx([style.innerColumn, style.inlineLabels])}>
<Panel.Description>
Select data from <i>{`"${fileName}"`}</i> to merge into the current project.
</Panel.Description>
<Info type='warning'>
This process is irreversible and can result in data loss. <br />
You may want to create a duplicate backup beforehand.
</Info>
<label>
<Switch
size='large'
checked={watch('project')}
onCheckedChange={(value: boolean) => setValue('project', value, { shouldDirty: true })}
/>
Project data
</label>
<label>
<Switch
size='large'
checked={watch('rundowns')}
onCheckedChange={(value: boolean) => setValue('rundowns', value, { shouldDirty: true })}
/>
Rundown + Custom Fields
</label>
<label>
<Switch
size='large'
checked={watch('viewSettings')}
onCheckedChange={(value: boolean) => setValue('viewSettings', value, { shouldDirty: true })}
/>
View Settings
</label>
<label>
<Switch
size='large'
checked={watch('urlPresets')}
onCheckedChange={(value: boolean) => setValue('urlPresets', value, { shouldDirty: true })}
/>
URL Presets
</label>
<label>
<Switch
size='large'
checked={watch('automation')}
onCheckedChange={(value: boolean) => setValue('automation', value, { shouldDirty: true })}
/>
Automation Settings
</label>
</Panel.Section>
</form>
}
/>
);
}
@@ -55,7 +55,7 @@
margin-bottom: 1rem;
}
.empty {
.loaderBox {
height: 300px;
position: relative;
}
@@ -9,8 +9,8 @@ interface ProjectPanelProps extends PanelBaseProps {
setLocation: (location: SettingsOptionId) => void;
}
export default function ProjectPanel({ location, setLocation }: ProjectPanelProps) {
const manageProjectsRef = useScrollIntoView<HTMLDivElement>('list', location);
export default function ProjectPanel({ location, nonce, setLocation }: ProjectPanelProps) {
const manageProjectsRef = useScrollIntoView<HTMLDivElement>('list', location, nonce);
const handleQuickClose = () => {
setLocation('project');
@@ -9,13 +9,13 @@ import ProjectData from './ProjectData';
import ServerPortSettings from './ServerPortSettings';
import ViewSettings from './ViewSettings';
export default function SettingsPanel({ location }: PanelBaseProps) {
const dataRef = useScrollIntoView<HTMLDivElement>('data', location);
const generalRef = useScrollIntoView<HTMLDivElement>('general', location);
const viewRef = useScrollIntoView<HTMLDivElement>('view', location);
const customViewsRef = useScrollIntoView<HTMLDivElement>('custom-views', location);
const mcpRef = useScrollIntoView<HTMLDivElement>('mcp', location);
const portRef = useScrollIntoView<HTMLDivElement>('port', location);
export default function SettingsPanel({ location, nonce }: PanelBaseProps) {
const dataRef = useScrollIntoView<HTMLDivElement>('data', location, nonce);
const generalRef = useScrollIntoView<HTMLDivElement>('general', location, nonce);
const viewRef = useScrollIntoView<HTMLDivElement>('view', location, nonce);
const customViewsRef = useScrollIntoView<HTMLDivElement>('custom-views', location, nonce);
const mcpRef = useScrollIntoView<HTMLDivElement>('mcp', location, nonce);
const portRef = useScrollIntoView<HTMLDivElement>('port', location, nonce);
return (
<>
@@ -8,6 +8,8 @@ export type SettingsOption = {
label: string;
secondary?: Readonly<SettingsOption[]>;
highlight?: string;
/** alternative terms a user may search for when looking for this section */
keywords?: Readonly<string[]>;
};
const staticOptions = [
@@ -15,52 +17,94 @@ const staticOptions = [
id: 'settings',
label: 'Settings',
secondary: [
{ id: 'settings__data', label: 'Project data' },
{ id: 'settings__general', label: 'General settings' },
{ id: 'settings__view', label: 'View settings' },
{ id: 'settings__custom-views', label: 'Custom views' },
{ id: 'settings__mcp', label: 'MCP Server' },
...(isDocker ? [] : [{ id: 'settings__port', label: 'Server port' }]),
{ id: 'settings__data', label: 'Project data', keywords: ['title', 'description', 'logo', 'url', 'info'] },
{
id: 'settings__general',
label: 'General settings',
keywords: ['pin', 'password', 'lock', 'language', 'time format', 'timezone', '12 hour', '24 hour'],
},
{
id: 'settings__view',
label: 'View settings',
keywords: ['css', 'style', 'theme', 'override', 'translation', 'freeze', 'overtime'],
},
{ id: 'settings__custom-views', label: 'Custom views', keywords: ['html', 'upload', 'external', 'embed'] },
{ id: 'settings__mcp', label: 'MCP Server', keywords: ['ai', 'agent', 'model context protocol'] },
...(isDocker
? []
: [{ id: 'settings__port', label: 'Server port', keywords: ['http', 'network', 'address', 'listen'] }]),
],
},
{
id: 'project',
label: 'Project',
secondary: [
{ id: 'project__create', label: 'Create...' },
{ id: 'project__list', label: 'Manage projects' },
{ id: 'project__create', label: 'Create...', keywords: ['new project', 'quick start', 'wizard'] },
{
id: 'project__list',
label: 'Manage projects',
keywords: ['load', 'open', 'rename', 'duplicate', 'delete', 'download', 'import', 'backup', 'merge'],
},
],
},
{
id: 'manage',
label: 'Project settings',
secondary: [
{ id: 'manage__defaults', label: 'Rundown defaults' },
{ id: 'manage__custom', label: 'Custom fields' },
{ id: 'manage__rundowns', label: 'Manage rundowns' },
{ id: 'manage__sheets', label: 'Import spreadsheet' },
{ id: 'manage__sheets', label: 'Sync with Google Sheet' },
{
id: 'manage__defaults',
label: 'Rundown defaults',
keywords: ['duration', 'warning time', 'danger time', 'defaults'],
},
{
id: 'manage__custom',
label: 'Custom fields',
keywords: ['metadata', 'columns', 'extra data', 'image field'],
},
{
id: 'manage__rundowns',
label: 'Manage rundowns',
keywords: ['rundown', 'xlsx', 'excel', 'duplicate', 'load'],
},
{
id: 'manage__sheets',
label: 'Import spreadsheet',
keywords: ['google sheet', 'sync', 'spreadsheet', 'xlsx', 'excel', 'csv', 'export'],
},
],
},
{
id: 'automation',
label: 'Automation',
secondary: [
{ id: 'automation__settings', label: 'Automation settings' },
{ id: 'automation__automations', label: 'Manage automations' },
{ id: 'automation__triggers', label: 'Manage triggers' },
{
id: 'automation__settings',
label: 'Automation settings',
keywords: ['osc input', 'port', 'enable', 'remote control'],
},
{
id: 'automation__automations',
label: 'Manage automations',
keywords: ['osc', 'http', 'webhook', 'integration', 'api', 'output', 'action'],
},
{
id: 'automation__triggers',
label: 'Manage triggers',
keywords: ['lifecycle', 'on load', 'on start', 'on finish', 'on update'],
},
],
},
{
id: 'sharing',
label: 'Sharing and reporting',
secondary: [
{ id: 'sharing__presets', label: 'URL Presets' },
{ id: 'sharing__presets', label: 'URL Presets', keywords: ['alias', 'link', 'url', 'shortcut'] },
{
id: 'sharing__link',
label: 'Share link',
keywords: ['qr code', 'guest', 'cuesheet link', 'permissions', 'read only'],
},
{ id: 'sharing__report', label: 'Runtime report' },
{ id: 'sharing__report', label: 'Runtime report', keywords: ['actual times', 'csv', 'export', 'history'] },
],
},
{
@@ -70,20 +114,24 @@ const staticOptions = [
{
id: 'network__log',
label: 'Event log',
keywords: ['debug', 'errors', 'console', 'export log'],
},
{
id: 'network__clients',
label: 'Manage clients',
keywords: ['redirect', 'identify', 'rename client', 'connected'],
},
],
},
{
id: 'about',
label: 'About',
keywords: ['version', 'update', 'licence', 'license', 'credits'],
},
{
id: 'shutdown',
label: 'Shutdown',
keywords: ['quit', 'exit', 'close ontime'],
},
] as const;
@@ -92,6 +140,37 @@ export type SettingsOptionId =
| (typeof staticOptions)[number]['id']
| Extract<(typeof staticOptions)[number], { secondary: object }>['secondary'][number]['id'];
function matchesQuery(option: SettingsOption, query: string): boolean {
if (option.label.toLowerCase().includes(query)) {
return true;
}
return Boolean(option.keywords?.some((keyword) => keyword.includes(query)));
}
/**
* Filters the settings menu against a user query.
* A group is kept if it matches itself (with all its children) or if any of its children match.
*/
export function filterSettingsOptions(options: Readonly<SettingsOption[]>, query: string): SettingsOption[] {
const normalised = query.trim().toLowerCase();
if (!normalised) {
return [...options];
}
return options.reduce<SettingsOption[]>((accumulator, option) => {
if (matchesQuery(option, normalised)) {
accumulator.push(option);
return accumulator;
}
const secondary = option.secondary?.filter((child) => matchesQuery(child, normalised));
if (secondary?.length) {
accumulator.push({ ...option, secondary });
}
return accumulator;
}, []);
}
export function useAppSettingsMenu() {
const { data } = useAppVersion();
@@ -1,12 +1,28 @@
import { useCallback, useMemo } from 'react';
import { useSearchParams } from 'react-router';
import { create } from 'zustand';
import { SettingsOptionId } from './useAppSettingsMenu';
const settingsKey = 'settings';
interface NavigationNonceStore {
nonce: number;
bump: () => void;
}
/**
* Incremented on every navigation request, including requests to the location we are already in.
* Sections use it to scroll themselves into view again when the user re-selects them.
*/
const useNavigationNonce = create<NavigationNonceStore>((set) => ({
nonce: 0,
bump: () => set((state) => ({ nonce: state.nonce + 1 })),
}));
export default function useAppSettingsNavigation() {
const [searchParams, setSearchParams] = useSearchParams();
const nonce = useNavigationNonce((store) => store.nonce);
const selectedPanel = useMemo(
() => (searchParams.get(settingsKey) as SettingsOptionId | null) ?? 'project',
@@ -24,9 +40,10 @@ export default function useAppSettingsNavigation() {
(panelId: SettingsOptionId) => {
searchParams.set(settingsKey, panelId);
setSearchParams(searchParams);
useNavigationNonce.getState().bump();
},
[searchParams, setSearchParams],
);
return { isOpen, panel, location, setLocation, close };
return { isOpen, panel, location, nonce, setLocation, close };
}