Compare commits

..

21 Commits

Author SHA1 Message Date
Carlos Valente 9a6feed49b refactor: improve groups and time end 2026-06-28 09:08:24 +02:00
Carlos Valente c6cf9f2bf7 fixup! refactor: add to correct section 2026-06-28 08:48:14 +02:00
Carlos Valente 2f73303554 refactor: simplify object creation 2026-06-28 08:46:11 +02:00
Carlos Valente 96943ca528 refactor: add to correct section 2026-06-28 08:45:54 +02:00
Carlos Valente 8cfffd32c7 fix: duplicate rundowns from service layer 2026-06-27 16:30:04 +02:00
Carlos Valente 24a606a246 fixup! improve linting and documentation 2026-06-27 16:27:59 +02:00
Carlos Valente c322028821 docs 2026-06-27 16:27:59 +02:00
Carlos Valente 9f40d962ea improve linting and documentation 2026-06-27 16:27:59 +02:00
Claude 1e851ae40d refactor(mcp): port tools to current services with typed translation handlers
- Tool handlers are now a typed map of thin translation wrappers: wire
  arguments are cast once at the boundary to types derived (Pick) from
  ontime-types, and all service calls are fully typed — a change to a
  service signature or domain type now fails compilation in api-mcp
  instead of drifting silently. No more 'as never' casts.
- Entry mutations target the loaded rundown explicitly, matching the
  rundownId-first service signatures introduced with secondary rundowns.
- Rundown create/rename/delete/duplicate call the rundown service;
  mcp.service.ts and its duplicated router logic are deleted.
- ontime_create_entry supports all entry types (event, delay, milestone,
  group) and entry tools accept flag and custom field values, matching
  what the workflow prompts already instruct agents to do. Tool names
  use entry terminology consistently with the service layer.
- ontime_create_project description fixed: creating a project switches
  to it.

https://claude.ai/code/session_01V27pYyjw2PGSWy7wNtiWKj
2026-06-27 16:27:59 +02:00
Claude 2bcda2dfa4 refactor(mcp): scope bearer authentication to the MCP route
Reverts the change to the shared authenticate middleware, which made
every authenticated route accept Authorization: Bearer tokens. Bearer
support now lives in a wrapper middleware inside api-mcp, mounted only
on /mcp; all other requests fall through to the app middleware
unchanged. Removing the api-mcp module restores stock auth behaviour.

https://claude.ai/code/session_01V27pYyjw2PGSWy7wNtiWKj
2026-06-27 16:27:59 +02:00
Claude e2a2ea1d24 refactor(rundown): extract rundown rename/delete/duplicate into service layer
Moves the logic previously inlined in the router handlers into
rundown.service.ts so other consumers (such as the MCP server) can reuse
it without duplication. Status codes and error messages are unchanged.

https://claude.ai/code/session_01V27pYyjw2PGSWy7wNtiWKj
2026-06-27 16:27:59 +02:00
Claude 5be596b83e refactor(mcp): dispatch map for resources, userPrompt helper, enrich prompt guidance
mcp.resources.ts: replace 6-branch if-chain with a RESOURCE_READERS dispatch map.
Each resource is now a one-liner { mimeType, read } entry; handleReadResource
collapses to a 3-line lookup with a single throw.

mcp.prompts.ts: extract repeated message-wrapping boilerplate into userPrompt()
helper, reducing each prompt from ~10 lines of structure to a single call.

Enrich all four prompts with operational domain knowledge:
- create_rundown_from_agenda: entry type selection (event/milestone/delay/group),
  timerType and endAction guidance, linkStart chain semantics, flag usage, colour
  conventions
- bulk_edit_rundown: timeStrategy (lock-end vs lock-duration), endAction automation
  risk, clarified time-shift mechanics
- validate_rundown: totalDelay/totalDays checks, linkStart chain validation,
  play-next chain disclosure, flag audit
- restructure_rundown: group awareness (entries vs order arrays), insert order type,
  move-out vs move-in patterns

https://claude.ai/code/session_01U24MeuUacYXeQhbX3tatEe
2026-06-27 16:25:47 +02:00
Claude ab15a65585 simplify: unify 405 handlers, fix double-read in renameRundown, guard mcpClientConfig
- mcp.router.ts: extract identical GET/DELETE 405 responses into methodNotAllowed
- mcp.service.ts: pass the already-patched rundown object to initRundown instead
  of re-fetching it from the data provider
- McpSection.tsx: compute mcpClientConfig only when mcpEndpointUrl is truthy

https://claude.ai/code/session_01U24MeuUacYXeQhbX3tatEe
2026-06-27 16:25:47 +02:00
Claude 65ef442a9d refactor(mcp): consolidate documentation into mcp.schema.ts
Single file to update when the data model changes:
- EVENT_TIMER_FIELDS and EVENT_WRITABLE_FIELDS (tool JSON schemas) moved from mcp.tools.ts
- ONTIME_SCHEMA_MARKDOWN and ONTIME_DOCS_MARKDOWN moved from mcp.resources.ts
- mcp.tools.ts and mcp.resources.ts now import from mcp.schema.ts
- Removed inline data model notes from the agenda prompt; replaced with a reference
  to the ontime://schema resource
- Fixed inaccurate enum values in schema (Milestone type added, TimerType/EndAction
  corrected to match ontime-types)

https://claude.ai/code/session_01U24MeuUacYXeQhbX3tatEe
2026-06-27 16:25:47 +02:00
Claude 71a7f26493 refactor(mcp): address review — split files, fix naming, stateless router
Split the monolithic mcp.router.ts into focused modules:
- mcp.service.ts: coordination helpers (rundownListResponse, renameRundown, deleteRundown)
- mcp.tools.ts: tool definitions (ontime_ prefix, annotation presets, shared field schemas,
  CHARACTER_LIMIT truncation) + handleToolCall dispatcher
- mcp.prompts.ts: prompt definitions + handleGetPrompt
- mcp.resources.ts: resource definitions (schema, live data, docs) + handleReadResource
- mcp.server.ts: createMcpServer factory that wires the above modules
- mcp.router.ts: thin stateless Express router (POST/GET405/DELETE405)

Other changes per review comments:
- Rename ontime_delete_event → ontime_delete_entry (entry vs event naming convention)
- Remove hardcoded cue/colour prefix conventions from tool descriptions and prompts;
  replace with "ask the user what convention they prefer"
- Remove automations tools (left for a later PR)
- Add Bearer auth comment in authenticate.ts explaining the MCP use case
- Extract McpSection component from FeaturePanel to isolate URL state re-renders
- Update bulk_edit prompt to mention linkStart cascade behaviour
- Add 405 comments explaining why GET/DELETE are not supported in stateless mode

https://claude.ai/code/session_01U24MeuUacYXeQhbX3tatEe
2026-06-27 16:25:47 +02:00
Claude b850a51204 mcp: add ontime://docs resource for documentation discoverability
Exposes a curated index of docs.getontime.no topic URLs as the
ontime://docs MCP resource, and adds a "Further reading" footer to
ontime://schema so agents reading the data model know where to find
deeper documentation.

https://claude.ai/code/session_01U24MeuUacYXeQhbX3tatEe
2026-06-27 16:25:47 +02:00
Claude 0094c15da2 feat(mcp): expose rundown and schema as readable MCP resources
Adds five resources so the agent can load context once per session
instead of re-fetching via tools, and so it can ground its answers in
Ontime's data model without guessing:

- ontime://schema — markdown reference for the event/rundown/group
  model, time format (ms from midnight), cue conventions, colours,
  playback states
- ontime://rundown/current — full currently-loaded rundown (JSON)
- ontime://rundowns — every rundown in the project plus the loaded ID
- ontime://project/info — project metadata
- ontime://project/custom-fields — custom field definitions

Declares the resources capability on the MCP server and registers
resources/list and resources/read handlers that dispatch by URI.

https://claude.ai/code/session_01U24MeuUacYXeQhbX3tatEe
2026-06-27 16:25:47 +02:00
Claude e7d62f44e7 feat(mcp): batch rundown edits, project file management, workflow prompts
Adds 8 tools and 3 prompts to cover four priority pre-production flows:
build-from-agenda (batch create), bulk edit, validate, restructure.

Tools:
- create_events_batch, batch_update_events — avoid per-event round trips
- list_projects, load_project, create_project, rename_project,
  duplicate_project, delete_project — project file management

Prompts:
- bulk_edit_rundown — teaches the agent to use batch_update_events vs
  per-event updates based on whether fields are shared
- validate_rundown — pre-show checks for cue gaps, overlaps, missing
  fields, duration anomalies
- restructure_rundown — teaches reorder_event sequencing
- create_rundown_from_agenda — updated to use create_events_batch

Safety:
- Mutating tools now attach a playback warning to their response when
  timer state is not stopped, so the agent can relay it to the user
- create_automation description mentions the in-app test button

https://claude.ai/code/session_01U24MeuUacYXeQhbX3tatEe
2026-06-27 16:25:47 +02:00
Claude 3c8d7e51f5 chore: ignore .claude/ metadata directory
https://claude.ai/code/session_01U24MeuUacYXeQhbX3tatEe
2026-06-27 16:25:47 +02:00
Claude 3da3950fce fix(mcp): session ID mismatch, rename_rundown data loss, DELETE status
- Session ID: deterministic UUID passed to sessionIdGenerator so the
  map key matches the mcp-session-id header sent to clients; previously
  two independent randomUUID() calls produced different IDs, breaking
  all post-initialize requests
- rename_rundown: use spread { ...rundown, title } instead of narrow
  structuredClone cast that silently stripped order/entries/revision
- DELETE /mcp: return 204 No Content instead of 200 with body

https://claude.ai/code/session_01U24MeuUacYXeQhbX3tatEe
2026-06-27 16:25:47 +02:00
Claude ac3694ce4d feat: add MCP server for AI agent integration
Adds a Streamable HTTP MCP server at /mcp that exposes 19 tools
covering rundown entries, rundown management, runtime state, project
info, automations, and custom fields. Includes Bearer token auth
support and a UI card in Settings > Sharing showing the endpoint URL
and Claude Desktop config.

https://claude.ai/code/session_01U24MeuUacYXeQhbX3tatEe
2026-06-27 16:25:47 +02:00
200 changed files with 1687 additions and 6979 deletions
-5
View File
@@ -1,5 +0,0 @@
# CodeGraph data files — local to each machine, not for committing.
# Ignore everything in .codegraph/ except this file itself, so transient
# files (the database, daemon.pid, sockets, logs) never show up in git.
*
!.gitignore
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getontime/cli", "name": "@getontime/cli",
"version": "4.11.0", "version": "4.9.0",
"author": "Carlos Valente", "author": "Carlos Valente",
"description": "Time keeping for live events", "description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime", "repository": "https://github.com/cpvalente/ontime",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "ontime-ui", "name": "ontime-ui",
"version": "4.11.0", "version": "4.9.0",
"private": true, "private": true,
"type": "module", "type": "module",
"dependencies": { "dependencies": {
-10
View File
@@ -6,7 +6,6 @@ import {
ProjectRundownsList, ProjectRundownsList,
RenumberCues, RenumberCues,
Rundown, Rundown,
RundownImportPayload,
TransientEventPayload, TransientEventPayload,
} from 'ontime-types'; } from 'ontime-types';
@@ -83,15 +82,6 @@ export async function deleteRundown(rundownId: RundownId): Promise<AxiosResponse
return axios.delete(`${rundownPath}/${rundownId}`); return axios.delete(`${rundownPath}/${rundownId}`);
} }
/**
* HTTP request to apply an imported rundown using a merge strategy or into a new rundown
*/
export async function importRundownWithOptions(
payload: RundownImportPayload,
): Promise<AxiosResponse<ProjectRundownsList>> {
return axios.post(`${rundownPath}/import`, payload);
}
// #endregion operations on project rundowns ====================== // #endregion operations on project rundowns ======================
// #region operations on rundown entries ========================== // #region operations on rundown entries ==========================
@@ -6,8 +6,7 @@ import SwatchPicker from './SwatchPicker';
import style from './SwatchSelect.module.scss'; import style from './SwatchSelect.module.scss';
interface ColourInputProps { interface ColourInputProps {
/** undefined represents values which do not agree, no swatch is highlighted */ value: string;
value: string | undefined;
name: 'colour'; name: 'colour';
handleChange: (newValue: 'colour', name: string) => void; handleChange: (newValue: 'colour', name: string) => void;
} }
@@ -45,11 +44,7 @@ export default function SwatchSelect(props: ColourInputProps) {
{colours.map((colour) => ( {colours.map((colour) => (
<Swatch key={colour} color={colour} onClick={setColour} isSelected={value === colour} /> <Swatch key={colour} color={colour} onClick={setColour} isSelected={value === colour} />
))} ))}
<SwatchPicker <SwatchPicker color={value} onChange={setColour} isSelected={!colours.includes(value)} />
color={value ?? ''}
onChange={setColour}
isSelected={value !== undefined && !colours.includes(value)}
/>
</div> </div>
); );
} }
@@ -1,31 +0,0 @@
import { shouldSubmitValue } from '../useReactiveTextInput';
describe('shouldSubmitValue()', () => {
it('submits a value which differs from the initial value', () => {
expect(shouldSubmitValue('new', 'old')).toBe(true);
});
it('does not submit a value which has not changed', () => {
expect(shouldSubmitValue('same', 'same')).toBe(false);
});
it('submits an empty value when the initial value is known', () => {
// clearing a field which has a value is a valid edit
expect(shouldSubmitValue('', 'old')).toBe(true);
});
it('does not submit an empty value when the initial value is unknown', () => {
// an unknown value renders as an empty field, submitting it on blur
// would overwrite every entry being edited with an empty value
expect(shouldSubmitValue('', undefined)).toBe(false);
});
it('submits a typed value when the initial value is unknown', () => {
expect(shouldSubmitValue('typed', undefined)).toBe(true);
});
it('submits an unchanged value when the caller opts in', () => {
expect(shouldSubmitValue('same', 'same', true)).toBe(true);
expect(shouldSubmitValue('', undefined, true)).toBe(true);
});
});
@@ -1,23 +1,6 @@
import { HotkeyItem, getHotkeyHandler } from '@mantine/hooks'; import { HotkeyItem, getHotkeyHandler } from '@mantine/hooks';
import { ChangeEvent, KeyboardEvent, RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { ChangeEvent, KeyboardEvent, RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react';
/**
* Whether a value should be sent to the server
* An undefined initial value means that the value is unknown, for example when several
* entries are being edited and they do not agree on a value
* It is shown as an empty field, so submitting it would overwrite data the user has not edited
*/
export function shouldSubmitValue(
valueToSubmit: string,
initialText: string | undefined,
allowSubmitSameValue?: boolean,
): boolean {
if (allowSubmitSameValue) {
return true;
}
return valueToSubmit !== (initialText ?? '');
}
interface UseReactiveTextInputReturn { interface UseReactiveTextInputReturn {
value: string; value: string;
onChange: (event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => void; onChange: (event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
@@ -26,8 +9,7 @@ interface UseReactiveTextInputReturn {
} }
export default function useReactiveTextInput( export default function useReactiveTextInput(
/** undefined represents an unknown value, the field is shown empty and blurring it submits nothing */ initialText: string,
initialText: string | undefined,
submitCallback: (newValue: string) => void, submitCallback: (newValue: string) => void,
ref: RefObject<HTMLInputElement | HTMLTextAreaElement | null>, ref: RefObject<HTMLInputElement | HTMLTextAreaElement | null>,
options?: { options?: {
@@ -40,7 +22,7 @@ export default function useReactiveTextInput(
allowKeyboardNavigation?: boolean; allowKeyboardNavigation?: boolean;
}, },
): UseReactiveTextInputReturn { ): UseReactiveTextInputReturn {
const [text, setText] = useState<string>(initialText ?? ''); const [text, setText] = useState<string>(initialText);
// track whether we are submitting via a submit key (eg enter) and avoid submitting again on blur // track whether we are submitting via a submit key (eg enter) and avoid submitting again on blur
const isKeyboardSubmitting = useRef(false); const isKeyboardSubmitting = useRef(false);
// track escape to prevent the subsequent blur from submitting // track escape to prevent the subsequent blur from submitting
@@ -75,7 +57,8 @@ export default function useReactiveTextInput(
*/ */
const handleSubmit = useCallback( const handleSubmit = useCallback(
(valueToSubmit: string) => { (valueToSubmit: string) => {
if (!shouldSubmitValue(valueToSubmit, initialText, options?.allowSubmitSameValue)) { // No need to update if it hasn't changed
if (valueToSubmit === initialText && !options?.allowSubmitSameValue) {
options?.onCancelUpdate?.(); options?.onCancelUpdate?.();
} else { } else {
const cleanVal = valueToSubmit.trim(); const cleanVal = valueToSubmit.trim();
@@ -103,10 +86,10 @@ export default function useReactiveTextInput(
const handleEscape = useCallback(() => { const handleEscape = useCallback(() => {
isEscaping.current = true; isEscaping.current = true;
// No need to update if it hasn't changed // No need to update if it hasn't changed
setText(initialText ?? ''); setText(initialText);
// force the text to be the initial value // force the text to be the initial value
if (ref.current) { if (ref.current) {
ref.current.value = initialText ?? ''; ref.current.value = initialText;
} }
options?.onCancelUpdate?.(); options?.onCancelUpdate?.();
setTimeout(() => ref.current?.blur()); // Immediate timeout to ensure text is set before blurring setTimeout(() => ref.current?.blur()); // Immediate timeout to ensure text is set before blurring
@@ -41,10 +41,7 @@ export default function TimeInput<T extends string>({
* @description Resets input value to given * @description Resets input value to given
*/ */
const resetValue = useCallback(() => { const resetValue = useCallback(() => {
if (time === undefined) { if (typeof time !== 'number' || isNaN(time)) {
// there is no value to show, we leave the field empty so that the placeholder is visible
setValue('');
} else if (typeof time !== 'number' || isNaN(time)) {
setValue('00:00:00'); setValue('00:00:00');
} else if (shouldFormat) { } else if (shouldFormat) {
setValue(formatTime(time)); setValue(formatTime(time));
@@ -1,14 +0,0 @@
.kbd {
display: inline-block;
min-width: 1.5rem;
padding: 0.0625rem 0.375rem;
border-radius: 2px;
background-color: $gray-1100;
color: $ui-white;
box-shadow: 0 0 3px rgba(0, 0, 0, 0.4);
font-family: monospace;
font-size: calc(1rem - 4px);
line-height: 1.5;
text-align: center;
white-space: nowrap;
}
@@ -1,7 +0,0 @@
import { PropsWithChildren } from 'react';
import style from './Kbd.module.scss';
export default function Kbd({ children }: PropsWithChildren) {
return <kbd className={style.kbd}>{children}</kbd>;
}
@@ -38,6 +38,8 @@ $progress-bar-br: 3px;
.multiprogress-bar__indicator-bar { .multiprogress-bar__indicator-bar {
background-color: var(--background-color-override, $ui-black); background-color: var(--background-color-override, $ui-black);
opacity: 0.8; opacity: 0.8;
transition: 1s linear;
transition-property: width;
.multiprogress-bar--ignore-css-override & { .multiprogress-bar--ignore-css-override & {
background-color: $ui-black; background-color: $ui-black;
@@ -1,6 +1,5 @@
import { MaybeNumber } from 'ontime-types'; import { MaybeNumber } from 'ontime-types';
import { useAnimatedProgress } from '../../hooks/useAnimatedProgress';
import { getProgress } from '../../utils/getProgress'; import { getProgress } from '../../utils/getProgress';
import { cx } from '../../utils/styleUtils'; import { cx } from '../../utils/styleUtils';
@@ -35,7 +34,7 @@ export default function MultiPartProgressBar(props: MultiPartProgressBar) {
className = '', className = '',
} = props; } = props;
const percentRemaining = 100 - useAnimatedProgress(now, complete); const percentRemaining = 100 - getProgress(now, complete);
const dangerWidth = danger ? 100 - getProgress(danger, complete) : 0; const dangerWidth = danger ? 100 - getProgress(danger, complete) : 0;
const warningWidth = warning ? 100 - dangerWidth - getProgress(warning, complete) : 0; const warningWidth = warning ? 100 - dangerWidth - getProgress(warning, complete) : 0;
const isOvertime = now !== null && now < 0; const isOvertime = now !== null && now < 0;
@@ -8,7 +8,6 @@ import { useLocation } from 'react-router';
import { isLocalhost, supportsFullscreen } from '../../../externals'; import { isLocalhost, supportsFullscreen } from '../../../externals';
import { canUseWakeLock, useKeepAwakeOptions } from '../../../features/keep-awake/useWakeLock'; import { canUseWakeLock, useKeepAwakeOptions } from '../../../features/keep-awake/useWakeLock';
import { navigatorConstants } from '../../../viewerConfig'; import { navigatorConstants } from '../../../viewerConfig';
import useUrlPresets from '../../hooks-query/useUrlPresets';
import { useIsSmallScreen } from '../../hooks/useIsSmallScreen'; import { useIsSmallScreen } from '../../hooks/useIsSmallScreen';
import { useClientStore } from '../../stores/clientStore'; import { useClientStore } from '../../stores/clientStore';
import { useViewOptionsStore } from '../../stores/viewOptions'; import { useViewOptionsStore } from '../../stores/viewOptions';
@@ -106,8 +105,6 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
{route.label} {route.label}
</ClientLink> </ClientLink>
))} ))}
<PresetNavigation isSmallScreen={isSmallScreen} onClose={onClose} />
</div> </div>
{isLocalhost && ( {isLocalhost && (
@@ -120,27 +117,3 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
</Dialog.Root> </Dialog.Root>
); );
} }
function PresetNavigation({ isSmallScreen, onClose }: { isSmallScreen: boolean; onClose: () => void }) {
const location = useLocation();
const { data: urlPresets } = useUrlPresets();
const navPresets = urlPresets.filter((preset) => preset.enabled && preset.displayInNav);
if (navPresets.length === 0) return null;
return (
<>
<hr className={style.separator} />
{navPresets.map((preset) => (
<ClientLink
key={preset.alias}
to={`preset/${preset.alias}`}
current={location.pathname === `/preset/${preset.alias}`}
postAction={isSmallScreen ? onClose : undefined}
>
{preset.alias}
</ClientLink>
))}
</>
);
}
@@ -1,8 +1,6 @@
import { useDisclosure, useHotkeys } from '@mantine/hooks'; import { useDisclosure, useHotkeys } from '@mantine/hooks';
import { memo } from 'react'; import { memo } from 'react';
import { useSearchParams } from 'react-router';
import { hasCustomParams, useSavedViewParams } from '../../stores/savedViewParams';
import { useViewParamsEditorStore } from '../view-params-editor/viewParamsEditor.store'; import { useViewParamsEditorStore } from '../view-params-editor/viewParamsEditor.store';
import FloatingNavigation from './floating-navigation/FloatingNavigation'; import FloatingNavigation from './floating-navigation/FloatingNavigation';
import NavigationMenu from './NavigationMenu'; import NavigationMenu from './NavigationMenu';
@@ -19,9 +17,6 @@ export default memo(ViewNavigationMenu);
function ViewNavigationMenu({ isNavigationLocked, suppressSettings }: ViewNavigationMenuProps) { function ViewNavigationMenu({ isNavigationLocked, suppressSettings }: ViewNavigationMenuProps) {
const [isMenuOpen, menuHandler] = useDisclosure(); const [isMenuOpen, menuHandler] = useDisclosure();
const { open: showEditFormDrawer } = useViewParamsEditorStore(); const { open: showEditFormDrawer } = useViewParamsEditorStore();
const [searchParams] = useSearchParams();
const savedParams = useSavedViewParams((store) => store.params);
const hasSavedChanges = hasCustomParams(searchParams) || Object.keys(savedParams).length > 0;
useHotkeys([ useHotkeys([
[ [
@@ -51,7 +46,6 @@ function ViewNavigationMenu({ isNavigationLocked, suppressSettings }: ViewNaviga
<FloatingNavigation <FloatingNavigation
toggleMenu={isNavigationLocked ? undefined : menuHandler.toggle} toggleMenu={isNavigationLocked ? undefined : menuHandler.toggle}
toggleSettings={suppressSettings ? undefined : showEditFormDrawer} toggleSettings={suppressSettings ? undefined : showEditFormDrawer}
hasSavedChanges={hasSavedChanges}
/> />
{!isNavigationLocked && <NavigationMenu isOpen={isMenuOpen} onClose={menuHandler.close} />} {!isNavigationLocked && <NavigationMenu isOpen={isMenuOpen} onClose={menuHandler.close} />}
</> </>
@@ -1,29 +1,4 @@
.trailing {
display: inline-flex;
align-items: center;
gap: 0.5rem;
margin-left: auto;
}
.linkIcon { .linkIcon {
margin-left: auto;
@include rotate-fourty-five; @include rotate-fourty-five;
} }
.indicator {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
background-color: $active-indicator;
pointer-events: none;
flex-shrink: 0;
}
.clear {
opacity: 0.6;
transition: opacity $transition-time-action;
&:hover,
&:focus-visible {
opacity: 1;
}
}
@@ -1,17 +1,9 @@
import { MouseEvent, PropsWithChildren } from 'react'; import { PropsWithChildren } from 'react';
import { IoArrowUp, IoCloseOutline } from 'react-icons/io5'; import { IoArrowUp } from 'react-icons/io5';
import { useLocation, useNavigate, useSearchParams } from 'react-router'; import { useNavigate } from 'react-router';
import { useElectronEvent } from '../../../hooks/useElectronEvent'; import { useElectronEvent } from '../../../hooks/useElectronEvent';
import {
hasCustomParams,
reservedParams,
stripReservedParams,
useSavedViewParams,
} from '../../../stores/savedViewParams';
import { handleLinks } from '../../../utils/linkUtils'; import { handleLinks } from '../../../utils/linkUtils';
import IconButton from '../../buttons/IconButton';
import Tooltip from '../../tooltip/Tooltip';
import NavigationMenuItem from '../navigation-menu-item/NavigationMenuItem'; import NavigationMenuItem from '../navigation-menu-item/NavigationMenuItem';
import style from './ClientLink.module.scss'; import style from './ClientLink.module.scss';
@@ -24,104 +16,32 @@ interface ClientLinkProps {
export default function ClientLink({ current, to, postAction, children }: PropsWithChildren<ClientLinkProps>) { export default function ClientLink({ current, to, postAction, children }: PropsWithChildren<ClientLinkProps>) {
const { isElectron } = useElectronEvent(); const { isElectron } = useElectronEvent();
const navigate = useNavigate();
if (isElectron) { if (isElectron) {
return ( return (
<ElectronNavigationItem current={current} to={to} postAction={postAction}> <NavigationMenuItem
active={current}
onClick={() => {
handleLinks(to);
postAction?.();
}}
>
{children} {children}
</ElectronNavigationItem> <IoArrowUp className={style.linkIcon} />
</NavigationMenuItem>
); );
} }
return ( return (
<BrowserNavigationItem current={current} to={to} postAction={postAction}> <NavigationMenuItem
active={current}
onClick={() => {
navigate(`/${to}`);
postAction?.();
}}
>
{children} {children}
</BrowserNavigationItem>
);
}
function ElectronNavigationItem({ current, to, postAction, children }: PropsWithChildren<ClientLinkProps>) {
const navigateToLink = () => {
handleLinks(to);
postAction?.();
};
return (
<NavigationMenuItem active={current} onClick={navigateToLink}>
{children}
<span className={style.trailing}>
<IoArrowUp className={style.linkIcon} />
</span>
</NavigationMenuItem>
);
}
function BrowserNavigationItem({ current, to, postAction, children }: PropsWithChildren<ClientLinkProps>) {
const navigate = useNavigate();
const location = useLocation();
const [searchParams, setSearchParams] = useSearchParams();
const { params: savedParams, save, clear } = useSavedViewParams();
/**
* Save the params of the view we are leaving and resolve the destination,
* restoring any params previously saved for the target view.
*/
const resolveDestination = () => {
const currentView = location.pathname.replace(/^\//, '');
save(currentView, stripReservedParams(location.search));
const restored = savedParams[to];
return `${to}${restored ? `?${restored}` : ''}`;
};
const isCustomised = current ? hasCustomParams(searchParams) : Boolean(savedParams[to]);
const navigateToLink = () => {
const destination = resolveDestination();
navigate(`/${destination}`);
postAction?.();
};
/**
* Clear the saved settings for this route without navigating to it.
* When it is the current view, also reset the live URL to its defaults.
*/
const clearViewSettings = (event: MouseEvent) => {
event.stopPropagation();
clear(to);
// if mounted, clear the URL params
if (current) {
const preserved = new URLSearchParams();
reservedParams.forEach((key) => {
const value = searchParams.get(key);
if (value !== null) preserved.set(key, value);
});
setSearchParams(preserved);
}
};
return (
<NavigationMenuItem active={current} onClick={navigateToLink}>
{children}
{isCustomised && (
<span className={style.trailing}>
<span className={style.indicator} aria-hidden data-testid='client-link__saved-indicator' />
<Tooltip
text='Reset to default'
render={
<IconButton
variant='ghosted-white'
size='small'
className={style.clear}
aria-label='Reset to default'
onClick={clearViewSettings}
/>
}
>
<IoCloseOutline />
</Tooltip>
</span>
)}
</NavigationMenuItem> </NavigationMenuItem>
); );
} }
@@ -22,19 +22,3 @@
top: 0; top: 0;
z-index: $zindex-nav; z-index: $zindex-nav;
} }
.buttonWithIndicator {
position: relative;
display: inline-flex;
}
.indicator {
position: absolute;
top: -0.15rem;
right: -0.15rem;
width: 0.6rem;
height: 0.6rem;
border-radius: 50%;
background-color: $active-indicator;
pointer-events: none;
}
@@ -9,10 +9,9 @@ import style from './FloatingNavigation.module.scss';
interface FloatingNavigationProps { interface FloatingNavigationProps {
toggleMenu?: () => void; toggleMenu?: () => void;
toggleSettings?: () => void; toggleSettings?: () => void;
hasSavedChanges?: boolean;
} }
export default function FloatingNavigation({ toggleMenu, toggleSettings, hasSavedChanges }: FloatingNavigationProps) { export default function FloatingNavigation({ toggleMenu, toggleSettings }: FloatingNavigationProps) {
const isButtonShown = useFadeOutOnInactivity(true); const isButtonShown = useFadeOutOnInactivity(true);
return ( return (
@@ -21,18 +20,15 @@ export default function FloatingNavigation({ toggleMenu, toggleSettings, hasSave
className={cx([style.fadeable, style.buttonContainer, !isButtonShown && style.hidden])} className={cx([style.fadeable, style.buttonContainer, !isButtonShown && style.hidden])}
> >
{toggleMenu && ( {toggleMenu && (
<div className={style.buttonWithIndicator}> <IconButton
<IconButton variant='subtle-white'
variant='subtle-white' size='xlarge'
size='xlarge' onClick={toggleMenu}
onClick={toggleMenu} aria-label='toggle menu'
aria-label='toggle menu' data-testid='navigation__toggle-menu'
data-testid='navigation__toggle-menu' >
> <IoApps />
<IoApps /> </IconButton>
</IconButton>
{hasSavedChanges && <span className={style.indicator} data-testid='navigation__saved-indicator' />}
</div>
)} )}
{toggleSettings && ( {toggleSettings && (
<IconButton <IconButton
@@ -14,4 +14,6 @@ $progress-bar-br: 3px;
.progress-bar__indicator { .progress-bar__indicator {
height: $progress-bar-size; height: $progress-bar-size;
background-color: var(--timer-progress-override, $accent-color); background-color: var(--timer-progress-override, $accent-color);
transition: 1s linear;
transition-property: width;
} }
@@ -1,6 +1,6 @@
import { MaybeNumber } from 'ontime-types'; import { MaybeNumber } from 'ontime-types';
import { useAnimatedProgress } from '../../hooks/useAnimatedProgress'; import { getProgress } from '../../utils/getProgress';
import './ProgressBar.scss'; import './ProgressBar.scss';
@@ -12,7 +12,7 @@ interface ProgressBarProps {
export default function ProgressBar(props: ProgressBarProps) { export default function ProgressBar(props: ProgressBarProps) {
const { current, duration, className } = props; const { current, duration, className } = props;
const progress = useAnimatedProgress(current, duration); const progress = getProgress(current, duration);
return ( return (
<div className={`progress-bar__bg ${className}`}> <div className={`progress-bar__bg ${className}`}>
@@ -16,21 +16,13 @@ interface SelectProps<T> extends Omit<BaseSelect.Root.Props<T>, 'items'> {
options: SelectOption<T>[]; options: SelectOption<T>[];
fluid?: boolean; fluid?: boolean;
size?: 'medium' | 'large'; size?: 'medium' | 'large';
/** shown when the value is null, used to represent values which do not agree */
placeholder?: string;
} }
export default function Select<T>({ export default function Select<T>({ options, fluid, size = 'medium', ...selectRootProps }: SelectProps<T>) {
options,
fluid,
size = 'medium',
placeholder,
...selectRootProps
}: SelectProps<T>) {
return ( return (
<BaseSelect.Root items={options} {...selectRootProps}> <BaseSelect.Root items={options} {...selectRootProps}>
<BaseSelect.Trigger className={cx([styles.select, styles[size], fluid && styles.fluid])}> <BaseSelect.Trigger className={cx([styles.select, styles[size], fluid && styles.fluid])}>
<BaseSelect.Value placeholder={placeholder} /> <BaseSelect.Value />
<BaseSelect.Icon className={styles.selectIcon}> <BaseSelect.Icon className={styles.selectIcon}>
<LuChevronsUpDown /> <LuChevronsUpDown />
</BaseSelect.Icon> </BaseSelect.Icon>
@@ -4,9 +4,7 @@
color: $white-10; color: $white-10;
.empty { .empty {
display: block; width: 100%;
width: min(100%, 24rem);
margin-inline: auto;
opacity: 0.8; opacity: 0.8;
} }
@@ -15,6 +13,6 @@
margin-inline: auto; margin-inline: auto;
font-weight: 600; font-weight: 600;
font-size: 2em; font-size: 2em;
max-width: min(100%, 600px); max-width: 600px;
} }
} }
@@ -29,11 +29,6 @@
} }
} }
/* values do not agree, we show an empty track instead of a thumb position */
.mixed .thumb {
visibility: hidden;
}
.medium { .medium {
padding: 2px; padding: 2px;
--width: calc(2.5rem + 4px); --width: calc(2.5rem + 4px);
@@ -6,13 +6,11 @@ import style from './Switch.module.scss';
interface SwitchProps extends BaseSwitch.Root.Props { interface SwitchProps extends BaseSwitch.Root.Props {
size?: 'medium' | 'large'; size?: 'medium' | 'large';
/** the switch represents several values which do not agree, we hide the thumb */
mixed?: boolean;
} }
export default function Switch({ size = 'medium', mixed, ...switchProps }: SwitchProps) { export default function Switch({ size = 'medium', ...switchProps }: SwitchProps) {
return ( return (
<BaseSwitch.Root className={cx([style.switch, style[size], mixed && style.mixed])} {...switchProps}> <BaseSwitch.Root className={cx([style.switch, style[size]])} {...switchProps}>
<BaseSwitch.Thumb className={style.thumb} /> <BaseSwitch.Thumb className={style.thumb} />
</BaseSwitch.Root> </BaseSwitch.Root>
); );
@@ -6,7 +6,6 @@ import { useSearchParams } from 'react-router';
import useViewSettings from '../../hooks-query/useViewSettings'; import useViewSettings from '../../hooks-query/useViewSettings';
import { useIsSmallScreen } from '../../hooks/useIsSmallScreen'; import { useIsSmallScreen } from '../../hooks/useIsSmallScreen';
import { useSavedViewParams } from '../../stores/savedViewParams';
import Button from '../buttons/Button'; import Button from '../buttons/Button';
import IconButton from '../buttons/IconButton'; import IconButton from '../buttons/IconButton';
import Info from '../info/Info'; import Info from '../info/Info';
@@ -28,7 +27,6 @@ function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const { data: viewSettings } = useViewSettings(); const { data: viewSettings } = useViewSettings();
const { isOpen, close } = useViewParamsEditorStore(); const { isOpen, close } = useViewParamsEditorStore();
const clearSavedParams = useSavedViewParams((store) => store.clear);
const isSmallScreen = useIsSmallScreen(); const isSmallScreen = useIsSmallScreen();
const getPreservedParams = () => getPreservedSearchParams(searchParams, viewOptions); const getPreservedParams = () => getPreservedSearchParams(searchParams, viewOptions);
@@ -38,7 +36,6 @@ function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
}; };
const resetParams = () => { const resetParams = () => {
clearSavedParams(target);
setSearchParams(getPreservedParams()); setSearchParams(getPreservedParams());
}; };
@@ -1,76 +0,0 @@
import { OntimeEvent, OntimeGroup, RundownEntries, SupportedEntry } from 'ontime-types';
import { applyPatchToEvents, canPredictBatchResult } from '../entryAction.utils';
function makeEntries(): RundownEntries {
return {
'1': { type: SupportedEntry.Event, id: '1', title: 'one', custom: { lx: 'a', sound: 'x' } } as OntimeEvent,
'2': { type: SupportedEntry.Event, id: '2', title: 'two', custom: { lx: 'b' } } as OntimeEvent,
'3': { type: SupportedEntry.Event, id: '3', title: 'three', custom: {} } as OntimeEvent,
group: { type: SupportedEntry.Group, id: 'group', title: 'a group', custom: {} } as OntimeGroup,
};
}
describe('applyPatchToEvents()', () => {
it('applies the patch to the given events only', () => {
const patched = applyPatchToEvents(makeEntries(), ['1', '2'], { title: 'patched' });
expect((patched['1'] as OntimeEvent).title).toBe('patched');
expect((patched['2'] as OntimeEvent).title).toBe('patched');
expect((patched['3'] as OntimeEvent).title).toBe('three');
});
it('patches custom fields instead of replacing them', () => {
const patched = applyPatchToEvents(makeEntries(), ['1', '2'], { custom: { lx: 'new' } });
// the field which was not part of the patch must survive
expect((patched['1'] as OntimeEvent).custom).toStrictEqual({ lx: 'new', sound: 'x' });
expect((patched['2'] as OntimeEvent).custom).toStrictEqual({ lx: 'new' });
});
it('keeps the existing custom fields when the patch has none', () => {
const patched = applyPatchToEvents(makeEntries(), ['1'], { title: 'patched' });
expect((patched['1'] as OntimeEvent).custom).toStrictEqual({ lx: 'a', sound: 'x' });
});
it('ignores ids which are not in the rundown', () => {
const patched = applyPatchToEvents(makeEntries(), ['1', 'does-not-exist'], { title: 'patched' });
expect(Object.keys(patched)).toStrictEqual(['1', '2', '3', 'group']);
expect((patched['1'] as OntimeEvent).title).toBe('patched');
});
it('ignores entries which are not events', () => {
const patched = applyPatchToEvents(makeEntries(), ['group'], { title: 'patched' });
expect((patched.group as OntimeGroup).title).toBe('a group');
});
it('does not mutate the given entries', () => {
const entries = makeEntries();
applyPatchToEvents(entries, ['1'], { title: 'patched', custom: { lx: 'new' } });
expect((entries['1'] as OntimeEvent).title).toBe('one');
expect((entries['1'] as OntimeEvent).custom).toStrictEqual({ lx: 'a', sound: 'x' });
});
it('handles repeated ids', () => {
const patched = applyPatchToEvents(makeEntries(), ['1', '1'], { title: 'patched' });
expect((patched['1'] as OntimeEvent).title).toBe('patched');
});
});
describe('canPredictBatchResult()', () => {
it('allows resolving patches which do not affect the schedule', () => {
expect(canPredictBatchResult({ title: 'a title' })).toBe(true);
expect(canPredictBatchResult({ colour: 'red', flag: true })).toBe(true);
});
it('defers to the server when the duration changes', () => {
// a duration cascades into the start and end of every linked event
expect(canPredictBatchResult({ duration: 1000 })).toBe(false);
expect(canPredictBatchResult({ title: 'a title', duration: 1000 })).toBe(false);
});
});
@@ -1,39 +0,0 @@
import { EntryId, OntimeEvent, RundownEntries, isOntimeEvent } from 'ontime-types';
/**
* Applies a patch to a set of events, used to optimistically resolve a batch edit
* Entries which are missing or are not events are left untouched, mirroring the server
* @returns a new entries object, the given entries are not mutated
*/
export function applyPatchToEvents(
entries: RundownEntries,
ids: EntryId[],
patch: Partial<OntimeEvent>,
): RundownEntries {
const patchedEntries = { ...entries };
for (const id of new Set(ids)) {
const entry = patchedEntries[id];
if (!isOntimeEvent(entry)) {
continue;
}
patchedEntries[id] = {
...entry,
...patch,
// custom fields are patched, not replaced
custom: patch.custom ? { ...entry.custom, ...patch.custom } : entry.custom,
};
}
return patchedEntries;
}
/**
* Whether the result of a batch edit can be resolved without the server
* Changing the duration cascades through the rundown, so the resulting
* schedule is only known once the server has recalculated it
*/
export function canPredictBatchResult(patch: Partial<OntimeEvent>): boolean {
return !('duration' in patch);
}
@@ -1,41 +0,0 @@
import { MaybeNumber, Playback } from 'ontime-types';
import { useEffect, useRef, useState } from 'react';
import { getProgress } from '../utils/getProgress';
import { usePlayback } from './useSocket';
/**
* Returns the live completion percentage (0100) of a countdown, interpolated locally.
*/
export function useAnimatedProgress(current: MaybeNumber, duration: MaybeNumber): number {
const playback = usePlayback();
const isRunning = playback === Playback.Play || playback === Playback.Roll;
const baseline = useRef({ current, at: performance.now() });
const [, setTick] = useState(0);
// there is only something to animate while a running timer is counting down towards 0
const shouldAnimate = isRunning && current !== null && current > 0 && duration !== null;
// re-anchor to the authoritative value whenever the server pushes a new timer update
useEffect(() => {
baseline.current = { current, at: performance.now() };
}, [current, duration, playback]);
// while counting down, re-render every animation frame so the derived progress stays smooth
useEffect(() => {
if (!shouldAnimate) {
return;
}
let frame = requestAnimationFrame(function tick() {
setTick((value) => value + 1);
frame = requestAnimationFrame(tick);
});
return () => cancelAnimationFrame(frame);
}, [shouldAnimate]);
// derive from the anchor plus elapsed time at render; frozen to the anchor when not running
const anchored = baseline.current.current;
const value = isRunning && anchored !== null ? anchored - (performance.now() - baseline.current.at) : anchored;
return getProgress(value, duration);
}
+22 -17
View File
@@ -1,6 +1,7 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { import {
EntryId, EntryId,
InsertOptions,
MaybeString, MaybeString,
OntimeDelay, OntimeDelay,
OntimeEntry, OntimeEntry,
@@ -52,7 +53,6 @@ import {
} from '../api/rundown'; } from '../api/rundown';
import { logAxiosError } from '../api/utils'; import { logAxiosError } from '../api/utils';
import { useEditorSettings } from '../stores/editorSettings'; import { useEditorSettings } from '../stores/editorSettings';
import { applyPatchToEvents, canPredictBatchResult } from './entryAction.utils';
export type EventOptions = Partial<{ export type EventOptions = Partial<{
// options of any new entries (event / delay / group) // options of any new entries (event / delay / group)
@@ -60,15 +60,9 @@ export type EventOptions = Partial<{
before: MaybeString; before: MaybeString;
// options of entries of type OntimeEvent // options of entries of type OntimeEvent
linkPrevious: boolean; linkPrevious: boolean;
/** the timing reference for the new entry, usually the same as after */
lastEventId: MaybeString; lastEventId: MaybeString;
}>; }>;
type ClientInsertOptions = {
after?: EntryId;
before?: EntryId;
};
/** /**
* Gather utilities for actions on entries in the loaded rundown. * Gather utilities for actions on entries in the loaded rundown.
*/ */
@@ -126,7 +120,7 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
* @private * @private
*/ */
const { mutateAsync: addEntryMutation } = useMutation({ const { mutateAsync: addEntryMutation } = useMutation({
mutationFn: ([rundownId, entry]: [string, PatchWithId & ClientInsertOptions]) => postAddEntry(rundownId, entry), mutationFn: ([rundownId, entry]: [string, PatchWithId & InsertOptions]) => postAddEntry(rundownId, entry),
onMutate: async ([_rundownId, entry]) => { onMutate: async ([_rundownId, entry]) => {
const queryKey = resolveCurrentRundownQueryKey(); const queryKey = resolveCurrentRundownQueryKey();
await queryClient.cancelQueries({ queryKey }); await queryClient.cancelQueries({ queryKey });
@@ -155,9 +149,8 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
addToRundown( addToRundown(
newRundown, newRundown,
optimisticEntry, optimisticEntry,
parent ? (newRundown.entries[parent.id] as OntimeGroup) : null,
afterId, afterId,
entry.before ?? null, parent ? (newRundown.entries[parent.id] as OntimeGroup) : null,
); );
queryClient.setQueryData<Rundown>(queryKey, newRundown); queryClient.setQueryData<Rundown>(queryKey, newRundown);
@@ -200,7 +193,7 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
throw new Error('Rundown not initialised'); throw new Error('Rundown not initialised');
} }
const newEntry: PatchWithId & ClientInsertOptions = { ...entry, id: generateId() }; const newEntry: PatchWithId & InsertOptions = { ...entry, id: generateId() };
// handle adding options that concern all event types // handle adding options that concern all event types
if (options?.after) { if (options?.after) {
@@ -212,8 +205,8 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
// ************* CHECK OPTIONS specific to events // ************* CHECK OPTIONS specific to events
if (isOntimeEvent(newEntry)) { if (isOntimeEvent(newEntry)) {
// last event id is the timing reference of the previous OntimeEvent in rundown
if (options?.lastEventId) { if (options?.lastEventId) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we know this is a value
const previousEvent = rundownData.entries[options?.lastEventId]; const previousEvent = rundownData.entries[options?.lastEventId];
if (isOntimeEvent(previousEvent)) { if (isOntimeEvent(previousEvent)) {
newEntry.timeStart = previousEvent.timeEnd; newEntry.timeStart = previousEvent.timeEnd;
@@ -291,7 +284,7 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
* Clone an entry * Clone an entry
*/ */
const clone = useCallback( const clone = useCallback(
async (entryId: EntryId, options?: ClientInsertOptions) => { async (entryId: EntryId, options?: InsertOptions) => {
try { try {
const rundownId = getCurrentRundownData()?.id; const rundownId = getCurrentRundownData()?.id;
if (!rundownId) { if (!rundownId) {
@@ -484,9 +477,21 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
// Snapshot the previous value // Snapshot the previous value
const previousRundown = queryClient.getQueryData<Rundown>(queryKey); const previousRundown = queryClient.getQueryData<Rundown>(queryKey);
// when the result cannot be resolved here we wait for the recalculated rundown from the server if (previousRundown) {
if (previousRundown && canPredictBatchResult(data.data)) { const eventIds = new Set(data.ids);
const newRundown = applyPatchToEvents(previousRundown.entries, data.ids, data.data); const newRundown = { ...previousRundown.entries };
eventIds.forEach((eventId) => {
if (Object.hasOwn(newRundown, eventId)) {
const event = newRundown[eventId];
if (isOntimeEvent(event)) {
newRundown[eventId] = {
...event,
...data,
};
}
}
});
queryClient.setQueryData<Rundown>(queryKey, { queryClient.setQueryData<Rundown>(queryKey, {
id: previousRundown.id, id: previousRundown.id,
@@ -1052,7 +1057,7 @@ function optimisticDeleteEntries(entryIds: EntryId[], rundown: Rundown) {
/** /**
* Utility to create an optimistic entry for immediate cache insertion * Utility to create an optimistic entry for immediate cache insertion
*/ */
function createOptimisticEntry(payload: PatchWithId & ClientInsertOptions): OntimeEntry { function createOptimisticEntry(payload: PatchWithId & InsertOptions): OntimeEntry {
const { after: _after, before: _before, ...entryData } = payload; const { after: _after, before: _before, ...entryData } = payload;
const id = entryData.id; const id = entryData.id;
let parent: EntryId | null = null; let parent: EntryId | null = null;
-11
View File
@@ -139,17 +139,6 @@ export const setEventPlayback = {
pause: () => sendSocket('pause', undefined), pause: () => sendSocket('pause', undefined),
}; };
export const useTimerProgress = createSelector((state: RuntimeStore) => ({
playback: state.timer.playback,
phase: state.timer.phase,
addedTime: state.timer.addedTime,
secondaryTimer: state.timer.secondaryTimer,
current: state.timer.current,
expectedFinish: state.timer.expectedFinish,
startedAt: state.timer.startedAt,
isCountToEnd: state.eventNow?.countToEnd ?? false,
}));
export const useTimer = createSelector((state: RuntimeStore) => ({ export const useTimer = createSelector((state: RuntimeStore) => ({
...state.timer, ...state.timer,
})); }));
@@ -1,87 +0,0 @@
import { hasCustomParams, stripReservedParams, useSavedViewParams } from '../savedViewParams';
describe('savedViewParams store', () => {
beforeEach(() => {
useSavedViewParams.getState().clearAll();
});
it('saves and restores params per view', () => {
useSavedViewParams.getState().save('timer', 'hideSeconds=true');
useSavedViewParams.getState().save('backstage', 'showProgress=false');
expect(useSavedViewParams.getState().params.timer).toBe('hideSeconds=true');
expect(useSavedViewParams.getState().params.backstage).toBe('showProgress=false');
});
it('overwrites the saved params for a view on subsequent saves', () => {
useSavedViewParams.getState().save('timer', 'hideSeconds=true');
useSavedViewParams.getState().save('timer', 'hideSeconds=false');
expect(useSavedViewParams.getState().params.timer).toBe('hideSeconds=false');
});
it('does not store an entry for an empty search string', () => {
useSavedViewParams.getState().save('timer', '');
expect(useSavedViewParams.getState().params).toEqual({});
});
it('removes a previously saved entry when saved with an empty search string', () => {
useSavedViewParams.getState().save('timer', 'hideSeconds=true');
useSavedViewParams.getState().save('timer', '');
expect(useSavedViewParams.getState().params).toEqual({});
});
it('ignores empty view keys', () => {
useSavedViewParams.getState().save('', 'hideSeconds=true');
expect(useSavedViewParams.getState().params).toEqual({});
});
it('clears the saved params for a single view without touching others', () => {
useSavedViewParams.getState().save('timer', 'hideSeconds=true');
useSavedViewParams.getState().save('backstage', 'showProgress=false');
useSavedViewParams.getState().clear('timer');
expect(useSavedViewParams.getState().params).toEqual({ backstage: 'showProgress=false' });
});
it('is a no-op when clearing a view without saved params', () => {
useSavedViewParams.getState().save('backstage', 'showProgress=false');
useSavedViewParams.getState().clear('timer');
expect(useSavedViewParams.getState().params).toEqual({ backstage: 'showProgress=false' });
});
it('clears all saved params', () => {
useSavedViewParams.getState().save('timer', 'hideSeconds=true');
useSavedViewParams.getState().clearAll();
expect(useSavedViewParams.getState().params).toEqual({});
});
});
describe('stripReservedParams', () => {
it('removes reserved auth/preset params while keeping view customisation', () => {
expect(stripReservedParams('hideSeconds=true&token=abc&n=1&alias=my')).toBe('hideSeconds=true');
});
it('returns an empty string when only reserved params are present', () => {
expect(stripReservedParams('token=abc&n=1&alias=my')).toBe('');
});
});
describe('hasCustomParams', () => {
it('is true when a non-reserved param is present', () => {
expect(hasCustomParams(new URLSearchParams('hideSeconds=true&token=abc'))).toBe(true);
});
it('is false when only reserved params are present', () => {
expect(hasCustomParams(new URLSearchParams('token=abc&n=1&alias=my'))).toBe(false);
});
it('is false when there are no params', () => {
expect(hasCustomParams(new URLSearchParams(''))).toBe(false);
});
});
@@ -1,62 +0,0 @@
import { create } from 'zustand';
// params that are auth/preset markers, not user view customisation (see common/utils/urlPresets.ts)
export const reservedParams = new Set(['token', 'n', 'alias']);
interface SavedViewParamsStore {
params: Record<string, string>; // view key (e.g. "timer") -> search string without leading "?"
save: (view: string, search: string) => void;
clear: (view: string) => void;
clearAll: () => void;
}
/**
* Remembers the last view parameters used for each view so they can be
* restored when the user navigates back to that view through the menu.
* In-memory only: persists across SPA navigation, resets on a full page reload.
*/
export const useSavedViewParams = create<SavedViewParamsStore>((set) => ({
params: {},
save: (view, search) =>
set((state) => {
// ignore empty view keys and do not store empty entries, so the
// "saved changes" indicator only reflects genuine customisation
if (!view) return state;
const params = { ...state.params };
if (search) {
params[view] = search;
} else {
delete params[view];
}
return { params };
}),
clear: (view) =>
set((state) => {
if (!state.params[view]) return state;
const params = { ...state.params };
delete params[view];
return { params };
}),
clearAll: () => set({ params: {} }),
}));
/**
* Removes reserved (auth/preset) params from a search string, keeping only
* genuine view customisation.
*/
export function stripReservedParams(search: string): string {
const sp = new URLSearchParams(search);
reservedParams.forEach((key) => sp.delete(key));
return sp.toString();
}
/**
* Whether the search params contain any genuine view customisation,
* ignoring reserved params.
*/
export function hasCustomParams(searchParams: URLSearchParams): boolean {
for (const key of searchParams.keys()) {
if (!reservedParams.has(key)) return true;
}
return false;
}
@@ -1,4 +1,4 @@
import { hostToBaseUrl, linkToOtherHost } from '../linkUtils'; import { linkToOtherHost } from '../linkUtils';
describe('linkToOTherHost', () => { describe('linkToOTherHost', () => {
it('should handle electron links', () => { it('should handle electron links', () => {
@@ -22,18 +22,3 @@ describe('linkToOTherHost', () => {
expect(destination).toBe('https://app.getontime.no/user-hash/path'); expect(destination).toBe('https://app.getontime.no/user-hash/path');
}); });
}); });
describe('hostToBaseUrl', () => {
it('uses http when the page is plain http', () => {
expect(hostToBaseUrl('192.168.10.166', 4001, 'http://localhost:4001')).toBe('http://192.168.10.166:4001');
});
it('keeps https when the page reaches the server over TLS on the same port', () => {
expect(hostToBaseUrl('192.168.10.166', 4001, 'https://localhost:4001')).toBe('https://192.168.10.166:4001');
});
it('falls back to http when the page is https on a different port (TLS proxy)', () => {
// the raw server port does not speak TLS, an https link to it would fail
expect(hostToBaseUrl('192.168.10.166', 4001, 'https://ontime.example.com')).toBe('http://192.168.10.166:4001');
});
});
@@ -1,25 +0,0 @@
import { describe, expect, it } from 'vitest';
import { removeFileExtension } from '../uploadUtils';
describe('removeFileExtension()', () => {
it('removes a trailing extension', () => {
expect(removeFileExtension('show.xlsx')).toBe('show');
});
it('only removes the last extension', () => {
expect(removeFileExtension('my.show.xlsx')).toBe('my.show');
});
it('returns the name unchanged when there is no extension', () => {
expect(removeFileExtension('rundown')).toBe('rundown');
});
it('does not treat a leading dot as an extension', () => {
expect(removeFileExtension('.gitignore')).toBe('.gitignore');
});
it('handles an empty string', () => {
expect(removeFileExtension('')).toBe('');
});
});
@@ -37,7 +37,6 @@ describe('getRouteFromPreset()', () => {
alias: 'demopage', alias: 'demopage',
target: OntimeView.Timer, target: OntimeView.Timer,
search: 'user=guest', search: 'user=guest',
displayInNav: false,
options: {}, options: {},
}, },
]; ];
@@ -120,7 +119,6 @@ describe('getRouteFromPreset()', () => {
alias: 'cuesheet-4685d6', alias: 'cuesheet-4685d6',
target: OntimeView.Cuesheet, target: OntimeView.Cuesheet,
search: '', search: '',
displayInNav: false,
options: { options: {
read: 'full', read: 'full',
write: '-', write: '-',
@@ -133,7 +131,6 @@ describe('getRouteFromPreset()', () => {
alias: 'cuesheet-basic', alias: 'cuesheet-basic',
target: OntimeView.Cuesheet, target: OntimeView.Cuesheet,
search: '', search: '',
displayInNav: false,
}, },
]; ];
const cuesheetPresetWithNavLock: URLPreset[] = [ const cuesheetPresetWithNavLock: URLPreset[] = [
@@ -142,7 +139,6 @@ describe('getRouteFromPreset()', () => {
alias: 'cuesheet-locked', alias: 'cuesheet-locked',
target: OntimeView.Cuesheet, target: OntimeView.Cuesheet,
search: 'n=1', search: 'n=1',
displayInNav: false,
}, },
]; ];
@@ -224,7 +220,6 @@ describe('generateUrlPresetOptions', () => {
target: 'timer', target: 'timer',
search: 'param1=value1&param2=value2', search: 'param1=value1&param2=value2',
enabled: true, enabled: true,
displayInNav: false,
}, },
], ],
[ [
@@ -236,7 +231,6 @@ describe('generateUrlPresetOptions', () => {
target: 'timer', target: 'timer',
search: 'param1=value1&param2=value2', search: 'param1=value1&param2=value2',
enabled: true, enabled: true,
displayInNav: false,
}, },
], ],
[ [
@@ -248,7 +242,6 @@ describe('generateUrlPresetOptions', () => {
target: 'timer', target: 'timer',
search: 'param1=value1&param2=value2', search: 'param1=value1&param2=value2',
enabled: true, enabled: true,
displayInNav: false,
}, },
], ],
[ [
@@ -260,7 +253,6 @@ describe('generateUrlPresetOptions', () => {
target: 'timer', target: 'timer',
search: 'param1=value1&param2=value2', search: 'param1=value1&param2=value2',
enabled: true, enabled: true,
displayInNav: false,
}, },
], ],
])('should generate URL preset options for %s', (_description, alias, url, expected) => { ])('should generate URL preset options for %s', (_description, alias, url, expected) => {
+1 -20
View File
@@ -37,12 +37,7 @@ export function handleLinks(
event?.preventDefault(); event?.preventDefault();
const destination = new URL(externalServerUrl); const destination = new URL(externalServerUrl);
// split off any query string so it is not encoded into the pathname destination.pathname = externalBaseURI ? `${externalBaseURI}/${location}` : location;
const [pathname, search] = location.split('?');
destination.pathname = externalBaseURI ? `${externalBaseURI}/${pathname}` : pathname;
if (search) {
destination.search = search;
}
openLink(destination.toString()); openLink(destination.toString());
} }
@@ -59,17 +54,3 @@ export function linkToOtherHost(
} }
return destination.toString(); return destination.toString();
} }
/**
* Builds a base URL for reaching the Ontime server on a given network interface.
* The Ontime server itself only speaks HTTP, so we only keep https when the page
* already reaches the server over TLS on that same port (eg. a proxy in front of it);
* any other explicit server port gets http.
* externalServerUrl is used for testing
*/
export function hostToBaseUrl(host: string, port: number, externalServerUrl: string = serverURL): string {
const page = new URL(externalServerUrl);
const pagePort = page.port === '' ? (page.protocol === 'https:' ? 443 : 80) : Number(page.port);
const scheme = page.protocol === 'https:' && pagePort === port ? 'https' : 'http';
return `${scheme}://${host}:${port}`;
}
+4 -9
View File
@@ -3,9 +3,7 @@ import {
MILLIS_PER_HOUR, MILLIS_PER_HOUR,
MILLIS_PER_MINUTE, MILLIS_PER_MINUTE,
MILLIS_PER_SECOND, MILLIS_PER_SECOND,
dayInMs,
formatFromMillis, formatFromMillis,
getExpectedEnd,
getExpectedStart, getExpectedStart,
} from 'ontime-utils'; } from 'ontime-utils';
@@ -31,11 +29,6 @@ export function nowInMillis(): number {
return elapsed; return elapsed;
} }
export function normaliseWallClock(time: number): number {
const timeOfDay = time % dayInMs;
return timeOfDay < 0 ? timeOfDay + dayInMs : timeOfDay;
}
/** /**
* @description Resolves format from url and store * @description Resolves format from url and store
* @return {string|null} A format string like "hh:mm:ss a" or null * @return {string|null} A format string like "hh:mm:ss a" or null
@@ -187,13 +180,15 @@ export function getExpectedTimesFromExtendedEvent(
...state, ...state,
}, },
); );
const expectedEnd = getExpectedEnd(event, expectedStart, state.currentDay);
const plannedEnd = event.timeStart + event.duration + event.delay; const plannedEnd = event.timeStart + event.duration + event.delay;
return { return {
expectedStart, expectedStart,
timeToStart: expectedStart - state.clock, timeToStart: expectedStart - state.clock,
expectedEnd, expectedEnd: event.countToEnd
? Math.max(expectedStart + event.duration, plannedEnd)
: expectedStart + event.duration,
plannedEnd, plannedEnd,
}; };
} }
@@ -38,18 +38,6 @@ export function validateProjectFile(file: File) {
} }
} }
/**
* Removes a trailing file extension from a file name (e.g. "show.xlsx" -> "show")
* A leading dot (dotfiles like ".gitignore") is not treated as an extension
*/
export function removeFileExtension(fileName: string): string {
const lastDot = fileName.lastIndexOf('.');
if (lastDot <= 0) {
return fileName;
}
return fileName.slice(0, lastDot);
}
export function isExcelFile(file: File | null) { export function isExcelFile(file: File | null) {
return file?.name.endsWith('.xlsx'); return file?.name.endsWith('.xlsx');
} }
@@ -194,7 +194,6 @@ export function generateUrlPresetOptions(alias: string, userUrl: string): URLPre
target: path, target: path,
search: url.searchParams.toString(), search: url.searchParams.toString(),
enabled: true, enabled: true,
displayInNav: false,
}; };
} }
@@ -38,7 +38,7 @@ $inner-padding: 1rem;
.section { .section {
position: relative; position: relative;
margin-top: 2rem; margin-top: 2rem;
max-width: 1024px; max-width: 850px;
} }
.indent { .indent {
@@ -293,9 +293,8 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
<h3>Outputs</h3> <h3>Outputs</h3>
<Info> <Info>
Automation outputs can be used to send data from Ontime to external software <br /> Automation outputs can be used to send data from Ontime to external software <br />
or to change properties of Ontime itself. <br /> <br /> or to change properties of Ontime itself.
Use Ontime runtime data in these fields with template strings. Type {'{{'} to see autocomplete, or{' '} <ExternalLink href={integrationsDocsUrl}>See the documentation for templates</ExternalLink>
<ExternalLink href={integrationsDocsUrl}>read the docs</ExternalLink>
</Info> </Info>
{fieldOutputs.map((output, index) => { {fieldOutputs.map((output, index) => {
@@ -342,17 +341,12 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
</label> </label>
<label> <label>
Address Address
<TemplateInput <Input {...register(`outputs.${index}.address`)} fluid placeholder='/cue/start' />
{...register(`outputs.${index}.address`)}
value={output.address}
fluid
placeholder='/cue/start'
/>
<Panel.Error>{rowErrors?.address?.message}</Panel.Error> <Panel.Error>{rowErrors?.address?.message}</Panel.Error>
</label> </label>
<label> <label>
Arguments Arguments
<TemplateInput {...register(`outputs.${index}.args`)} value={output.args} fluid placeholder='1' /> <TemplateInput {...register(`outputs.${index}.args`)} value={output.args} placeholder='1' />
<Panel.Error>{rowErrors?.args?.message}</Panel.Error> <Panel.Error>{rowErrors?.args?.message}</Panel.Error>
</label> </label>
<div> <div>
@@ -382,7 +376,7 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
<div className={style.httpSection}> <div className={style.httpSection}>
<label> <label>
Target URL Target URL
<TemplateInput <Input
{...register(`outputs.${index}.url`, { {...register(`outputs.${index}.url`, {
required: { value: true, message: 'Required field' }, required: { value: true, message: 'Required field' },
pattern: { pattern: {
@@ -390,7 +384,6 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
message: 'HTTP messages should target http:// or https://', message: 'HTTP messages should target http:// or https://',
}, },
})} })}
value={output.url}
fluid fluid
placeholder='http://127.0.0.1/start/1' placeholder='http://127.0.0.1/start/1'
/> />
@@ -5,7 +5,6 @@ import { UseFormRegister, UseFormSetValue, UseFormWatch } from 'react-hook-form'
import Input from '../../../../common/components/input/input/Input'; import Input from '../../../../common/components/input/input/Input';
import Select from '../../../../common/components/select/Select'; import Select from '../../../../common/components/select/Select';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import TemplateInput from './template-input/TemplateInput';
import style from './AutomationForm.module.scss'; import style from './AutomationForm.module.scss';
@@ -72,8 +71,8 @@ export default function OntimeActionForm({
{ value: 'playback-pause', label: 'Playback: pause' }, { value: 'playback-pause', label: 'Playback: pause' },
{ value: 'playback-roll', label: 'Playback: roll' }, { value: 'playback-roll', label: 'Playback: roll' },
{ value: 'message-set', label: 'Primary Message' }, { value: 'message-set', label: 'Primary Message: set' },
{ value: 'message-secondary', label: 'Secondary Message' }, { value: 'message-secondary', label: 'Secondary Message: source' },
]} ]}
/> />
<Panel.Error>{rowErrors?.action?.message}</Panel.Error> <Panel.Error>{rowErrors?.action?.message}</Panel.Error>
@@ -97,12 +96,7 @@ export default function OntimeActionForm({
<> <>
<label> <label>
Text (leave empty for no change) Text (leave empty for no change)
<TemplateInput <Input {...register(`outputs.${index}.text`)} fluid placeholder='eg: Timer is finished' />
{...register(`outputs.${index}.text`)}
value={watch(`outputs.${index}.text`) ?? ''}
fluid
placeholder='eg: Timer is finished'
/>
<Panel.Error>{rowErrors?.text?.message}</Panel.Error> <Panel.Error>{rowErrors?.text?.message}</Panel.Error>
</label> </label>
<label> <label>
@@ -126,48 +120,31 @@ export default function OntimeActionForm({
)} )}
{selectedAction === 'message-secondary' && ( {selectedAction === 'message-secondary' && (
<> <label>
<label> Timer secondary source
Text (leave empty for no change) <Select<SecondarySource | 'null' | null>
<TemplateInput onValueChange={(value) => {
{...register(`outputs.${index}.text`)} // null -> no selection
value={watch(`outputs.${index}.text`) ?? ''} if (value === null) return;
fluid // 'null' -> clear the secondary source
placeholder='eg: Next up: keynote' if (value === 'null') {
/> setValue(`outputs.${index}.secondarySource`, null, { shouldDirty: true });
<Panel.Error>{rowErrors?.text?.message}</Panel.Error> return;
</label> }
<label> setValue(`outputs.${index}.secondarySource`, value, { shouldDirty: true });
Timer secondary source }}
<Select<SecondarySource | 'no-change' | 'null' | null> value={watch(`outputs.${index}.secondarySource`)}
onValueChange={(value) => { options={[
// null -> no selection { value: null, label: 'Select secondary source' },
if (value === null) return; { value: 'aux1', label: 'Auxiliary timer 1' },
// no-change -> leave the current secondary source untouched { value: 'aux2', label: 'Auxiliary timer 2' },
if (value === 'no-change') { { value: 'aux3', label: 'Auxiliary timer 3' },
setValue(`outputs.${index}.secondarySource`, undefined, { shouldDirty: true }); { value: 'secondary', label: 'Secondary' },
return; { value: 'null', label: 'None' }, // allow the user to clear the secondary source
} ]}
// 'null' -> clear the secondary source />
if (value === 'null') { <Panel.Error>{rowErrors?.secondarySource?.message}</Panel.Error>
setValue(`outputs.${index}.secondarySource`, null, { shouldDirty: true }); </label>
return;
}
setValue(`outputs.${index}.secondarySource`, value, { shouldDirty: true });
}}
value={watch(`outputs.${index}.secondarySource`) ?? 'no-change'}
options={[
{ value: 'no-change', label: 'No change' },
{ value: 'aux1', label: 'Auxiliary timer 1' },
{ value: 'aux2', label: 'Auxiliary timer 2' },
{ value: 'aux3', label: 'Auxiliary timer 3' },
{ value: 'secondary', label: 'Secondary' },
{ value: 'null', label: 'None' }, // allow the user to clear the secondary source
]}
/>
<Panel.Error>{rowErrors?.secondarySource?.message}</Panel.Error>
</label>
</>
)} )}
<div className={style.test}>{children}</div> <div className={style.test}>{children}</div>
@@ -79,7 +79,7 @@ export default function TriggersList(props: TriggersListProps) {
<tbody> <tbody>
{!showForm && triggers.length === 0 && ( {!showForm && triggers.length === 0 && (
<Panel.TableEmpty <Panel.TableEmpty
label='Create a trigger to run an automation' label='Create an automation to attach triggers to'
handleClick={canAdd ? () => setShowForm(true) : undefined} handleClick={canAdd ? () => setShowForm(true) : undefined}
/> />
)} )}
@@ -1,67 +1,29 @@
.inputShell { .wrapper {
position: relative; position: relative;
} }
.fluid { .suggestions {
width: 100%;
}
.input {
padding-right: 2rem;
}
.expandButton {
position: absolute;
top: 50%;
right: 0.25rem;
transform: translateY(-50%);
}
.positioner {
outline: 0;
}
.popup {
box-sizing: border-box;
min-width: var(--anchor-width);
max-width: var(--available-width);
border: 1px solid $gray-1000;
border-radius: $component-border-radius-md;
background: $gray-1250; background: $gray-1250;
color: $label-gray; color: $ui-white;
}
.list { position: absolute;
box-sizing: border-box; top: 100%;
max-height: min(20rem, var(--available-height)); left: 0;
overflow-y: auto; width: 100%;
overscroll-behavior: contain; margin: 0;
z-index: $zindex-floating;
padding-block: 0.25rem; padding-block: 0.25rem;
outline: 0;
}
.item { max-height: 200px;
box-sizing: border-box; overflow-y: auto;
padding: 0.25rem 0.5rem; color: $label-gray;
outline: 0;
cursor: default;
user-select: none;
overflow-wrap: anywhere;
&[data-highlighted] { li {
padding: 0.25rem;
}
li:hover {
color: $ui-white; color: $ui-white;
background: $blue-700; background: $blue-700;
} }
} }
.expandedEditor {
min-height: min(18rem, 45vh);
font-family: monospace;
line-height: 1.4;
}
.footerHint {
margin-right: auto;
color: $label-gray;
font-size: calc(1rem - 2px);
}
@@ -1,258 +1,69 @@
import { Autocomplete as BaseAutocomplete } from '@base-ui/react/autocomplete'; import { mergeRefs, useClickOutside } from '@mantine/hooks';
import type { ChangeEvent, ReactNode, Ref } from 'react'; import { forwardRef, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { IoExpandOutline } from 'react-icons/io5';
import Button from '../../../../../common/components/buttons/Button'; import Input, { type InputProps } from '../../../../../common/components/input/input/Input';
import IconButton from '../../../../../common/components/buttons/IconButton';
import { type InputProps } from '../../../../../common/components/input/input/Input';
import Textarea from '../../../../../common/components/input/textarea/Textarea';
import Modal from '../../../../../common/components/modal/Modal';
import useCustomFields from '../../../../../common/hooks-query/useCustomFields'; import useCustomFields from '../../../../../common/hooks-query/useCustomFields';
import { cx } from '../../../../../common/utils/styleUtils'; import { makeAutoCompleteList, matchRemaining, selectFromLastTemplate } from './templateInput.utils';
import { makeAutoCompleteList } from './templateInput.utils';
import { useTemplateAutocomplete } from './useTemplateAutocomplete';
import inputStyle from '../../../../../common/components/input/input/Input.module.scss';
import style from './TemplateInput.module.scss'; import style from './TemplateInput.module.scss';
interface TemplateInputProps extends Omit<InputProps, 'value'> { interface TemplateInputProps extends InputProps {}
ref?: Ref<HTMLInputElement>;
value?: string;
}
interface TemplateEditorModalProps { const TemplateInput = forwardRef(function TemplateInput(props: TemplateInputProps, ref) {
autocompleteList: string[]; const { value, onChange, ...rest } = props;
isOpen: boolean;
onClose: () => void;
onSave: (value: string) => void;
value: string;
}
type TemplateAutocompleteState = ReturnType<typeof useTemplateAutocomplete<HTMLInputElement>>;
interface TemplateAutocompleteRootProps {
autocomplete: TemplateAutocompleteState;
children: ReactNode;
value: string;
}
function assignRef<T>(ref: Ref<T> | undefined, value: T | null) {
if (typeof ref === 'function') {
ref(value);
} else if (ref) {
ref.current = value;
}
}
function emitInputChange(name: string | undefined, value: string, onChange: InputProps['onChange']) {
onChange?.({
target: { name, value },
currentTarget: { name, value },
} as ChangeEvent<HTMLInputElement>);
}
export default function TemplateInput({
className,
disabled,
fluid,
height = 'medium',
onChange,
readOnly,
ref,
value,
variant = 'subtle',
...rest
}: TemplateInputProps) {
const { data } = useCustomFields(); const { data } = useCustomFields();
const inputRef = useRef<HTMLInputElement | null>(null); const localRef = useClickOutside(() => setShowSuggestions(false));
const [inputValue, setInputValue] = useState(value || '');
const [isExpanded, setIsExpanded] = useState(false);
const autocompleteList = useMemo(() => { const autocompleteList = useMemo(() => {
return makeAutoCompleteList(data); return makeAutoCompleteList(data);
}, [data]); }, [data]);
const updateInputValue = useCallback( const [inputValue, setInputValue] = useState(value || '');
(nextValue: string) => { const [suggestions, setSuggestions] = useState<string[]>([]);
setInputValue(nextValue); const [showSuggestions, setShowSuggestions] = useState(false);
emitInputChange(rest.name, nextValue, onChange);
},
[onChange, rest.name],
);
const autocomplete = useTemplateAutocomplete(inputValue, autocompleteList, inputRef, updateInputValue); const updateSuggestions = (value: string) => {
const { setCursorForValue } = autocomplete; const template = selectFromLastTemplate(value);
return autocompleteList.filter((suggestion) => suggestion.startsWith(template));
// Keep the local autocomplete input in sync when react-hook-form resets or swaps field-array values.
useEffect(() => {
const nextValue = value || '';
setInputValue(nextValue);
setCursorForValue(nextValue, nextValue.length);
}, [setCursorForValue, value]);
const setInputRef = useCallback(
(element: HTMLInputElement | null) => {
inputRef.current = element;
assignRef(ref, element);
},
[ref],
);
const openExpandedEditor = () => {
autocomplete.setShowSuggestions(false);
setIsExpanded(true);
}; };
const closeExpandedEditor = () => { const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setIsExpanded(false); setInputValue(event.target.value);
};
const saveExpandedEditor = (nextValue: string) => { if (event.target.value.endsWith('{')) {
updateInputValue(nextValue); setShowSuggestions(true);
autocomplete.setCursorForValue(nextValue, nextValue.length); setSuggestions(updateSuggestions(event.target.value));
setIsExpanded(false); } else if (event.target.value === '' || event.target.value.endsWith('}}')) {
}; setShowSuggestions(false);
} else if (showSuggestions) {
return ( setSuggestions(updateSuggestions(event.target.value));
<>
<TemplateAutocompleteRoot autocomplete={autocomplete} value={inputValue}>
<div className={cx([style.inputShell, fluid && style.fluid])}>
<BaseAutocomplete.Input
ref={setInputRef}
className={cx([
inputStyle.input,
inputStyle[variant],
inputStyle[height],
fluid && inputStyle.fluid,
style.input,
className,
])}
{...rest}
disabled={disabled}
onClick={autocomplete.updateCursor}
onFocus={autocomplete.updateCursor}
onKeyUp={autocomplete.updateCursor}
onSelect={autocomplete.updateCursor}
readOnly={readOnly}
/>
<IconButton
aria-label='Expand template editor'
className={style.expandButton}
disabled={disabled || readOnly}
onClick={openExpandedEditor}
size='small'
title='Expand template editor'
variant='ghosted-white'
>
<IoExpandOutline />
</IconButton>
</div>
</TemplateAutocompleteRoot>
<TemplateEditorModal
autocompleteList={autocompleteList}
isOpen={isExpanded}
onClose={closeExpandedEditor}
onSave={saveExpandedEditor}
value={inputValue}
/>
</>
);
}
function TemplateAutocompleteRoot({ autocomplete, children, value }: TemplateAutocompleteRootProps) {
return (
<BaseAutocomplete.Root
items={autocomplete.suggestions}
autoHighlight
highlightItemOnHover
mode='none'
open={autocomplete.open}
value={value}
onOpenChange={autocomplete.setShowSuggestions}
onValueChange={autocomplete.handleValueChange}
>
{children}
<TemplateSuggestionPopup />
</BaseAutocomplete.Root>
);
}
function TemplateEditorModal({ autocompleteList, isOpen, onClose, onSave, value }: TemplateEditorModalProps) {
const expandedInputRef = useRef<HTMLTextAreaElement | null>(null);
const [draftValue, setDraftValue] = useState(value);
const autocomplete = useTemplateAutocomplete(draftValue, autocompleteList, expandedInputRef, setDraftValue);
const { setShowSuggestions } = autocomplete;
// Reset the draft whenever the modal opens so cancel never leaks unsaved changes.
useEffect(() => {
if (!isOpen) {
return;
} }
setDraftValue(value); onChange?.(event);
setShowSuggestions(false);
}, [isOpen, setShowSuggestions, value]);
const handleClose = () => {
setShowSuggestions(false);
onClose();
}; };
const handleSave = () => { const handleSuggestion = (value: string) => {
setInputValue((prev) => {
const remaining = matchRemaining(prev as string, value);
return prev + remaining;
});
setShowSuggestions(false); setShowSuggestions(false);
onSave(draftValue);
}; };
return ( return (
<Modal <div className={style.wrapper} ref={mergeRefs(localRef, ref)}>
isOpen={isOpen} <Input value={inputValue} {...rest} onChange={handleInputChange} fluid />
onClose={handleClose} {showSuggestions && suggestions.length > 0 && (
showBackdrop <ul className={style.suggestions}>
showCloseButton {suggestions.map((suggestion) => (
title='Edit template' <li key={suggestion} onClick={() => handleSuggestion(suggestion)}>
bodyElements={ {suggestion}
<TemplateAutocompleteRoot autocomplete={autocomplete} value={draftValue}> </li>
<BaseAutocomplete.Input ))}
autoFocus </ul>
className={style.expandedEditor} )}
onClick={autocomplete.updateCursor} </div>
onFocus={autocomplete.updateCursor}
onKeyUp={autocomplete.updateCursor}
onSelect={autocomplete.updateCursor}
render={<Textarea ref={expandedInputRef} fluid resize='none' rows={8} />}
/>
</TemplateAutocompleteRoot>
}
footerElements={
<>
<span className={style.footerHint}>Start a template with {'{{'} to see autocomplete.</span>
<Button onClick={handleClose}>Cancel</Button>
<Button onClick={handleSave} variant='primary'>
Save
</Button>
</>
}
/>
); );
} });
function TemplateSuggestionPopup() { export default TemplateInput;
return (
<BaseAutocomplete.Portal>
<BaseAutocomplete.Positioner side='bottom' align='start' className={style.positioner}>
<BaseAutocomplete.Popup className={style.popup}>
<BaseAutocomplete.List className={style.list}>
<BaseAutocomplete.Collection>
{(suggestion: string) => (
<BaseAutocomplete.Item key={suggestion} value={suggestion} className={style.item}>
{suggestion}
</BaseAutocomplete.Item>
)}
</BaseAutocomplete.Collection>
</BaseAutocomplete.List>
</BaseAutocomplete.Popup>
</BaseAutocomplete.Positioner>
</BaseAutocomplete.Portal>
);
}
@@ -1,4 +1,4 @@
import { completeTemplateAtCursor, matchRemaining, selectActiveTemplate } from '../templateInput.utils'; import { matchRemaining } from '../templateInput.utils';
describe('matchRemaining()', () => { describe('matchRemaining()', () => {
it('should return a partial string needed for autocomplete', () => { it('should return a partial string needed for autocomplete', () => {
@@ -15,54 +15,3 @@ describe('matchRemaining()', () => {
expect(matchRemaining('test', 'test')).toBe(''); expect(matchRemaining('test', 'test')).toBe('');
}); });
}); });
describe('selectActiveTemplate()', () => {
it('returns the last unclosed template fragment', () => {
expect(selectActiveTemplate('send {{event')).toBe('{{event');
expect(selectActiveTemplate('send {{eventNow.title}} and {{event')).toBe('{{event');
});
it('ignores single braces and closed templates', () => {
expect(selectActiveTemplate('send {')).toBe('');
expect(selectActiveTemplate('send {{eventNow.title}}')).toBe('');
});
it('only considers templates before the cursor', () => {
expect(selectActiveTemplate('send {{event}} then {{timer', 14)).toBe('');
expect(selectActiveTemplate('send {{event}} then {{timer', 27)).toBe('{{timer');
});
it('selects a partial template when the cursor is inside a completed template', () => {
expect(selectActiveTemplate('send {{timer.current}} after', 12)).toBe('{{timer');
});
});
describe('completeTemplateAtCursor()', () => {
it('completes the active template before the cursor', () => {
expect(completeTemplateAtCursor('send {{timer after', '{{timer.current}}', 12)).toEqual({
value: 'send {{timer.current}} after',
cursorIndex: 22,
});
});
it('preserves text before and after the cursor', () => {
expect(completeTemplateAtCursor('before {{event after', '{{eventNow.title}}', 14)).toEqual({
value: 'before {{eventNow.title}} after',
cursorIndex: 25,
});
});
it('preserves a following template when completing between templates', () => {
expect(completeTemplateAtCursor('{{clock}} and {{timer then {{eventNow.title}}', '{{timer.current}}', 21)).toEqual({
value: '{{clock}} and {{timer.current}} then {{eventNow.title}}',
cursorIndex: 31,
});
});
it('replaces the whole template when the cursor is inside a completed template', () => {
expect(completeTemplateAtCursor('before {{timer.current}} after', '{{timer.duration}}', 15)).toEqual({
value: 'before {{timer.duration}} after',
cursorIndex: 25,
});
});
});
@@ -54,16 +54,6 @@ const eventStaticPropertiesNext = [
'{{eventNext.delay}}', '{{eventNext.delay}}',
]; ];
const groupStaticPropertiesNow = [
'{{groupNow.id}}',
'{{groupNow.title}}',
'{{groupNow.note}}',
'{{groupNow.colour}}',
'{{groupNow.timeStart}}',
'{{groupNow.timeEnd}}',
'{{groupNow.duration}}',
];
const staticAuxProperties = (index: 1 | 2 | 3) => [ const staticAuxProperties = (index: 1 | 2 | 3) => [
`{{auxtimer${index}.current}}`, `{{auxtimer${index}.current}}`,
`{{auxtimer${index}.duration}}`, `{{auxtimer${index}.duration}}`,
@@ -85,19 +75,12 @@ export function makeAutoCompleteList(customFields: CustomFields): string[] {
...Object.entries(customFields).map(([key]) => `{{eventNow.custom.${key}}}`), ...Object.entries(customFields).map(([key]) => `{{eventNow.custom.${key}}}`),
...eventStaticPropertiesNext, ...eventStaticPropertiesNext,
...Object.entries(customFields).map(([key]) => `{{eventNext.custom.${key}}}`), ...Object.entries(customFields).map(([key]) => `{{eventNext.custom.${key}}}`),
...groupStaticPropertiesNow,
...Object.entries(customFields).map(([key]) => `{{groupNow.custom.${key}}}`),
...staticAuxProperties(1), ...staticAuxProperties(1),
...staticAuxProperties(2), ...staticAuxProperties(2),
...staticAuxProperties(3), ...staticAuxProperties(3),
]; ];
} }
interface TemplateCompletion {
cursorIndex: number;
value: string;
}
/** /**
* Returns the partial string b needed to autocomplete string a * Returns the partial string b needed to autocomplete string a
* @example matchRemaining('te', 'test') -> 'st' * @example matchRemaining('te', 'test') -> 'st'
@@ -128,54 +111,13 @@ export function matchRemaining(a: string, b: string) {
return ''; return '';
} }
function getActiveTemplateRange(text: string, cursorIndex = text.length) {
const textBeforeCursor = text.slice(0, cursorIndex);
const start = textBeforeCursor.lastIndexOf('{{');
if (start === -1) {
return null;
}
const closeBeforeCursor = textBeforeCursor.lastIndexOf('}}');
if (closeBeforeCursor > start) {
return null;
}
const closeAfterStart = text.indexOf('}}', start);
const nextStartAfterCursor = text.indexOf('{{', cursorIndex);
const closesBeforeNextTemplate = nextStartAfterCursor === -1 || closeAfterStart < nextStartAfterCursor;
const end = closeAfterStart !== -1 && closesBeforeNextTemplate ? closeAfterStart + 2 : cursorIndex;
return {
end,
start,
template: text.slice(start, cursorIndex),
};
}
/** /**
* Selects the last unclosed starting template before the cursor. * Selects the last starting template in a string
*/ */
export function selectActiveTemplate(text: string, cursorIndex = text.length) { export function selectFromLastTemplate(text: string) {
return getActiveTemplateRange(text, cursorIndex)?.template ?? ''; const lastBraceIndex = text.lastIndexOf('{{');
} if (lastBraceIndex !== -1) {
return text.slice(lastBraceIndex);
/**
* Replaces the active template fragment before the cursor with the selected suggestion.
*/
export function completeTemplateAtCursor(
text: string,
suggestion: string,
cursorIndex = text.length,
): TemplateCompletion {
const activeTemplateRange = getActiveTemplateRange(text, cursorIndex);
if (!activeTemplateRange) {
const value = text + matchRemaining(text, suggestion);
return { value, cursorIndex: value.length };
} }
return '';
const value = `${text.slice(0, activeTemplateRange.start)}${suggestion}${text.slice(activeTemplateRange.end)}`;
return {
value,
cursorIndex: activeTemplateRange.start + suggestion.length,
};
} }
@@ -1,72 +0,0 @@
import { Autocomplete as BaseAutocomplete } from '@base-ui/react/autocomplete';
import { useCallback, useMemo, useState, type RefObject } from 'react';
import { completeTemplateAtCursor, selectActiveTemplate } from './templateInput.utils';
type TemplateElement = HTMLInputElement | HTMLTextAreaElement;
function getCursorIndex(element: TemplateElement | null, fallback: string) {
return element?.selectionStart ?? fallback.length;
}
function focusCursor(element: TemplateElement | null, cursorIndex: number) {
requestAnimationFrame(() => {
element?.focus();
element?.setSelectionRange(cursorIndex, cursorIndex);
});
}
export function useTemplateAutocomplete<T extends TemplateElement>(
value: string,
autocompleteList: string[],
elementRef: RefObject<T | null>,
onValueChange: (value: string) => void,
) {
const [showSuggestions, setShowSuggestions] = useState(false);
const [cursor, setCursor] = useState(value.length);
const activeTemplate = selectActiveTemplate(value, cursor);
const suggestions = useMemo(() => {
if (!activeTemplate) {
return [];
}
return autocompleteList.filter((suggestion) => suggestion.startsWith(activeTemplate));
}, [activeTemplate, autocompleteList]);
const setCursorForValue = useCallback((nextValue: string, cursorIndex: number) => {
setCursor(cursorIndex);
setShowSuggestions(Boolean(selectActiveTemplate(nextValue, cursorIndex)));
}, []);
const updateCursor = useCallback(() => {
const cursorIndex = getCursorIndex(elementRef.current, value);
setCursorForValue(value, cursorIndex);
}, [elementRef, setCursorForValue, value]);
const handleValueChange = useCallback(
(nextValue: string, eventDetails: BaseAutocomplete.Root.ChangeEventDetails) => {
if (eventDetails.reason === 'item-press') {
eventDetails.cancel();
const completed = completeTemplateAtCursor(value, nextValue, cursor);
setCursorForValue(completed.value, completed.cursorIndex);
onValueChange(completed.value);
focusCursor(elementRef.current, completed.cursorIndex);
return;
}
const cursorIndex = getCursorIndex(elementRef.current, nextValue);
setCursorForValue(nextValue, cursorIndex);
onValueChange(nextValue);
},
[cursor, elementRef, onValueChange, setCursorForValue, value],
);
return {
handleValueChange,
open: showSuggestions && suggestions.length > 0,
setCursorForValue,
setShowSuggestions,
suggestions,
updateCursor,
};
}
@@ -1,8 +1,7 @@
import { OntimeView, URLPreset } from 'ontime-types'; import { URLPreset } from 'ontime-types';
import { useState } from 'react'; import { useState } from 'react';
import { IoAdd, IoOpenOutline, IoPencil, IoTrash } from 'react-icons/io5'; import { IoAdd, IoOpenOutline, IoPencil, IoTrash } from 'react-icons/io5';
import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button'; import Button from '../../../../common/components/buttons/Button';
import IconButton from '../../../../common/components/buttons/IconButton'; import IconButton from '../../../../common/components/buttons/IconButton';
import Info from '../../../../common/components/info/Info'; import Info from '../../../../common/components/info/Info';
@@ -23,23 +22,13 @@ const urlPresetsDocs = 'https://docs.getontime.no/features/url-presets/';
export default function URLPresets() { export default function URLPresets() {
const [formState, setFormState] = useState<FormState>({ isOpen: false, preset: undefined }); const [formState, setFormState] = useState<FormState>({ isOpen: false, preset: undefined });
const [actionError, setActionError] = useState<string | null>(null);
const { data, status } = useUrlPresets(); const { data, status } = useUrlPresets();
const { updatePreset, deletePreset, isMutating } = useUpdateUrlPreset(); const { deletePreset, isMutating } = useUpdateUrlPreset();
const openNewForm = () => setFormState({ isOpen: true }); const openNewForm = () => setFormState({ isOpen: true });
const openEditForm = (preset: URLPreset) => setFormState({ isOpen: true, preset }); const openEditForm = (preset: URLPreset) => setFormState({ isOpen: true, preset });
const closeForm = () => setFormState({ isOpen: false, preset: undefined }); const closeForm = () => setFormState({ isOpen: false, preset: undefined });
const persistPreset = async (preset: URLPreset) => {
setActionError(null);
try {
await updatePreset(preset.alias, preset);
} catch (error) {
setActionError(maybeAxiosError(error));
}
};
return ( return (
<Panel.Section> <Panel.Section>
<Panel.Card> <Panel.Card>
@@ -64,12 +53,10 @@ export default function URLPresets() {
<Panel.Section> <Panel.Section>
<Panel.Loader isLoading={status === 'pending'} /> <Panel.Loader isLoading={status === 'pending'} />
{formState.isOpen && <URLPresetForm urlPreset={formState.preset} onClose={closeForm} />} {formState.isOpen && <URLPresetForm urlPreset={formState.preset} onClose={closeForm} />}
{actionError && <Panel.Error>{actionError}</Panel.Error>}
<Panel.Table> <Panel.Table>
<thead> <thead>
<tr> <tr>
<th>Enabled</th> <th>Enabled</th>
<th>Show in nav</th>
<th>Target view</th> <th>Target view</th>
<th>Alias</th> <th>Alias</th>
<th /> <th />
@@ -78,23 +65,10 @@ export default function URLPresets() {
<tbody> <tbody>
{data.length === 0 && <Panel.TableEmpty handleClick={openNewForm} />} {data.length === 0 && <Panel.TableEmpty handleClick={openNewForm} />}
{data.map((preset, index) => { {data.map((preset, index) => {
const isCuesheet = preset.target === OntimeView.Cuesheet;
return ( return (
<tr key={preset.alias}> <tr key={preset.alias}>
<td> <td>
<Switch <Switch defaultChecked={preset.enabled} onCheckedChange={() => {}} />
checked={preset.enabled}
onCheckedChange={(enabled) => persistPreset({ ...preset, enabled })}
disabled={isMutating}
aria-label='Toggle preset enabled'
/>
</td>
<td>
<Switch
checked={preset.displayInNav}
onCheckedChange={(checked) => persistPreset({ ...preset, displayInNav: checked })}
disabled={isMutating || isCuesheet}
/>
</td> </td>
<td> <td>
<Tag>{preset.target}</Tag> <Tag>{preset.target}</Tag>
@@ -1,5 +1,5 @@
import { OntimeView, OntimeViewPresettable, URLPreset } from 'ontime-types'; import { OntimeView, OntimeViewPresettable, URLPreset } from 'ontime-types';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { maybeAxiosError, unwrapError } from '../../../../../common/api/utils'; import { maybeAxiosError, unwrapError } from '../../../../../common/api/utils';
@@ -12,7 +12,6 @@ import { preventEscape } from '../../../../../common/utils/keyEvent';
import { isUrlSafe } from '../../../../../common/utils/regex'; import { isUrlSafe } from '../../../../../common/utils/regex';
import { enDash } from '../../../../../common/utils/styleUtils'; import { enDash } from '../../../../../common/utils/styleUtils';
import { generateUrlPresetOptions } from '../../../../../common/utils/urlPresets'; import { generateUrlPresetOptions } from '../../../../../common/utils/urlPresets';
import CuesheetLinkOptions, { CuesheetPermissionValues } from '../../../../sharing/composite/CuesheetLinkOptions';
import * as Panel from '../../../panel-utils/PanelUtils'; import * as Panel from '../../../panel-utils/PanelUtils';
import style from './URLPresetForm.module.scss'; import style from './URLPresetForm.module.scss';
@@ -33,7 +32,6 @@ const defaultValues: URLPreset = {
target: OntimeView.Timer, target: OntimeView.Timer,
search: '', search: '',
enabled: true, enabled: true,
displayInNav: false,
}; };
interface URLPresetFormProps { interface URLPresetFormProps {
@@ -63,42 +61,12 @@ export default function URLPresetForm({ urlPreset, onClose }: URLPresetFormProps
}); });
const urlRef = useRef<HTMLInputElement>(null); const urlRef = useRef<HTMLInputElement>(null);
// Cuesheet read/write permissions live outside react-hook-form
const initialPermissions = useRef<CuesheetPermissionValues>({
read: urlPreset?.options?.read ?? 'full',
write: urlPreset?.options?.write ?? 'full',
});
const [cuesheetPermissions, setCuesheetPermissions] = useState<CuesheetPermissionValues>(initialPermissions.current);
// update initial permissions on mount
useEffect(() => {
initialPermissions.current = {
read: urlPreset?.options?.read ?? 'full',
write: urlPreset?.options?.write ?? 'full',
};
setCuesheetPermissions(initialPermissions.current);
// oxlint-disable-next-line eslint-plugin-react-hooks/exhaustive-deps -- run on mount
}, []);
const isEditingCuesheet = urlPreset && urlPreset.target === OntimeView.Cuesheet;
const isCuesheet = watch('target') === OntimeView.Cuesheet;
const permissionsDirty =
isCuesheet &&
(cuesheetPermissions.read !== initialPermissions.current.read ||
cuesheetPermissions.write !== initialPermissions.current.write);
const noReadAccess = isCuesheet && cuesheetPermissions.read === '-';
const setupSubmit = async (data: URLPreset) => { const setupSubmit = async (data: URLPreset) => {
try { try {
// Preserve / apply cuesheet permissions, which are not part of the form fields
const payload: URLPreset =
data.target === OntimeView.Cuesheet
? { ...data, target: OntimeView.Cuesheet, options: cuesheetPermissions }
: data;
if (urlPreset) { if (urlPreset) {
await updatePreset(urlPreset.alias, payload); await updatePreset(urlPreset.alias, data);
} else { } else {
await addPreset(payload); await addPreset(data);
} }
onClose(); onClose();
} catch (error) { } catch (error) {
@@ -106,7 +74,6 @@ export default function URLPresetForm({ urlPreset, onClose }: URLPresetFormProps
} }
}; };
// focus on alias when the form opens
useEffect(() => { useEffect(() => {
setFocus('alias'); setFocus('alias');
}, [setFocus]); }, [setFocus]);
@@ -159,66 +126,44 @@ export default function URLPresetForm({ urlPreset, onClose }: URLPresetFormProps
<div className={style.expand}> <div className={style.expand}>
<Panel.Description>Generate options (paste URL to generate options)</Panel.Description> <Panel.Description>Generate options (paste URL to generate options)</Panel.Description>
<Panel.InlineElements> <Panel.InlineElements>
<Input placeholder='Paste URL' fluid ref={urlRef} disabled={isEditingCuesheet} /> <Input placeholder='Paste URL' fluid ref={urlRef} />
<Button onClick={generateOptions} disabled={isEditingCuesheet}> <Button onClick={generateOptions}>Generate</Button>
Generate
</Button>
</Panel.InlineElements> </Panel.InlineElements>
</div> </div>
</Panel.InlineElements> </Panel.InlineElements>
{errors.alias?.message && <Panel.Error>{errors.alias.message}</Panel.Error>} {errors.alias?.message && <Panel.Error>{errors.alias.message}</Panel.Error>}
{!isEditingCuesheet && ( <div>
<> {enDash} or {enDash}
<div> </div>
{enDash} or {enDash} <div>2. Choose a view and its parameters</div>
</div> <div>
<div>2. Choose a view and its parameters</div> <Panel.Description>Target</Panel.Description>
<div> <Select
<Panel.Description>Target</Panel.Description> options={targetOptions}
<Select {...register('target', { required: 'Target is required' })}
options={targetOptions} value={watch('target')}
{...register('target', { required: 'Target is required' })} onValueChange={(value: OntimeViewPresettable | null) => {
value={watch('target')} if (value === null) return;
onValueChange={(value: OntimeViewPresettable | null) => { setValue('target', value, { shouldDirty: true });
if (value === null) return; }}
setValue('target', value, { shouldDirty: true }); />
}} </div>
/> <div>
</div> <Panel.Description>Parameters</Panel.Description>
<div> <Textarea
<Panel.Description>Parameters</Panel.Description> fluid
<Textarea rows={3}
fluid {...register('search', {
rows={3} validate: validateParams,
{...register('search', { })}
validate: validateParams, />
})} <Panel.Error>{errors.search?.message}</Panel.Error>
/> </div>
<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> <div>
<Panel.Error>{errors.root?.message}</Panel.Error> <Panel.Error>{errors.root?.message}</Panel.Error>
<Panel.InlineElements align='end'> <Panel.InlineElements align='end'>
<Button onClick={onClose}>Cancel</Button> <Button onClick={onClose}>Cancel</Button>
<Button <Button variant='primary' type='submit' disabled={!isValid || !isDirty} loading={isSubmitting || isMutating}>
variant='primary'
type='submit'
disabled={!isValid || (!isDirty && !permissionsDirty) || noReadAccess}
loading={isSubmitting || isMutating}
>
Save Save
</Button> </Button>
</Panel.InlineElements> </Panel.InlineElements>
@@ -1,6 +1,4 @@
import type { import type {
ImportedFields,
RundownImportMode,
SpreadsheetPreviewResponse, SpreadsheetPreviewResponse,
SpreadsheetWorksheetMetadata, SpreadsheetWorksheetMetadata,
SpreadsheetWorksheetOptions, SpreadsheetWorksheetOptions,
@@ -24,7 +22,7 @@ import Info from '../../../../../common/components/info/Info';
import ExternalLink from '../../../../../common/components/link/external-link/ExternalLink'; import ExternalLink from '../../../../../common/components/link/external-link/ExternalLink';
import Modal from '../../../../../common/components/modal/Modal'; import Modal from '../../../../../common/components/modal/Modal';
import useRundown from '../../../../../common/hooks-query/useRundown'; import useRundown from '../../../../../common/hooks-query/useRundown';
import { removeFileExtension, validateExcelImport } from '../../../../../common/utils/uploadUtils'; import { validateExcelImport } from '../../../../../common/utils/uploadUtils';
import * as Panel from '../../../panel-utils/PanelUtils'; import * as Panel from '../../../panel-utils/PanelUtils';
import GSheetSetup from './GSheetSetup'; import GSheetSetup from './GSheetSetup';
import SheetImportEditor from './sheet-import/SheetImportEditor'; import SheetImportEditor from './sheet-import/SheetImportEditor';
@@ -37,7 +35,6 @@ const googleSheetDocsUrl = 'https://docs.getontime.no/features/import-spreadshee
type ActiveSource = type ActiveSource =
| { | {
kind: 'excel'; kind: 'excel';
fileName: string;
worksheetNames: string[]; worksheetNames: string[];
initialWorksheetMetadata: SpreadsheetWorksheetMetadata | null; initialWorksheetMetadata: SpreadsheetWorksheetMetadata | null;
closedByUser: boolean; closedByUser: boolean;
@@ -58,7 +55,7 @@ export default function SourcesPanel() {
const [activeSource, setActiveSource] = useState<ActiveSource | null>(null); const [activeSource, setActiveSource] = useState<ActiveSource | null>(null);
const { data: currentRundown } = useRundown(); const { data: currentRundown } = useRundown();
const { applyImport } = useSpreadsheetImport(); const { importRundown } = useSpreadsheetImport();
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
@@ -77,7 +74,6 @@ export default function SourcesPanel() {
const worksheetOptions = await uploadExcel(fileToUpload); const worksheetOptions = await uploadExcel(fileToUpload);
setActiveSource({ setActiveSource({
kind: 'excel', kind: 'excel',
fileName: fileToUpload.name,
worksheetNames: worksheetOptions.worksheets, worksheetNames: worksheetOptions.worksheets,
initialWorksheetMetadata: worksheetOptions.metadata, initialWorksheetMetadata: worksheetOptions.metadata,
closedByUser: false, closedByUser: false,
@@ -130,32 +126,21 @@ export default function SourcesPanel() {
setError(''); setError('');
}; };
const handleApplyImport = async ( const handleApplyImport = async (preview: SpreadsheetPreviewResponse) => {
preview: SpreadsheetPreviewResponse,
mode: RundownImportMode,
newRundownTitle: string,
providedFields: ImportedFields,
) => {
if (mode === 'new') {
const title = newRundownTitle.trim() || preview.rundown.title;
await applyImport({ mode: 'new', rundown: { ...preview.rundown, title }, customFields: preview.customFields });
handleFinished();
return;
}
if (!currentRundown) { if (!currentRundown) {
throw new Error('No current rundown loaded'); throw new Error('No current rundown loaded');
} }
// override or merge into the current rundown; merge uses providedFields to know which columns await importRundown(
// the sheet supplied so matched events keep the rest (e.g. automations) {
await applyImport({ [currentRundown.id]: {
mode, ...preview.rundown,
targetRundownId: currentRundown.id, id: currentRundown.id,
rundown: preview.rundown, title: currentRundown.title,
customFields: preview.customFields, },
providedFields, },
}); preview.customFields,
);
handleFinished(); handleFinished();
}; };
@@ -223,13 +208,6 @@ export default function SourcesPanel() {
if (activeSource.kind === 'excel') return 'excel'; if (activeSource.kind === 'excel') return 'excel';
return `gsheet:${activeSource.sheetId}`; return `gsheet:${activeSource.sheetId}`;
})(); })();
// suggested name when importing into a new rundown: the spreadsheet file name (without extension)
// for Excel, or the document title for Google Sheets
const spreadsheetName = (() => {
if (!activeSource) return '';
if (activeSource.kind === 'excel') return removeFileExtension(activeSource.fileName);
return activeSource.title;
})();
return ( return (
<Panel.Section> <Panel.Section>
@@ -309,7 +287,6 @@ export default function SourcesPanel() {
bodyElements={ bodyElements={
<SheetImportEditor <SheetImportEditor
sourceKey={sourceKey ?? 'spreadsheet'} sourceKey={sourceKey ?? 'spreadsheet'}
defaultRundownName={spreadsheetName}
worksheetNames={activeSource?.worksheetNames ?? []} worksheetNames={activeSource?.worksheetNames ?? []}
initialMetadata={activeSource?.initialWorksheetMetadata ?? null} initialMetadata={activeSource?.initialWorksheetMetadata ?? null}
loadMetadata={loadWorksheetMetadata} loadMetadata={loadWorksheetMetadata}
@@ -1,55 +0,0 @@
import type { RundownImportMode, SpreadsheetPreviewResponse } from 'ontime-types';
import { isOntimeEvent, isPlayableEvent, Playback } from 'ontime-types';
import { useEffect, useState } from 'react';
import Button from '../../../../../../common/components/buttons/Button';
import { usePlayback, useSelectedEventId } from '../../../../../../common/hooks/useSocket';
interface ApplyImportButtonProps {
preview: SpreadsheetPreviewResponse | null;
mode: RundownImportMode;
disabled: boolean;
loading: boolean;
onApply: () => void;
}
/**
* Apply action for the spreadsheet import.
* Subscribes to playback state on its own so playback updates do not re-render the whole editor.
* Requires a second click to confirm when applying would stop a running playback.
*/
export default function ApplyImportButton({ preview, mode, disabled, loading, onApply }: ApplyImportButtonProps) {
const playback = usePlayback();
const loadedEventId = useSelectedEventId();
// the loaded (playing) event loses its playback unless it still exists as a playable event after the import
const loadedEntry = loadedEventId ? preview?.rundown.entries[loadedEventId] : undefined;
const willLoadedEventBeOverriden = !(
loadedEntry !== undefined &&
isOntimeEvent(loadedEntry) &&
isPlayableEvent(loadedEntry)
);
// applying stops playback when creating a new rundown, or when the playing event does not survive
const willStopPlayback = playback !== Playback.Stop && (mode === 'new' || willLoadedEventBeOverriden);
// two-step confirmation before applying an import that stops playback
const [confirmStop, setConfirmStop] = useState(false);
useEffect(() => {
setConfirmStop(false);
}, [mode, preview]);
const handleClick = () => {
if (willStopPlayback && !confirmStop) {
setConfirmStop(true);
return;
}
onApply();
};
return (
<Button variant='primary' onClick={handleClick} disabled={disabled} loading={loading}>
{willStopPlayback && confirmStop ? 'Confirm — stop playback & apply import' : 'Apply import'}
</Button>
);
}
@@ -33,11 +33,6 @@
white-space: nowrap; white-space: nowrap;
} }
.importModeTrigger {
min-width: 12rem;
justify-content: space-between;
}
.addColumnTrigger { .addColumnTrigger {
justify-content: center; justify-content: center;
white-space: nowrap; white-space: nowrap;
@@ -1,20 +1,10 @@
import type { import type { SpreadsheetPreviewResponse, SpreadsheetWorksheetMetadata } from 'ontime-types';
ImportedFields,
RundownImportMode,
SpreadsheetPreviewResponse,
SpreadsheetWorksheetMetadata,
} from 'ontime-types';
import type { ImportMap } from 'ontime-utils'; import type { ImportMap } from 'ontime-utils';
import { useMemo } from 'react'; import { IoArrowUpOutline, IoEye } from 'react-icons/io5';
import { IoArrowUpOutline, IoCheckmark, IoChevronDown, IoEye, IoWarningOutline } from 'react-icons/io5';
import Button from '../../../../../../common/components/buttons/Button'; import Button from '../../../../../../common/components/buttons/Button';
import { DropdownMenu, DropdownMenuOption } from '../../../../../../common/components/dropdown-menu/DropdownMenu';
import Input from '../../../../../../common/components/input/input/Input';
import Select from '../../../../../../common/components/select/Select'; import Select from '../../../../../../common/components/select/Select';
import * as Panel from '../../../../panel-utils/PanelUtils'; import * as Panel from '../../../../panel-utils/PanelUtils';
import ApplyImportButton from './ApplyImportButton';
import { isIdColumnMapped } from './importMapUtils';
import PreviewTable from './preview/PreviewTable'; import PreviewTable from './preview/PreviewTable';
import SheetImportMappingPane from './SheetImportMappingPane'; import SheetImportMappingPane from './SheetImportMappingPane';
import { useSheetImportForm } from './useSheetImportForm'; import { useSheetImportForm } from './useSheetImportForm';
@@ -23,46 +13,17 @@ import style from './SheetImportEditor.module.scss';
interface SheetImportEditorProps { interface SheetImportEditorProps {
sourceKey: string; sourceKey: string;
defaultRundownName: string;
worksheetNames: string[]; worksheetNames: string[];
initialMetadata: SpreadsheetWorksheetMetadata | null; initialMetadata: SpreadsheetWorksheetMetadata | null;
loadMetadata: (worksheet: string) => Promise<SpreadsheetWorksheetMetadata>; loadMetadata: (worksheet: string) => Promise<SpreadsheetWorksheetMetadata>;
previewImport: (importMap: ImportMap) => Promise<SpreadsheetPreviewResponse>; previewImport: (importMap: ImportMap) => Promise<SpreadsheetPreviewResponse>;
onApply: ( onApply: (preview: SpreadsheetPreviewResponse) => Promise<void>;
preview: SpreadsheetPreviewResponse,
mode: RundownImportMode,
newRundownTitle: string,
providedFields: ImportedFields,
) => Promise<void>;
onCancel: () => void; onCancel: () => void;
onExport?: (importMap: ImportMap) => Promise<void>; onExport?: (importMap: ImportMap) => Promise<void>;
} }
const importModeOptions: Array<{
value: RundownImportMode;
label: string;
description: string;
}> = [
{
value: 'override',
label: 'Replace current rundown',
description: 'Spreadsheet data completely replaces current rundown',
},
{
value: 'merge',
label: 'Merge with current rundown',
description: 'Merge entries referencing their IDs, entries not present in spreadsheet rundown are deleted',
},
{
value: 'new',
label: 'New rundown',
description: 'Create a new rundown to import the data into. Loads the new rundown',
},
];
export default function SheetImportEditor({ export default function SheetImportEditor({
sourceKey, sourceKey,
defaultRundownName,
worksheetNames, worksheetNames,
initialMetadata, initialMetadata,
loadMetadata, loadMetadata,
@@ -88,16 +49,11 @@ export default function SheetImportEditor({
isBusy, isBusy,
canPreview, canPreview,
displayError, displayError,
importMode,
setImportMode,
newRundownTitle,
setNewRundownTitle,
handlePreviewSubmit, handlePreviewSubmit,
handleExportSubmit, handleExportSubmit,
handleApply, handleApply,
} = useSheetImportForm({ } = useSheetImportForm({
sourceKey, sourceKey,
defaultRundownName,
worksheetNames, worksheetNames,
initialMetadata, initialMetadata,
loadMetadata, loadMetadata,
@@ -106,19 +62,6 @@ export default function SheetImportEditor({
onExport, onExport,
}); });
const selectedImportMode = importModeOptions.find((option) => option.value === importMode) ?? importModeOptions[0];
const importModeItems = useMemo<DropdownMenuOption[]>(
() =>
importModeOptions.map((option) => ({
type: 'item',
label: option.label,
description: option.description,
icon: importMode === option.value ? IoCheckmark : undefined,
onClick: () => setImportMode(option.value),
})),
[importMode, setImportMode],
);
return ( return (
<Panel.Section as='form' id='spreadsheet-import-workspace' className={style.editor} onSubmit={handlePreviewSubmit}> <Panel.Section as='form' id='spreadsheet-import-workspace' className={style.editor} onSubmit={handlePreviewSubmit}>
<Panel.InlineElements align='apart' wrap='wrap' className={style.editorToolbar}> <Panel.InlineElements align='apart' wrap='wrap' className={style.editorToolbar}>
@@ -164,63 +107,33 @@ export default function SheetImportEditor({
</div> </div>
{displayError && <Panel.Error>{displayError}</Panel.Error>} {displayError && <Panel.Error>{displayError}</Panel.Error>}
{importMode === 'merge' && !isIdColumnMapped(values) && ( <Panel.InlineElements align='end' wrap='wrap' className={style.editorFooter}>
<Panel.Description tone='warning'> <Button onClick={onCancel} disabled={isBusy}>
<IoWarningOutline /> No ID column mapped merge matches entries by ID, so it will behave like Replace. Export Cancel
your rundown to a spreadsheet first to keep its IDs. </Button>
</Panel.Description> {onExport && (
)} <Button onClick={handleExportSubmit} disabled={!canPreview} loading={state.loading === 'export'}>
<Panel.InlineElements align='apart' wrap='wrap' className={style.editorFooter}> <IoArrowUpOutline />
<Panel.InlineElements wrap='wrap'> Export
<label className={style.worksheetControl}>
<span className={style.worksheetLabel}>Import mode</span>
<DropdownMenu
render={<Button className={style.importModeTrigger} variant='subtle-white' />}
items={importModeItems}
>
{selectedImportMode.label}
<IoChevronDown />
</DropdownMenu>
</label>
{importMode === 'new' && (
<label className={style.worksheetControl}>
<span className={style.worksheetLabel}>New rundown name</span>
<Input
value={newRundownTitle}
onChange={(event) => setNewRundownTitle(event.target.value)}
placeholder={state.preview?.rundown.title || 'Imported rundown'}
aria-label='New rundown name'
/>
</label>
)}
</Panel.InlineElements>
<Panel.InlineElements wrap='wrap'>
<Button onClick={onCancel} disabled={isBusy}>
Cancel
</Button> </Button>
{onExport && ( )}
<Button onClick={handleExportSubmit} disabled={!canPreview} loading={state.loading === 'export'}> <Button
<IoArrowUpOutline /> variant={state.preview ? undefined : 'primary'}
Export onClick={handlePreviewSubmit}
</Button> disabled={!canPreview}
)} loading={state.loading === 'preview'}
<Button >
variant={state.preview ? undefined : 'primary'} <IoEye />
onClick={handlePreviewSubmit} Preview import
disabled={!canPreview} </Button>
loading={state.loading === 'preview'} <Button
> variant='primary'
<IoEye /> onClick={handleApply}
Preview import disabled={!state.preview || isBusy}
</Button> loading={state.loading === 'apply'}
<ApplyImportButton >
preview={state.preview} Apply import
mode={importMode} </Button>
disabled={!state.preview || isBusy}
loading={state.loading === 'apply'}
onApply={handleApply}
/>
</Panel.InlineElements>
</Panel.InlineElements> </Panel.InlineElements>
</Panel.Section> </Panel.Section>
); );
@@ -1,21 +1,15 @@
import { beforeEach, describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { import {
builtInFieldDefs, builtInFieldDefs,
convertToImportMap, convertToImportMap,
createDefaultFormValues, createDefaultFormValues,
defaultImportMode,
getImportWarnings, getImportWarnings,
getPersistedImportMode,
getProvidedImportFields,
getResolvedCustomFields, getResolvedCustomFields,
isIdColumnMapped,
persistImportMode,
} from '../importMapUtils'; } from '../importMapUtils';
const cueIndex = builtInFieldDefs.findIndex((field) => field.label === 'Cue'); const cueIndex = builtInFieldDefs.findIndex((field) => field.label === 'Cue');
const titleIndex = builtInFieldDefs.findIndex((field) => field.label === 'Title'); const titleIndex = builtInFieldDefs.findIndex((field) => field.label === 'Title');
const idIndex = builtInFieldDefs.findIndex((field) => field.importKey === 'id');
describe('getImportWarnings()', () => { describe('getImportWarnings()', () => {
it('warns when two mappings target the same spreadsheet column', () => { it('warns when two mappings target the same spreadsheet column', () => {
@@ -134,64 +128,3 @@ describe('convertToImportMap()', () => {
}); });
}); });
}); });
describe('getProvidedImportFields()', () => {
it('reports the mapped built-in and custom fields the sheet supplies', () => {
const values = createDefaultFormValues();
values.builtIn[titleIndex] = { header: 'title', enabled: true };
values.builtIn[cueIndex] = { header: '', enabled: false };
values.custom = [{ ontimeName: 'ignored', importName: 'FOH/Monitor' }];
const provided = getProvidedImportFields(convertToImportMap(values));
// enabled built-in mappings are reported as event fields, disabled ones are not
expect(provided.event).toContain('title');
expect(provided.event).not.toContain('cue');
// the id column is only used for matching, never overwritten
expect(provided.event).not.toContain('id');
// custom fields are reported symmetrically by their resolved Ontime name
expect(provided.custom).toStrictEqual(['FOH Monitor']);
});
});
describe('isIdColumnMapped()', () => {
it('is true when the ID field is enabled with a header', () => {
// the default form maps the ID column
expect(isIdColumnMapped(createDefaultFormValues())).toBe(true);
});
it('is false when the ID field is disabled', () => {
const values = createDefaultFormValues();
values.builtIn[idIndex] = { header: 'id', enabled: false };
expect(isIdColumnMapped(values)).toBe(false);
});
it('is false when the ID field header is blank', () => {
const values = createDefaultFormValues();
values.builtIn[idIndex] = { header: ' ', enabled: true };
expect(isIdColumnMapped(values)).toBe(false);
});
});
describe('import mode persistence', () => {
const sourceKey = 'excel-test';
beforeEach(() => {
localStorage.clear();
});
it('defaults to override when nothing is persisted', () => {
expect(getPersistedImportMode(sourceKey)).toBe('override');
expect(defaultImportMode).toBe('override');
});
it('round-trips a persisted value', () => {
persistImportMode(sourceKey, 'merge');
expect(getPersistedImportMode(sourceKey)).toBe('merge');
});
it('falls back to the default when the persisted value is invalid', () => {
persistImportMode(sourceKey, 'nonsense' as never);
expect(getPersistedImportMode(sourceKey)).toBe('override');
});
});
@@ -1,4 +1,3 @@
import type { ImportedFields, RundownImportMode } from 'ontime-types';
import type { ImportMap } from 'ontime-utils'; import type { ImportMap } from 'ontime-utils';
import { makeStageKey } from '../../../../../../common/utils/localStorage'; import { makeStageKey } from '../../../../../../common/utils/localStorage';
@@ -50,16 +49,6 @@ export function createDefaultFormValues(): ImportFormValues {
}; };
} }
/**
* Whether the mapping supplies an ID column. Merge matches entries by ID, so without one every
* imported entry gets a fresh id and nothing can reconcile with the current rundown.
*/
export function isIdColumnMapped(values: ImportFormValues): boolean {
const idIndex = builtInFieldDefs.findIndex((def) => def.importKey === 'id');
const field = values.builtIn[idIndex];
return Boolean(field?.enabled && field.header.trim());
}
function sanitiseOntimeCustomFieldLabel(importName: string): string { function sanitiseOntimeCustomFieldLabel(importName: string): string {
// Replace punctuation with spaces, then collapse repeated whitespace into single spaces. // Replace punctuation with spaces, then collapse repeated whitespace into single spaces.
const sanitised = importName const sanitised = importName
@@ -92,24 +81,6 @@ export function getResolvedCustomFields(customFields: ImportFormValues['custom']
}); });
} }
/**
* Returns the fields the import map supplies — the complete description of what the incoming data
* provides, for both built-in and custom fields. A merge uses this to patch exactly these fields
* onto a matched event and keep everything else (e.g. automations) untouched.
* Import-map keys are OntimeEvent field names; `worksheet`/`custom` are meta and `id` is only used
* for matching, not overwritten.
*/
export function getProvidedImportFields(importMap: ImportMap): ImportedFields {
const event: string[] = [];
for (const [key, value] of Object.entries(importMap)) {
if (key === 'worksheet' || key === 'custom' || key === 'id') continue;
if (typeof value === 'string' && value.trim() !== '') {
event.push(key);
}
}
return { event, custom: Object.keys(importMap.custom) };
}
export function convertToImportMap(values: ImportFormValues): ImportMap { export function convertToImportMap(values: ImportFormValues): ImportMap {
const custom = getResolvedCustomFields(values.custom).reduce<Record<string, string>>( const custom = getResolvedCustomFields(values.custom).reduce<Record<string, string>>(
(accumulator, { ontimeName, importName }) => { (accumulator, { ontimeName, importName }) => {
@@ -159,12 +130,12 @@ function isPersistedFormValues(obj: unknown): obj is ImportFormValues {
export function getPersistedImportState(sourceKey: string): { values: ImportFormValues; isPersisted: boolean } { export function getPersistedImportState(sourceKey: string): { values: ImportFormValues; isPersisted: boolean } {
const storageKey = getImportMapKey(sourceKey); const storageKey = getImportMapKey(sourceKey);
try { try {
const persistedData = localStorage.getItem(storageKey); const raw = localStorage.getItem(storageKey);
if (!persistedData) { if (!raw) {
return { values: createDefaultFormValues(), isPersisted: false }; return { values: createDefaultFormValues(), isPersisted: false };
} }
const parsed: unknown = JSON.parse(persistedData); const parsed: unknown = JSON.parse(raw);
if (isPersistedFormValues(parsed)) { if (isPersistedFormValues(parsed)) {
return { values: parsed, isPersisted: true }; return { values: parsed, isPersisted: true };
} }
@@ -179,32 +150,6 @@ export function getPersistedImportState(sourceKey: string): { values: ImportForm
} }
} }
/**
* The import mode (new / merge / override) is persisted separately from the field mapping
* so the mapping schema guard stays untouched.
*/
/** Default import mode: replace matched elements in the current rundown */
export const defaultImportMode: RundownImportMode = 'override';
function getImportModeKey(sourceKey: string) {
return makeStageKey(`import-mode:${sourceKey}`);
}
/** Persists the import mode for a given source */
export function persistImportMode(sourceKey: string, mode: RundownImportMode) {
localStorage.setItem(getImportModeKey(sourceKey), mode);
}
/** Reads the persisted import mode for a source, falling back to the default when absent or invalid */
export function getPersistedImportMode(sourceKey: string): RundownImportMode {
const persisted = localStorage.getItem(getImportModeKey(sourceKey));
if (persisted === 'new' || persisted === 'merge' || persisted === 'override') {
return persisted;
}
return defaultImportMode;
}
/** /**
* Validates import mappings and generates warnings for duplicate or missing spreadsheet columns. * Validates import mappings and generates warnings for duplicate or missing spreadsheet columns.
*/ */
@@ -1,12 +1,7 @@
import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query';
import type { import type { SpreadsheetPreviewResponse, SpreadsheetWorksheetMetadata } from 'ontime-types';
ImportedFields,
RundownImportMode,
SpreadsheetPreviewResponse,
SpreadsheetWorksheetMetadata,
} from 'ontime-types';
import { millisToString } from 'ontime-utils'; import { millisToString } from 'ontime-utils';
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react';
import { useFieldArray, useForm } from 'react-hook-form'; import { useFieldArray, useForm } from 'react-hook-form';
import { maybeAxiosError } from '../../../../../../common/api/utils'; import { maybeAxiosError } from '../../../../../../common/api/utils';
@@ -16,11 +11,8 @@ import {
builtInFieldDefs, builtInFieldDefs,
convertToImportMap, convertToImportMap,
getImportWarnings, getImportWarnings,
getPersistedImportMode,
getPersistedImportState, getPersistedImportState,
getProvidedImportFields,
getResolvedCustomFields, getResolvedCustomFields,
persistImportMode,
persistImportState, persistImportState,
} from './importMapUtils'; } from './importMapUtils';
import { deriveHeaderOptionsState } from './spreadsheetImportUtils'; import { deriveHeaderOptionsState } from './spreadsheetImportUtils';
@@ -108,23 +100,16 @@ function buildColumnLabels(values: ImportFormValues): string[] {
interface UseSheetImportFormProps { interface UseSheetImportFormProps {
sourceKey: string; sourceKey: string;
defaultRundownName: string;
worksheetNames: string[]; worksheetNames: string[];
initialMetadata: SpreadsheetWorksheetMetadata | null; initialMetadata: SpreadsheetWorksheetMetadata | null;
loadMetadata: (worksheet: string) => Promise<SpreadsheetWorksheetMetadata>; loadMetadata: (worksheet: string) => Promise<SpreadsheetWorksheetMetadata>;
previewImport: (importMap: ReturnType<typeof convertToImportMap>) => Promise<SpreadsheetPreviewResponse>; previewImport: (importMap: ReturnType<typeof convertToImportMap>) => Promise<SpreadsheetPreviewResponse>;
onApply: ( onApply: (preview: SpreadsheetPreviewResponse) => Promise<void>;
preview: SpreadsheetPreviewResponse,
mode: RundownImportMode,
newRundownTitle: string,
providedFields: ImportedFields,
) => Promise<void>;
onExport?: (importMap: ReturnType<typeof convertToImportMap>) => Promise<void>; onExport?: (importMap: ReturnType<typeof convertToImportMap>) => Promise<void>;
} }
export function useSheetImportForm({ export function useSheetImportForm({
sourceKey, sourceKey,
defaultRundownName,
worksheetNames, worksheetNames,
initialMetadata, initialMetadata,
loadMetadata, loadMetadata,
@@ -185,8 +170,6 @@ export function useSheetImportForm({
const columnLabels = buildColumnLabels(values); const columnLabels = buildColumnLabels(values);
const [state, dispatch] = useReducer(importReducer, initialImportState); const [state, dispatch] = useReducer(importReducer, initialImportState);
const [importMode, setImportMode] = useState<RundownImportMode>(() => getPersistedImportMode(sourceKey));
const [newRundownTitle, setNewRundownTitle] = useState(defaultRundownName);
const warnings = getImportWarnings(values, headers); const warnings = getImportWarnings(values, headers);
const warningCount = Object.values(warnings).filter(Boolean).length; const warningCount = Object.values(warnings).filter(Boolean).length;
const previewRef = useRef<SpreadsheetPreviewResponse | null>(null); const previewRef = useRef<SpreadsheetPreviewResponse | null>(null);
@@ -199,12 +182,6 @@ export function useSheetImportForm({
dispatch({ type: 'reset' }); dispatch({ type: 'reset' });
}, [initialFormValues, reset]); }, [initialFormValues, reset]);
// Update import mode and rundown name if the source changes
useEffect(() => {
setImportMode(getPersistedImportMode(sourceKey));
setNewRundownTitle(defaultRundownName);
}, [sourceKey, defaultRundownName]);
// Keep the worksheet selection valid if the available worksheets change underneath the form. // Keep the worksheet selection valid if the available worksheets change underneath the form.
useEffect(() => { useEffect(() => {
if (worksheetNames.length === 0) return; if (worksheetNames.length === 0) return;
@@ -264,15 +241,13 @@ export function useSheetImportForm({
try { try {
dispatch({ type: 'startApply' }); dispatch({ type: 'startApply' });
const providedFields = getProvidedImportFields(convertToImportMap(getValues())); await onApply(state.preview);
await onApply(state.preview, importMode, newRundownTitle, providedFields);
persistImportState(sourceKey, getValues()); persistImportState(sourceKey, getValues());
persistImportMode(sourceKey, importMode);
dispatch({ type: 'applySuccess' }); dispatch({ type: 'applySuccess' });
} catch (error) { } catch (error) {
dispatch({ type: 'failure', error: maybeAxiosError(error) }); dispatch({ type: 'failure', error: maybeAxiosError(error) });
} }
}, [getValues, importMode, newRundownTitle, onApply, sourceKey, state.preview]); }, [getValues, onApply, sourceKey, state.preview]);
const handleExport = useCallback( const handleExport = useCallback(
async (formValues: ImportFormValues) => { async (formValues: ImportFormValues) => {
@@ -325,10 +300,6 @@ export function useSheetImportForm({
isBusy, isBusy,
canPreview, canPreview,
displayError, displayError,
importMode,
setImportMode,
newRundownTitle,
setNewRundownTitle,
handlePreviewSubmit: handleSubmit(handlePreview), handlePreviewSubmit: handleSubmit(handlePreview),
handleExportSubmit: handleSubmit(handleExport), handleExportSubmit: handleSubmit(handleExport),
handleApply, handleApply,
@@ -1,17 +1,30 @@
import { RundownImportPayload } from 'ontime-types'; import { useQueryClient } from '@tanstack/react-query';
import { CustomFields, ProjectRundowns } from 'ontime-types';
import { useCallback } from 'react'; import { useCallback } from 'react';
import { importRundownWithOptions } from '../../../../../common/api/rundown'; import { CUSTOM_FIELDS, RUNDOWN } from '../../../../../common/api/constants';
import { patchData } from '../../../../../common/api/db';
export default function useSpreadsheetImport() { export default function useSpreadsheetImport() {
/** applies a spreadsheet import: override or merge into the current rundown, or create a new one */ const queryClient = useQueryClient();
const applyImport = useCallback(async (payload: RundownImportPayload) => {
// the backend broadcasts a refetch once the rundown is parsed and applied, so the caches update /** applies rundown and customFields to current project */
// through that single path rather than racing it with an optimistic write from here const importRundown = useCallback(
await importRundownWithOptions(payload); async (rundowns: ProjectRundowns, customFields: CustomFields) => {
}, []); await patchData({ rundowns, customFields });
// we are unable to optimistically set the rundown since we need
// it to be normalised
await queryClient.invalidateQueries({
queryKey: RUNDOWN,
});
await queryClient.invalidateQueries({
queryKey: CUSTOM_FIELDS,
});
},
[queryClient],
);
return { return {
applyImport, importRundown,
}; };
} }
@@ -2,23 +2,27 @@ import { useEffect, useState } from 'react';
import { generateUrl } from '../../../../common/api/session'; import { generateUrl } from '../../../../common/api/session';
import CopyTag from '../../../../common/components/copy-tag/CopyTag'; import CopyTag from '../../../../common/components/copy-tag/CopyTag';
import { serverURL } from '../../../../externals'; import useInfo from '../../../../common/hooks-query/useInfo';
import { isOntimeCloud, serverURL } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
export default function McpSection() { export default function McpSection() {
const { data: infoData } = useInfo();
const [mcpEndpointUrl, setMcpEndpointUrl] = useState(''); const [mcpEndpointUrl, setMcpEndpointUrl] = useState('');
// generate url
useEffect(() => { useEffect(() => {
// the page origin is reachable wherever the user is browsing from, const baseUrl = isOntimeCloud
// and follows the deployment (cloud, reverse proxy, port mappings) ? serverURL
// we are reusing the endpoint, so locking config and nav have no effect : infoData.networkInterfaces.length > 0
generateUrl({ baseUrl: serverURL, path: 'mcp', authenticate: true, lockConfig: false, lockNav: false }) ? `http://${infoData.networkInterfaces[0].address}:${infoData.serverPort}`
: serverURL;
generateUrl({ baseUrl, path: 'mcp', authenticate: true, lockConfig: false, lockNav: false })
.then(setMcpEndpointUrl) .then(setMcpEndpointUrl)
.catch(() => { .catch(() => {
setMcpEndpointUrl(''); setMcpEndpointUrl('');
}); });
}, []); }, [infoData]);
const mcpClientConfig = mcpEndpointUrl const mcpClientConfig = mcpEndpointUrl
? JSON.stringify({ mcpServers: { ontime: { url: mcpEndpointUrl } } }, null, 2) ? JSON.stringify({ mcpServers: { ontime: { url: mcpEndpointUrl } } }, null, 2)
@@ -30,19 +34,14 @@ export default function McpSection() {
<Panel.SubHeader>MCP Server</Panel.SubHeader> <Panel.SubHeader>MCP Server</Panel.SubHeader>
<Panel.Paragraph>Connect any MCP-compatible AI agent to Ontime using the endpoint below.</Panel.Paragraph> <Panel.Paragraph>Connect any MCP-compatible AI agent to Ontime using the endpoint below.</Panel.Paragraph>
<Panel.Divider /> <Panel.Divider />
<Panel.ListGroup> <Panel.Field title='Endpoint URL' description='Add this URL to your MCP client settings' />
<Panel.ListItem> {mcpEndpointUrl && <CopyTag copyValue={mcpEndpointUrl}>{mcpEndpointUrl}</CopyTag>}
<Panel.Field title='Endpoint URL' description='Add this URL to your MCP client settings' /> <Panel.Divider />
{mcpEndpointUrl && <CopyTag copyValue={mcpEndpointUrl}>{mcpEndpointUrl}</CopyTag>} <Panel.Field
</Panel.ListItem> title='Client configuration snippet'
<Panel.ListItem> description='Paste this into your AI agent settings under "mcpServers"'
<Panel.Field />
title='Client configuration snippet' {mcpEndpointUrl && <CopyTag copyValue={mcpClientConfig}>{mcpClientConfig}</CopyTag>}
description='Paste this into your AI agent settings under "mcpServers"'
/>
{mcpEndpointUrl && <CopyTag copyValue={mcpClientConfig}>{mcpClientConfig}</CopyTag>}
</Panel.ListItem>
</Panel.ListGroup>
</Panel.Card> </Panel.Card>
</Panel.Section> </Panel.Section>
); );
@@ -1,5 +1,5 @@
import { TranslationObject, langEn } from 'ontime-types'; import { TranslationObject, langEn } from 'ontime-types';
import { useEffect, useMemo } from 'react'; import { useMemo } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { maybeAxiosError } from '../../../../../common/api/utils'; import { maybeAxiosError } from '../../../../../common/api/utils';
@@ -10,8 +10,6 @@ import Modal from '../../../../../common/components/modal/Modal';
import { useTranslation } from '../../../../../translation/TranslationProvider'; import { useTranslation } from '../../../../../translation/TranslationProvider';
import * as Panel from '../../../panel-utils/PanelUtils'; import * as Panel from '../../../panel-utils/PanelUtils';
import style from './CustomTranslationModal.module.scss';
interface CustomTranslationModalProps { interface CustomTranslationModalProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
@@ -19,7 +17,14 @@ interface CustomTranslationModalProps {
export default function CustomTranslationModal({ isOpen, onClose }: CustomTranslationModalProps) { export default function CustomTranslationModal({ isOpen, onClose }: CustomTranslationModalProps) {
const { userTranslation, postUserTranslation } = useTranslation(); const { userTranslation, postUserTranslation } = useTranslation();
const customTranslationFormValues = useMemo(() => toFormValues(userTranslation), [userTranslation]);
const defaultValues = useMemo(() => {
const values: Record<string, string> = {};
Object.keys(langEn).forEach((key) => {
values[toFormKey(key)] = userTranslation[key as keyof TranslationObject] || '';
});
return values;
}, [userTranslation]);
const { const {
handleSubmit, handleSubmit,
@@ -28,17 +33,13 @@ export default function CustomTranslationModal({ isOpen, onClose }: CustomTransl
formState: { isSubmitting, isDirty, errors, isValid }, formState: { isSubmitting, isDirty, errors, isValid },
setError, setError,
} = useForm({ } = useForm({
defaultValues: customTranslationFormValues, defaultValues,
resetOptions: {
keepDirtyValues: true,
},
mode: 'onChange', mode: 'onChange',
}); });
// keep data up to date
useEffect(() => {
if (isOpen) {
reset(customTranslationFormValues);
}
}, [customTranslationFormValues, isOpen, reset]);
const onSubmit = async (formData: Record<string, string>) => { const onSubmit = async (formData: Record<string, string>) => {
try { try {
const translationData: Record<string, string> = {}; const translationData: Record<string, string> = {};
@@ -53,10 +54,6 @@ export default function CustomTranslationModal({ isOpen, onClose }: CustomTransl
} }
}; };
const resetToEnglish = () => {
reset(toFormValues(langEn), { keepDefaultValues: true });
};
return ( return (
<Modal <Modal
title='Edit custom translations' title='Edit custom translations'
@@ -87,12 +84,9 @@ export default function CustomTranslationModal({ isOpen, onClose }: CustomTransl
</Panel.Section> </Panel.Section>
} }
footerElements={ footerElements={
<div className={style.footer}> <div>
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>} {errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
<Panel.InlineElements align='apart'> <Panel.InlineElements align='apart'>
<Button variant='ghosted' size='large' onClick={resetToEnglish} disabled={isSubmitting}>
Reset to English
</Button>
<Panel.InlineElements> <Panel.InlineElements>
<Button size='large' onClick={onClose}> <Button size='large' onClick={onClose}>
Cancel Cancel
@@ -115,14 +109,6 @@ export default function CustomTranslationModal({ isOpen, onClose }: CustomTransl
); );
} }
function toFormValues(translation: Partial<TranslationObject>) {
const values: Record<string, string> = {};
Object.keys(langEn).forEach((key) => {
values[toFormKey(key)] = translation[key as keyof TranslationObject] || '';
});
return values;
}
function toFormKey(key: string) { function toFormKey(key: string) {
return key.replace('.', '_'); return key.replace('.', '_');
} }
@@ -73,10 +73,6 @@
margin-right: 0.25rem; margin-right: 0.25rem;
} }
.tagOvertime {
color: $playback-over;
}
.time { .time {
color: $section-white; color: $section-white;
font-size: $text-body-size; font-size: $text-body-size;
@@ -1,13 +1,12 @@
import { MaybeNumber, Playback, TimerPhase } from 'ontime-types'; import { MaybeNumber, Playback, TimerPhase } from 'ontime-types';
import { millisToString } from 'ontime-utils'; import { dayInMs, millisToString } from 'ontime-utils';
import { PropsWithChildren } from 'react'; import { PropsWithChildren } from 'react';
import AppLink from '../../../../common/components/link/app-link/AppLink'; import AppLink from '../../../../common/components/link/app-link/AppLink';
import Tooltip from '../../../../common/components/tooltip/Tooltip'; import Tooltip from '../../../../common/components/tooltip/Tooltip';
import useReport from '../../../../common/hooks-query/useReport'; import useReport from '../../../../common/hooks-query/useReport';
import { useTimerProgress } from '../../../../common/hooks/useSocket'; import { useTimer } from '../../../../common/hooks/useSocket';
import { cx } from '../../../../common/utils/styleUtils'; import { formatDuration } from '../../../../common/utils/time';
import { formatDuration, normaliseWallClock } from '../../../../common/utils/time';
import TimerDisplay from '../timer-display/TimerDisplay'; import TimerDisplay from '../timer-display/TimerDisplay';
import style from './PlaybackTimer.module.scss'; import style from './PlaybackTimer.module.scss';
@@ -25,14 +24,15 @@ function resolveAddedTimeLabel(addedTime: number) {
} }
export default function PlaybackTimer({ children }: PropsWithChildren) { export default function PlaybackTimer({ children }: PropsWithChildren) {
'use memo'; const timer = useTimer();
const timer = useTimerProgress();
const isRolling = timer.playback === Playback.Roll; const isRolling = timer.playback === Playback.Roll;
const isWaiting = timer.phase === TimerPhase.Pending; const isWaiting = timer.phase === TimerPhase.Pending;
const isOvertime = timer.phase === TimerPhase.Overtime; const isOvertime = timer.phase === TimerPhase.Overtime;
const hasAddedTime = Boolean(timer.addedTime); const hasAddedTime = Boolean(timer.addedTime);
const rollLabel = isRolling ? 'Roll mode active' : ''; const rollLabel = isRolling ? 'Roll mode active' : '';
const addedTimeLabel = resolveAddedTimeLabel(timer.addedTime); const addedTimeLabel = resolveAddedTimeLabel(timer.addedTime);
return ( return (
@@ -51,13 +51,7 @@ export default function PlaybackTimer({ children }: PropsWithChildren) {
{isWaiting ? ( {isWaiting ? (
<span className={style.rolltag}>Roll: Countdown to start</span> <span className={style.rolltag}>Roll: Countdown to start</span>
) : ( ) : (
<RunningStatus <RunningStatus startedAt={timer.startedAt} expectedFinish={timer.expectedFinish} playback={timer.playback} />
startedAt={timer.startedAt}
expectedFinish={timer.expectedFinish}
isStopped={timer.playback === Playback.Stop}
isCountToEnd={timer.isCountToEnd}
isOvertime={isOvertime}
/>
)} )}
</div> </div>
{children} {children}
@@ -68,18 +62,16 @@ export default function PlaybackTimer({ children }: PropsWithChildren) {
interface RunningStatusProps { interface RunningStatusProps {
startedAt: MaybeNumber; startedAt: MaybeNumber;
expectedFinish: MaybeNumber; expectedFinish: MaybeNumber;
isStopped: boolean; playback: Playback;
isCountToEnd: boolean;
isOvertime: boolean;
} }
function RunningStatus({ startedAt, expectedFinish, playback }: RunningStatusProps) {
function RunningStatus({ startedAt, expectedFinish, isStopped, isCountToEnd, isOvertime }: RunningStatusProps) { if (playback === Playback.Stop) {
if (isStopped) {
return <StoppedStatus />; return <StoppedStatus />;
} }
const started = millisToString(startedAt); const started = millisToString(startedAt);
const finish = millisToString(expectedFinish === null ? null : normaliseWallClock(expectedFinish)); const finishedMs = expectedFinish !== null ? expectedFinish % dayInMs : null;
const finish = millisToString(finishedMs);
return ( return (
<> <>
@@ -88,9 +80,7 @@ function RunningStatus({ startedAt, expectedFinish, isStopped, isCountToEnd, isO
<span className={style.time}>{started}</span> <span className={style.time}>{started}</span>
</span> </span>
<span className={style.finish}> <span className={style.finish}>
<span className={cx([style.tag, isOvertime && style.tagOvertime])}> <span className={style.tag}>Expect end</span>
{isCountToEnd ? 'Scheduled end' : 'Expected end'}
</span>
<span className={style.time}>{finish}</span> <span className={style.time}>{finish}</span>
</span> </span>
</> </>
@@ -180,13 +180,7 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
return ( return (
<Fragment key={entry.id}> <Fragment key={entry.id}>
<OperatorGroup <OperatorGroup key={entry.id} title={entry.title} />
key={entry.id}
title={entry.title}
colour={entry.colour}
count={entry.entries.length}
duration={entry.duration}
/>
{entry.entries.map((nestedEntryId) => { {entry.entries.map((nestedEntryId) => {
const nestedEntry = rundown.entries[nestedEntryId]; const nestedEntry = rundown.entries[nestedEntryId];
if (!isOntimeEvent(nestedEntry)) { if (!isOntimeEvent(nestedEntry)) {
@@ -223,7 +217,6 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
isLinkedToLoaded={isLinkedToLoaded} isLinkedToLoaded={isLinkedToLoaded}
isSelected={isLoaded} isSelected={isLoaded}
isPast={isPast} isPast={isPast}
groupColour={entry.colour}
selectedRef={isLoaded ? selectedRef : undefined} selectedRef={isLoaded ? selectedRef : undefined}
showStart={showStart} showStart={showStart}
subscribed={subscribedData} subscribed={subscribedData}
@@ -22,39 +22,17 @@
background-color: $gray-1250; background-color: $gray-1250;
} }
&.grouped {
position: relative;
padding-left: 0.35rem;
background:
linear-gradient(90deg, color-mix(in srgb, transparent 88%, var(--group-colour, $gray-500) 12%), transparent 8rem),
$viewer-card-bg-color;
}
&.running { &.running {
border-top: 1px solid $gray-1300; border-top: 1px solid $gray-1300;
background-color: var(--operator-running-bg-override, $active-green); background-color: var(--operator-running-bg-override, $active-green);
} }
&.grouped.running {
background:
linear-gradient(90deg, color-mix(in srgb, transparent 82%, var(--group-colour, $gray-500) 18%), transparent 8rem),
var(--operator-running-bg-override, $active-green);
}
&.past { &.past {
border-top: 1px solid transparent; border-top: 1px solid transparent;
opacity: 0.2; opacity: 0.2;
} }
} }
.groupRail {
position: absolute;
inset-block: 0;
left: 0;
width: 0.35rem;
background-color: var(--group-colour, $gray-500);
}
.binder { .binder {
grid-area: binder; grid-area: binder;
color: $section-white; color: $section-white;
@@ -96,9 +74,6 @@
.plannedStart, .plannedStart,
.timeUntil, .timeUntil,
.runningTime { .runningTime {
display: flex;
align-items: center;
gap: 0.25rem;
border-radius: $component-border-radius-md; border-radius: $component-border-radius-md;
padding: 0.25rem 0.5rem; padding: 0.25rem 0.5rem;
line-height: 1; line-height: 1;
@@ -120,21 +95,6 @@
letter-spacing: 1px; letter-spacing: 1px;
} }
.live {
color: $ui-black;
background-color: $ui-white;
}
.due {
color: $ui-black;
background-color: $orange-500;
}
.done {
color: $white-60;
background-color: $white-7;
}
.runningTime { .runningTime {
grid-area: running; grid-area: running;
font-size: 1.25rem; font-size: 1.25rem;
@@ -25,7 +25,6 @@ interface OperatorEventProps {
isLinkedToLoaded: boolean; isLinkedToLoaded: boolean;
isSelected: boolean; isSelected: boolean;
isPast: boolean; isPast: boolean;
groupColour?: string;
selectedRef?: RefObject<HTMLDivElement | null>; selectedRef?: RefObject<HTMLDivElement | null>;
showStart: boolean; showStart: boolean;
subscribed: Subscribed; subscribed: Subscribed;
@@ -47,7 +46,6 @@ function OperatorEvent({
isLinkedToLoaded, isLinkedToLoaded,
isSelected, isSelected,
isPast, isPast,
groupColour,
selectedRef, selectedRef,
showStart, showStart,
subscribed, subscribed,
@@ -70,12 +68,7 @@ function OperatorEvent({
const mouseHandlers = useLongPress(handleLongPress); const mouseHandlers = useLongPress(handleLongPress);
const cueColours = colour && getAccessibleColour(colour); const cueColours = colour && getAccessibleColour(colour);
const operatorClasses = cx([ const operatorClasses = cx([style.event, isSelected && style.running, isPast && style.past]);
style.event,
groupColour && style.grouped,
isSelected && style.running,
isPast && style.past,
]);
const hasFields = subscribed.some((field) => field.value); const hasFields = subscribed.some((field) => field.value);
const columnCount = subscribed.length ? Math.min(subscribed.length, 4) : 0; const columnCount = subscribed.length ? Math.min(subscribed.length, 4) : 0;
@@ -92,10 +85,8 @@ function OperatorEvent({
data-testid={cue} data-testid={cue}
ref={selectedRef} ref={selectedRef}
onContextMenu={handleLongPress} onContextMenu={handleLongPress}
style={groupColour ? ({ '--group-colour': groupColour } as CSSProperties) : undefined}
{...mouseHandlers} {...mouseHandlers}
> >
{groupColour && <div className={style.groupRail} />}
<div className={style.binder} style={{ ...cueColours }}> <div className={style.binder} style={{ ...cueColours }}>
<span className={style.cue}>{cue}</span> <span className={style.cue}>{cue}</span>
</div> </div>
@@ -162,11 +153,11 @@ function OperatorEventSchedule({
isLinkedToLoaded, isLinkedToLoaded,
}: OperatorEventScheduleProps) { }: OperatorEventScheduleProps) {
if (isPast) { if (isPast) {
return <span className={cx([style.timeUntil, style.done])}>DONE</span>; return <span className={style.timeUntil}>DONE</span>;
} }
if (isSelected) { if (isSelected) {
return <span className={cx([style.timeUntil, style.live])}>LIVE</span>; return <span className={style.timeUntil}>LIVE</span>;
} }
return ( return (
@@ -195,7 +186,7 @@ function TimeUntil({ timeStart, delay, dayOffset, totalGap, isLinkedToLoaded }:
const timeUntilString = isDue ? 'DUE' : `${formatDuration(Math.abs(timeUntil), timeUntil > 2 * MILLIS_PER_MINUTE)}`; const timeUntilString = isDue ? 'DUE' : `${formatDuration(Math.abs(timeUntil), timeUntil > 2 * MILLIS_PER_MINUTE)}`;
return ( return (
<span className={cx([style.timeUntil, isDue && style.due])} data-testid='time-until'> <span className={style.timeUntil} data-testid='time-until'>
{timeUntilString} {timeUntilString}
</span> </span>
); );
@@ -1,33 +1,13 @@
.group { .group {
width: 100%; width: 100%;
min-height: 2.5rem; padding: 0.25rem 0.5rem;
padding: 0.4rem 0.75rem;
border-left: 0.35rem solid var(--group-colour, $gray-500);
background-color: $gray-1350; background-color: $gray-1350;
background: color-mix(in srgb, transparent 88%, var(--group-colour, $gray-500) 12%);
font-size: 1.25rem; font-size: 1.25rem;
font-weight: 600;
display: flex;
align-items: center;
gap: 1rem;
letter-spacing: 0;
} }
.title { // tablet
flex: 1 1 auto; @media (min-width: $min-tablet) {
min-width: 0; .group {
overflow: hidden; padding: 0.25rem 1rem;
text-overflow: ellipsis; }
white-space: nowrap;
}
.meta {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: 0.5rem;
color: color-mix(in srgb, currentColor 60%, transparent);
font-size: 0.875rem;
font-weight: 500;
} }
@@ -1,29 +1,12 @@
import { CSSProperties, memo } from 'react'; import { memo } from 'react';
import { getAccessibleColour } from '../../../common/utils/styleUtils';
import { formatDuration } from '../../../common/utils/time';
import style from './OperatorGroup.module.scss'; import style from './OperatorGroup.module.scss';
interface OperatorGroup { interface OperatorGroup {
title: string; title: string;
colour: string;
count: number;
duration: number;
} }
export default memo(OperatorGroup); export default memo(OperatorGroup);
function OperatorGroup({ title, colour, count, duration }: OperatorGroup) { function OperatorGroup({ title }: OperatorGroup) {
const groupColour = colour || '#929292'; return <div className={style.group}>{title}</div>;
const groupColours = getAccessibleColour(groupColour);
return (
<div className={style.group} style={{ ...groupColours, '--group-colour': groupColour } as CSSProperties}>
<span className={style.title}>{title}</span>
<span className={style.meta}>
<span>{`${count} ${count === 1 ? 'event' : 'events'}`}</span>
<span>{formatDuration(duration)}</span>
</span>
</div>
);
} }
+1 -20
View File
@@ -105,29 +105,12 @@ export default function Rundown({ order, flatOrder, entries, id, rundownMetadata
handleCollapseGroup, handleCollapseGroup,
}); });
// Jump to the current element on demand: the running event in Run mode, the edit cursor otherwise.
// Scrolls only (no selection change) so jumping to the running event does not hijack the edit cursor.
const jumpToCurrent = useCallback(() => {
const targetId = editorMode === AppMode.Run ? featureData?.selectedEventId : cursor;
if (!targetId) {
return;
}
// Open parent group if the target is inside a collapsed group
const entry = entries[targetId];
if (entry && 'parent' in entry) {
expandGroup(entry.parent);
}
scrollToEntry(targetId);
}, [editorMode, featureData?.selectedEventId, cursor, entries, expandGroup, scrollToEntry]);
// Keyboard shortcuts // Keyboard shortcuts
useRundownKeyboard({ useRundownKeyboard({
cursor, cursor,
commands, commands,
clearSelectedEvents, clearSelectedEvents,
setEntryCopyId, setEntryCopyId,
jumpToCurrent,
}); });
// DND handlers // DND handlers
@@ -339,9 +322,7 @@ export default function Rundown({ order, flatOrder, entries, id, rundownMetadata
increaseViewportBy={{ top: 200, bottom: 400 }} increaseViewportBy={{ top: 200, bottom: 400 }}
style={{ height: '100%' }} style={{ height: '100%' }}
components={{ components={{
Header: isEditMode Header: isEditMode ? () => <QuickAddButtons previousEventId={null} parentGroup={null} /> : undefined,
? () => <QuickAddButtons previousEventId={null} nextEventId={order[0]} parentGroup={null} />
: undefined,
Footer: () => ( Footer: () => (
<> <>
{isEditMode && ( {isEditMode && (
@@ -22,13 +22,10 @@ export default function CuesheetEntryEditor({ entryId, rundown }: CuesheetEntryE
return event ?? null; return event ?? null;
}, [entryId, rundown.entries, rundown.order.length]); }, [entryId, rundown.entries, rundown.order.length]);
// the cuesheet always edits a single entry if (isOntimeEvent(entry)) {
const events = useMemo(() => (isOntimeEvent(entry) ? [entry] : null), [entry]);
if (events) {
return ( return (
<div className={style.entryEditor} data-testid='editor-container'> <div className={style.entryEditor} data-testid='editor-container'>
<EventEditor events={events} /> <EventEditor event={entry} />
</div> </div>
); );
} }
@@ -75,26 +75,6 @@
gap: 1rem; gap: 1rem;
} }
// a label with an action aligned to the end of the row
.labelRow {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.5rem;
}
.clearAction {
font-size: $aux-text-size;
color: $blue-400;
margin-bottom: $element-inner-spacing; // match the label
text-decoration: underline;
text-underline-offset: 2px;
&:hover {
color: $blue-500;
}
}
.splitTwo { .splitTwo {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
@@ -1,19 +1,14 @@
import { OntimeEvent } from 'ontime-types'; import { OntimeEvent } from 'ontime-types';
import { useCallback, useMemo } from 'react'; import { useCallback } from 'react';
import * as Editor from '../../../common/components/editor-utils/EditorUtils'; import * as Editor from '../../../common/components/editor-utils/EditorUtils';
import Info from '../../../common/components/info/Info';
import AppLink from '../../../common/components/link/app-link/AppLink'; import AppLink from '../../../common/components/link/app-link/AppLink';
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext'; import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
import useCustomFields from '../../../common/hooks-query/useCustomFields'; import useCustomFields from '../../../common/hooks-query/useCustomFields';
import EventEditorBatchSchedule from './composite/EventEditorBatchSchedule';
import EntryEditorCustomFields from './composite/EventEditorCustomFields'; import EntryEditorCustomFields from './composite/EventEditorCustomFields';
import EventEditorSchedule from './composite/EventEditorSchedule';
import EventEditorTimes from './composite/EventEditorTimes'; import EventEditorTimes from './composite/EventEditorTimes';
import EventEditorTitles from './composite/EventEditorTitles'; import EventEditorTitles from './composite/EventEditorTitles';
import EventEditorTriggers from './composite/EventEditorTriggers'; import EventEditorTriggers from './composite/EventEditorTriggers';
import { mixedPlaceholder } from './entryEditor.utils';
import { mergeEvents, resolveConflict } from './mergeEvents';
import style from './EntryEditor.module.scss'; import style from './EntryEditor.module.scss';
@@ -21,115 +16,66 @@ import style from './EntryEditor.module.scss';
export type EventEditorUpdateFields = 'cue' | 'title' | 'note' | 'colour' | string; export type EventEditorUpdateFields = 'cue' | 'title' | 'note' | 'colour' | string;
interface EventEditorProps { interface EventEditorProps {
/** events being edited, editing several events at once shows a merged view of their values */ event: OntimeEvent;
events: OntimeEvent[];
} }
export default function EventEditor({ events }: EventEditorProps) { export default function EventEditor({ event }: EventEditorProps) {
const { data: customFields } = useCustomFields(); const { data: customFields } = useCustomFields();
const { updateEntry, batchUpdateEvents } = useEntryActionsContext(); const { updateEntry } = useEntryActionsContext();
const isEditor = window.location.pathname.includes('editor'); const isEditor = window.location.pathname.includes('editor');
const ids = useMemo(() => events.map((event) => event.id), [events]);
const merged = useMemo(() => mergeEvents(events), [events]);
// when editing a single event, we can show the values which are unique to it
const singleEvent = events.length === 1 ? events[0] : null;
/**
* Applies a patch to every event being edited
*/
const submit = useCallback(
(patch: Partial<OntimeEvent>) => {
if (ids.length === 1) {
updateEntry({ id: ids[0], ...patch });
return;
}
batchUpdateEvents(patch, ids);
},
[batchUpdateEvents, ids, updateEntry],
);
const handleSubmit = useCallback( const handleSubmit = useCallback(
(field: EventEditorUpdateFields, value: string) => { (field: EventEditorUpdateFields, value: string) => {
if (field.startsWith('custom-')) { if (field.startsWith('custom-')) {
const fieldLabel = field.split('custom-')[1]; const fieldLabel = field.split('custom-')[1];
submit({ custom: { [fieldLabel]: value } }); updateEntry({ id: event.id, custom: { [fieldLabel]: value } });
} else { } else {
submit({ [field]: value }); updateEntry({ id: event.id, [field]: value });
} }
}, },
[submit], [event.id, updateEntry],
); );
// inputs keep local state, we remount them when the edited entries change
const editorKey = ids.join();
return ( return (
<div className={style.content}> <div className={style.content}>
{singleEvent ? (
<EventEditorSchedule
key={`${editorKey}-schedule`}
eventId={singleEvent.id}
timeStart={singleEvent.timeStart}
timeEnd={singleEvent.timeEnd}
duration={singleEvent.duration}
timeStrategy={singleEvent.timeStrategy}
linkStart={singleEvent.linkStart}
delay={singleEvent.delay}
/>
) : (
<EventEditorBatchSchedule
key={`${editorKey}-schedule`}
duration={resolveConflict(merged.duration)}
submit={submit}
/>
)}
<EventEditorTimes <EventEditorTimes
key={`${editorKey}-times`} key={`${event.id}-times`}
countToEnd={resolveConflict(merged.countToEnd)} eventId={event.id}
endAction={resolveConflict(merged.endAction)} timeStart={event.timeStart}
timerType={resolveConflict(merged.timerType)} timeEnd={event.timeEnd}
timeWarning={resolveConflict(merged.timeWarning)} duration={event.duration}
timeDanger={resolveConflict(merged.timeDanger)} timeStrategy={event.timeStrategy}
submit={submit} linkStart={event.linkStart}
countToEnd={event.countToEnd}
delay={event.delay}
endAction={event.endAction}
timerType={event.timerType}
timeWarning={event.timeWarning}
timeDanger={event.timeDanger}
/> />
<EventEditorTitles <EventEditorTitles
key={`${editorKey}-titles`} key={`${event.id}-titles`}
eventId={singleEvent?.id ?? null} eventId={event.id}
eventCount={events.length} cue={event.cue}
cue={singleEvent?.cue ?? ''} flag={event.flag}
flag={resolveConflict(merged.flag)} title={event.title}
title={resolveConflict(merged.title)} note={event.note}
note={resolveConflict(merged.note)} colour={event.colour}
colour={resolveConflict(merged.colour)}
submit={submit}
/> />
<div className={style.column}> <div className={style.column}>
<Editor.Title> <Editor.Title>
Custom Fields Custom Fields
{isEditor && <AppLink search='settings=manage__custom'>Manage Custom Fields</AppLink>} {isEditor && <AppLink search='settings=manage__custom'>Manage Custom Fields</AppLink>}
</Editor.Title> </Editor.Title>
<EntryEditorCustomFields <EntryEditorCustomFields fields={customFields} handleSubmit={handleSubmit} entry={event} />
key={`${editorKey}-custom`}
fields={customFields}
custom={merged.custom}
idKey={editorKey}
mixedPlaceholder={mixedPlaceholder}
handleSubmit={handleSubmit}
/>
</div> </div>
<div className={style.column}> <div className={style.column}>
<Editor.Title> <Editor.Title>
Automations Automations
{isEditor && singleEvent && <AppLink search='settings=automation'>Manage Automations</AppLink>} {isEditor && <AppLink search='settings=automation'>Manage Automations</AppLink>}
</Editor.Title> </Editor.Title>
{singleEvent ? ( <EventEditorTriggers triggers={event.triggers} eventId={event.id} />
<EventEditorTriggers triggers={singleEvent.triggers} eventId={singleEvent.id} />
) : (
<Info>Automations are not available when editing multiple events</Info>
)}
</div> </div>
</div> </div>
); );
@@ -3,94 +3,58 @@
height: 100%; height: 100%;
max-height: 100%; max-height: 100%;
overflow-y: auto; overflow-y: auto;
padding: 1rem 1.5rem; padding: 0.5rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow-x: auto;
} }
.shortcutSection { .shortcutSection {
flex: 1; flex: 1;
width: min(100%, 48rem); margin-top: 15vh;
margin-top: clamp(1.5rem, 8vh, 5rem);
margin-inline: auto; margin-inline: auto;
gap: 1rem;
} }
.shortcuts { .shortcuts {
display: grid; font-size: calc(1rem - 3px);
gap: 0.875rem; border-collapse: separate;
margin-top: 0.875rem; border-spacing: 4rem 0;
}
.shortcutGroup { tr {
h3 { white-space: nowrap;
margin: 0 0 0.375rem; td:nth-child(odd) {
color: $ui-white; text-align: left;
font-size: calc(1rem - 3px); }
font-weight: 600; td:nth-child(even) {
text-transform: uppercase; text-align: right;
}
} }
} }
.shortcutList { .spacer {
display: grid; height: 1rem;
gap: 0.25rem;
}
.shortcutRow {
min-height: 1.625rem;
display: grid;
grid-template-columns: minmax(10rem, 1fr) minmax(0, auto);
align-items: center;
gap: 0.75rem;
font-size: calc(1rem - 3px);
}
.shortcutLabel {
min-width: 0;
line-height: 1.2;
}
.shortcutKeys {
display: inline-flex;
align-items: center;
flex-wrap: wrap;
justify-content: flex-end;
gap: 0.25rem 0.5rem;
min-width: 0;
}
.keyCombo {
display: inline-flex;
align-items: center;
flex-wrap: nowrap;
gap: 0.25rem 0;
}
.separator {
color: $gray-500;
font-size: calc(1rem - 5px);
} }
.prompt { .prompt {
margin-left: 4rem;
}
.divider {
display: inline-block;
text-align: center;
width: 1em;
}
.kbd {
font-family: monospace;
white-space: nowrap;
font-size: calc(1rem - 2px);
padding: 0.125rem 0.5rem;
background-color: $gray-1200;
color: $ui-white; color: $ui-white;
} border-radius: 2px;
font-weight: 400;
@media (max-width: 680px) { box-shadow: 0px 0px 3px 0px rgba(0, 0, 0, 0.4);
.entryEditor {
padding: 0.75rem 1rem;
}
.shortcutSection {
margin-top: 1rem;
}
.shortcutRow {
grid-template-columns: 1fr;
gap: 0.25rem;
}
.shortcutKeys {
justify-content: flex-start;
}
} }
@@ -1,7 +1,6 @@
import { PropsWithChildren, memo } from 'react'; import { PropsWithChildren, memo } from 'react';
import * as Editor from '../../../common/components/editor-utils/EditorUtils'; import * as Editor from '../../../common/components/editor-utils/EditorUtils';
import Kbd from '../../../common/components/kbd/Kbd';
import { deviceAlt, deviceMod } from '../../../common/utils/deviceUtils'; import { deviceAlt, deviceMod } from '../../../common/utils/deviceUtils';
import style from './EventEditorEmpty.module.scss'; import style from './EventEditorEmpty.module.scss';
@@ -13,127 +12,216 @@ function EventEditorEmpty() {
<div className={style.entryEditor} data-testid='editor-container'> <div className={style.entryEditor} data-testid='editor-container'>
<div className={style.shortcutSection}> <div className={style.shortcutSection}>
<Editor.Title className={style.prompt}>Rundown shortcuts</Editor.Title> <Editor.Title className={style.prompt}>Rundown shortcuts</Editor.Title>
<div className={style.shortcuts}> <table className={style.shortcuts}>
<ShortcutGroup title='Search'> <tbody>
<Shortcut label='Find in rundown'> <tr>
<Combo keys={[deviceMod, 'F']} /> <td>Find in rundown</td>
</Shortcut> <td>
<Shortcut label='Open settings'> <Kbd>{deviceMod}</Kbd>
<Combo keys={[deviceMod, ',']} /> <AuxKey>+</AuxKey>
</Shortcut> <Kbd>F</Kbd>
</ShortcutGroup> </td>
</tr>
<ShortcutGroup title='Navigation'> <tr>
<Shortcut label='Select entry'> <td>Open Settings</td>
<Combo keys={[deviceAlt, '↑']} /> <td>
<Separator /> <Kbd>{deviceMod}</Kbd>
<Combo keys={[deviceAlt, '↓']} /> <AuxKey>+</AuxKey>
</Shortcut> <Kbd>,</Kbd>
<Shortcut label='Select group'> </td>
<Combo keys={[deviceAlt, 'Shift', '↑']} /> </tr>
<Separator /> <tr className={style.spacer} />
<Combo keys={[deviceAlt, 'Shift', '↓']} /> <tr>
</Shortcut> <td>Select entry</td>
<Shortcut label='Jump to top / bottom'> <td>
<Combo keys={['Home']} /> <Kbd>{deviceAlt}</Kbd>
<Separator /> <AuxKey>+</AuxKey>
<Combo keys={['End']} /> <Kbd></Kbd>
</Shortcut> <AuxKey>/</AuxKey>
<Shortcut label='Page up / down'> <Kbd></Kbd>
<Combo keys={['PgUp']} /> </td>
<Separator /> </tr>
<Combo keys={['PgDn']} /> <tr>
</Shortcut> <td>Select group</td>
<Shortcut label='Jump to current entry'> <td>
<Combo keys={[deviceAlt, 'L']} /> <Kbd>{deviceAlt}</Kbd>
</Shortcut> <AuxKey>+</AuxKey>
<Shortcut label='Deselect entry'> <Kbd>Shift</Kbd>
<Combo keys={['Esc']} /> <AuxKey>+</AuxKey>
</Shortcut> <Kbd></Kbd>
</ShortcutGroup> <AuxKey>/</AuxKey>
<Kbd></Kbd>
<ShortcutGroup title='Editing'> </td>
<Shortcut label='Reorder selected entry'> </tr>
<Combo keys={[deviceAlt, deviceMod, '↑']} /> <tr>
<Separator /> <td>Jump to top / bottom</td>
<Combo keys={[deviceAlt, deviceMod, '↓']} /> <td>
</Shortcut> <Kbd>Home</Kbd>
<Shortcut label='Copy selected entry'> <AuxKey>/</AuxKey>
<Combo keys={[deviceMod, 'C']} /> <Kbd>End</Kbd>
</Shortcut> </td>
<Shortcut label='Cut selected entry'> </tr>
<Combo keys={[deviceMod, 'X']} /> <tr>
</Shortcut> <td>Page up / down</td>
<Shortcut label='Paste below'> <td>
<Combo keys={[deviceMod, 'V']} /> <Kbd>PgUp</Kbd>
</Shortcut> <AuxKey>/</AuxKey>
<Shortcut label='Paste above'> <Kbd>PgDn</Kbd>
<Combo keys={[deviceMod, 'Shift', 'V']} /> </td>
</Shortcut> </tr>
<Shortcut label='Clone selected entry'> <tr>
<Combo keys={[deviceMod, 'D']} /> <td>Deselect entry</td>
</Shortcut> <td>
<Shortcut label='Delete selected entry'> <Kbd>Esc</Kbd>
<Combo keys={[deviceAlt, 'Backspace']} /> </td>
</Shortcut> </tr>
</ShortcutGroup> <tr className={style.spacer} />
<tr>
<ShortcutGroup title='Insert'> <td>Reorder selected entry</td>
<Shortcut label='Add event below / above'> <td>
<Combo keys={[deviceAlt, 'E']} /> <Kbd>{deviceAlt}</Kbd>
<Separator /> <AuxKey>+</AuxKey>
<Combo keys={[deviceAlt, 'Shift', 'E']} /> <Kbd>{deviceMod}</Kbd>
</Shortcut> <AuxKey>+</AuxKey>
<Shortcut label='Add group below / above'> <Kbd></Kbd>
<Combo keys={[deviceAlt, 'G']} /> <AuxKey>/</AuxKey>
<Separator /> <Kbd></Kbd>
<Combo keys={[deviceAlt, 'Shift', 'G']} /> </td>
</Shortcut> </tr>
<Shortcut label='Add milestone below / above'> <tr>
<Combo keys={[deviceAlt, 'M']} /> <td>Copy selected entry</td>
<Separator /> <td>
<Combo keys={[deviceAlt, 'Shift', 'M']} /> <Kbd>{deviceMod}</Kbd>
</Shortcut> <AuxKey>+</AuxKey>
<Shortcut label='Add delay below / above'> <Kbd>C</Kbd>
<Combo keys={[deviceAlt, 'D']} /> </td>
<Separator /> </tr>
<Combo keys={[deviceAlt, 'Shift', 'D']} /> <tr>
</Shortcut> <td>Cut selected entry</td>
</ShortcutGroup> <td>
</div> <Kbd>{deviceMod}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>X</Kbd>
</td>
</tr>
<tr>
<td>Paste above</td>
<td>
<Kbd>{deviceMod}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>Shift</Kbd>
<AuxKey>+</AuxKey>
<Kbd>V</Kbd>
</td>
</tr>
<tr>
<td>Paste below</td>
<td>
<Kbd>{deviceMod}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>V</Kbd>
</td>
</tr>
<tr>
<td>Clone selected entry</td>
<td>
<Kbd>{deviceMod}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>D</Kbd>
</td>
</tr>
<tr>
<td>Delete selected entry</td>
<td>
<Kbd>{deviceAlt}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>Backspace</Kbd>
</td>
</tr>
<tr className={style.spacer} />
<tr>
<td>Add event below</td>
<td>
<Kbd>{deviceAlt}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>E</Kbd>
</td>
</tr>
<tr>
<td>Add event above</td>
<td>
<Kbd>{deviceAlt}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>Shift</Kbd>
<AuxKey>+</AuxKey>
<Kbd>E</Kbd>
</td>
</tr>
<tr>
<td>Add group below</td>
<td>
<Kbd>{deviceAlt}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>G</Kbd>
</td>
</tr>
<tr>
<td>Add group above</td>
<td>
<Kbd>{deviceAlt}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>Shift</Kbd>
<AuxKey>+</AuxKey>
<Kbd>G</Kbd>
</td>
</tr>
<tr>
<td>Add milestone below</td>
<td>
<Kbd>{deviceAlt}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>M</Kbd>
</td>
</tr>
<tr>
<td>Add milestone above</td>
<td>
<Kbd>{deviceAlt}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>Shift</Kbd>
<AuxKey>+</AuxKey>
<Kbd>M</Kbd>
</td>
</tr>
<tr>
<td>Add delay below</td>
<td>
<Kbd>{deviceAlt}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>D</Kbd>
</td>
</tr>
<tr>
<td>Add delay above</td>
<td>
<Kbd>{deviceAlt}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>Shift</Kbd>
<AuxKey>+</AuxKey>
<Kbd>D</Kbd>
</td>
</tr>
</tbody>
</table>
</div> </div>
</div> </div>
); );
} }
function ShortcutGroup({ title, children }: PropsWithChildren<{ title: string }>) { function AuxKey({ children }: PropsWithChildren) {
return ( return <span className={style.divider}>{children}</span>;
<section className={style.shortcutGroup}>
<h3>{title}</h3>
<div className={style.shortcutList}>{children}</div>
</section>
);
} }
function Shortcut({ label, children }: PropsWithChildren<{ label: string }>) { function Kbd({ children }: PropsWithChildren) {
return ( return <span className={style.kbd}>{children}</span>;
<div className={style.shortcutRow}>
<span className={style.shortcutLabel}>{label}</span>
<span className={style.shortcutKeys}>{children}</span>
</div>
);
}
function Combo({ keys }: { keys: string[] }) {
return (
<span className={style.keyCombo}>
{keys.map((key) => (
<Kbd key={key}>{key}</Kbd>
))}
</span>
);
}
function Separator() {
return <span className={style.separator}>/</span>;
} }
@@ -112,12 +112,7 @@ export default function GroupEditor({ group }: GroupEditorProps) {
Custom Fields Custom Fields
{isEditor && <AppLink search='settings=manage__custom'>Manage Custom Fields</AppLink>} {isEditor && <AppLink search='settings=manage__custom'>Manage Custom Fields</AppLink>}
</Editor.Title> </Editor.Title>
<EntryEditorCustomFields <EntryEditorCustomFields fields={customFields} handleSubmit={handleSubmit} entry={group} />
fields={customFields}
handleSubmit={handleSubmit}
custom={group.custom}
idKey={group.id}
/>
</div> </div>
</div> </div>
); );
@@ -69,12 +69,7 @@ export default function MilestoneEditor({ milestone }: MilestoneEditorProps) {
Custom Fields Custom Fields
{isEditor && <AppLink search='settings=manage__custom'>Manage Custom Fields</AppLink>} {isEditor && <AppLink search='settings=manage__custom'>Manage Custom Fields</AppLink>}
</Editor.Title> </Editor.Title>
<EntryEditorCustomFields <EntryEditorCustomFields fields={customFields} handleSubmit={handleSubmit} entry={milestone} />
fields={customFields}
handleSubmit={handleSubmit}
custom={milestone.custom}
idKey={milestone.id}
/>
</div> </div>
</div> </div>
); );
@@ -1,4 +1,4 @@
import { OntimeEntry, OntimeEvent, isOntimeEvent, isOntimeGroup, isOntimeMilestone } from 'ontime-types'; import { OntimeEntry, isOntimeEvent, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
import { useMemo } from 'react'; import { useMemo } from 'react';
import useRundown from '../../../common/hooks-query/useRundown'; import useRundown from '../../../common/hooks-query/useRundown';
@@ -15,25 +15,6 @@ export default function RundownEntryEditor() {
const selectedEvents = useEventSelection((state) => state.selectedEvents); const selectedEvents = useEventSelection((state) => state.selectedEvents);
const { data } = useRundown(); const { data } = useRundown();
/**
* Events in the current selection
* Only events can be multi selected, groups and milestones are always selected on their own
*/
const events = useMemo<OntimeEvent[]>(() => {
if (data.order.length === 0) {
return [];
}
const selection: OntimeEvent[] = [];
selectedEvents.forEach((entryId) => {
const entry = data.entries[entryId];
if (isOntimeEvent(entry)) {
selection.push(entry);
}
});
return selection;
}, [data.order.length, data.entries, selectedEvents]);
const entry = useMemo<OntimeEntry | null>(() => { const entry = useMemo<OntimeEntry | null>(() => {
if (data.order.length === 0) { if (data.order.length === 0) {
return null; return null;
@@ -48,20 +29,19 @@ export default function RundownEntryEditor() {
return event ?? null; return event ?? null;
}, [data.order.length, data.entries, selectedEvents]); }, [data.order.length, data.entries, selectedEvents]);
if (events.length > 0) {
const singleEvent = events.length === 1 ? events[0] : null;
return (
<div className={style.rundownEditor} data-testid='editor-container'>
<EventEditor events={events} />
{singleEvent && <EventEditorFooter id={singleEvent.id} cue={singleEvent.cue} />}
</div>
);
}
if (!entry) { if (!entry) {
return <EventEditorEmpty />; return <EventEditorEmpty />;
} }
if (isOntimeEvent(entry)) {
return (
<div className={style.rundownEditor} data-testid='editor-container'>
<EventEditor event={entry} />
<EventEditorFooter id={entry.id} cue={entry.cue} />
</div>
);
}
if (isOntimeMilestone(entry)) { if (isOntimeMilestone(entry)) {
return ( return (
<div className={style.rundownEditor} data-testid='editor-container'> <div className={style.rundownEditor} data-testid='editor-container'>
@@ -1,122 +0,0 @@
import { EndAction, OntimeEvent, SupportedEntry, TimerType } from 'ontime-types';
import { conflict, isConflict, mergeEvents, resolveConflict } from '../mergeEvents';
function makeEvent(patch: Partial<OntimeEvent>): OntimeEvent {
return {
type: SupportedEntry.Event,
id: 'event',
title: 'title',
note: 'note',
colour: '#FFCC78',
flag: false,
duration: 600000,
endAction: EndAction.None,
countToEnd: false,
timerType: TimerType.CountDown,
timeWarning: 120000,
timeDanger: 60000,
linkStart: true,
custom: {},
...patch,
} as OntimeEvent;
}
describe('mergeEvents()', () => {
it('returns the values of a single event', () => {
const event = makeEvent({ id: '1', title: 'only event' });
expect(mergeEvents([event])).toStrictEqual({
title: 'only event',
note: 'note',
colour: '#FFCC78',
flag: false,
duration: 600000,
endAction: EndAction.None,
countToEnd: false,
timerType: TimerType.CountDown,
timeWarning: 120000,
timeDanger: 60000,
linkStart: true,
custom: {},
});
});
it('keeps values which are shared by all events', () => {
const merged = mergeEvents([makeEvent({ id: '1' }), makeEvent({ id: '2' }), makeEvent({ id: '3' })]);
expect(merged.title).toBe('title');
expect(merged.colour).toBe('#FFCC78');
expect(merged.timerType).toBe(TimerType.CountDown);
expect(merged.duration).toBe(600000);
});
it('marks only the fields which differ as conflicting', () => {
const merged = mergeEvents([makeEvent({ id: '1', title: 'first' }), makeEvent({ id: '2', title: 'second' })]);
expect(merged.title).toBe(conflict);
expect(merged.note).toBe('note');
expect(merged.colour).toBe('#FFCC78');
expect(merged.flag).toBe(false);
});
it('handles boolean and numeric fields', () => {
const merged = mergeEvents([
makeEvent({ id: '1', flag: true, timeWarning: 1000, duration: 1000 }),
makeEvent({ id: '2', flag: false, timeWarning: 1000, duration: 2000 }),
]);
expect(merged.flag).toBe(conflict);
expect(merged.timeWarning).toBe(1000);
expect(merged.duration).toBe(conflict);
});
describe('custom fields', () => {
it('merges values under the same key', () => {
const merged = mergeEvents([
makeEvent({ id: '1', custom: { lx: 'same', sound: 'a' } }),
makeEvent({ id: '2', custom: { lx: 'same', sound: 'b' } }),
]);
expect(merged.custom).toStrictEqual({ lx: 'same', sound: conflict });
});
it('treats a missing key as empty', () => {
const merged = mergeEvents([makeEvent({ id: '1', custom: { lx: 'value' } }), makeEvent({ id: '2', custom: {} })]);
expect(merged.custom).toStrictEqual({ lx: conflict });
});
it('collects keys from all events', () => {
const merged = mergeEvents([
makeEvent({ id: '1', custom: { lx: '' } }),
makeEvent({ id: '2', custom: { sound: 'value' } }),
]);
// lx is empty in both events, sound is only filled in one of them
expect(merged.custom).toStrictEqual({ lx: '', sound: conflict });
});
it('does not report a key which is absent from every event', () => {
const merged = mergeEvents([makeEvent({ id: '1', custom: {} }), makeEvent({ id: '2', custom: {} })]);
expect(merged.custom).toStrictEqual({});
// an absent key is distinguishable from a conflicting one
expect(merged.custom.lx).toBeUndefined();
expect(isConflict(merged.custom.lx)).toBe(false);
});
});
});
describe('resolveConflict()', () => {
it('passes known values through', () => {
expect(resolveConflict('value')).toBe('value');
expect(resolveConflict(0)).toBe(0);
expect(resolveConflict(false)).toBe(false);
expect(resolveConflict('')).toBe('');
});
it('resolves a conflict to undefined', () => {
expect(resolveConflict(conflict)).toBeUndefined();
});
});
@@ -1,48 +0,0 @@
import { OntimeEvent } from 'ontime-types';
import { MILLIS_PER_SECOND, dayInMs, parseUserTime } from 'ontime-utils';
import { memo } from 'react';
import * as Editor from '../../../../common/components/editor-utils/EditorUtils';
import TimeInput from '../../../../common/components/input/time-input/TimeInput';
import { mixedPlaceholder } from '../entryEditor.utils';
import style from '../EntryEditor.module.scss';
interface EventEditorBatchScheduleProps {
/** undefined when the events do not share a duration */
duration: number | undefined;
submit: (patch: Partial<OntimeEvent>) => void;
}
/**
* Schedule fields which can be applied to several events at once
* Start and end times are absolute points in time: giving several events the same
* value would collapse their durations, so only the duration is offered here
* The server infers the duration lock and recalculates the rundown once for the whole batch
*/
export default memo(EventEditorBatchSchedule);
function EventEditorBatchSchedule({ duration, submit }: EventEditorBatchScheduleProps) {
const handleSubmit = (_field: 'duration', value: string) => {
// durations cannot exceed a day
submit({ duration: Math.min(parseUserTime(value), dayInMs - MILLIS_PER_SECOND) });
};
return (
<div className={style.column}>
<Editor.Title>Event schedule</Editor.Title>
<div className={style.splitTwo}>
<div>
<Editor.Label htmlFor='duration'>Duration</Editor.Label>
<TimeInput
id='duration'
name='duration'
submitHandler={handleSubmit}
time={duration}
placeholder={duration === undefined ? mixedPlaceholder : 'Duration'}
align='left'
/>
</div>
</div>
</div>
);
}
@@ -1,9 +1,8 @@
import { CustomFields, EntryCustomFields } from 'ontime-types'; import { CustomFields, OntimeEvent, OntimeGroup, OntimeMilestone } from 'ontime-types';
import { CSSProperties, Fragment } from 'react'; import { CSSProperties, Fragment } from 'react';
import { getAccessibleColour } from '../../../../common/utils/styleUtils'; import { getAccessibleColour } from '../../../../common/utils/styleUtils';
import { EventEditorUpdateFields } from '../EventEditor'; import { EventEditorUpdateFields } from '../EventEditor';
import { MergedCustomFields, resolveConflict } from '../mergeEvents';
import EventEditorImage from './EventEditorImage'; import EventEditorImage from './EventEditorImage';
import EventTextArea from './EventTextArea'; import EventTextArea from './EventTextArea';
import EntryEditorTextInput from './EventTextInput'; import EntryEditorTextInput from './EventTextInput';
@@ -12,29 +11,21 @@ import style from '../EntryEditor.module.scss';
interface EntryEditorCustomFieldsProps { interface EntryEditorCustomFieldsProps {
fields: CustomFields; fields: CustomFields;
/** values of the entries being edited, undefined when the entries do not agree */ entry: OntimeEvent | OntimeGroup | OntimeMilestone;
custom: EntryCustomFields | MergedCustomFields;
/** used to generate stable keys for the fields */
idKey: string;
mixedPlaceholder?: string;
handleSubmit: (field: EventEditorUpdateFields, value: string) => void; handleSubmit: (field: EventEditorUpdateFields, value: string) => void;
} }
export default function EntryEditorCustomFields({ export default function EntryEditorCustomFields({
fields: customFields, fields: customFields,
custom,
idKey,
mixedPlaceholder,
handleSubmit, handleSubmit,
entry,
}: EntryEditorCustomFieldsProps) { }: EntryEditorCustomFieldsProps) {
return ( return (
<Fragment> <Fragment>
{Object.keys(customFields).map((fieldKey) => { {Object.keys(customFields).map((fieldKey) => {
const key = `${idKey}-${fieldKey}`; const key = `${entry.id}-${fieldKey}`;
const fieldName = `custom-${fieldKey}`; const fieldName = `custom-${fieldKey}`;
// a key which is absent from the merged view is not present in any of the entries const initialValue = entry.custom[fieldKey] ?? '';
const initialValue = resolveConflict(custom[fieldKey] ?? '');
const placeholder = initialValue === undefined ? mixedPlaceholder : undefined;
const { backgroundColor, color } = getAccessibleColour(customFields[fieldKey].colour); const { backgroundColor, color } = getAccessibleColour(customFields[fieldKey].colour);
const labelText = customFields[fieldKey].label; const labelText = customFields[fieldKey].label;
@@ -45,7 +36,6 @@ export default function EntryEditorCustomFields({
field={fieldName} field={fieldName}
label={labelText} label={labelText}
initialValue={initialValue} initialValue={initialValue}
placeholder={placeholder}
submitHandler={handleSubmit} submitHandler={handleSubmit}
className={style.decorated} className={style.decorated}
style={{ '--decorator-bg': backgroundColor, '--decorator-color': color } as CSSProperties} style={{ '--decorator-bg': backgroundColor, '--decorator-color': color } as CSSProperties}
@@ -61,13 +51,13 @@ export default function EntryEditorCustomFields({
field={fieldName} field={fieldName}
label={labelText} label={labelText}
initialValue={initialValue} initialValue={initialValue}
placeholder={placeholder ?? 'Paste image URL'} placeholder='Paste image URL'
submitHandler={handleSubmit} submitHandler={handleSubmit}
className={style.decorated} className={style.decorated}
maxLength={255} maxLength={255}
style={{ '--decorator-bg': backgroundColor, '--decorator-color': color } as CSSProperties} style={{ '--decorator-bg': backgroundColor, '--decorator-color': color } as CSSProperties}
/> />
<EventEditorImage src={initialValue ?? ''} /> <EventEditorImage src={initialValue} />
</div> </div>
); );
} }
@@ -1,62 +0,0 @@
import { TimeStrategy } from 'ontime-types';
import { memo } from 'react';
import * as Editor from '../../../../common/components/editor-utils/EditorUtils';
import { millisToDelayString } from '../../../../common/utils/dateConfig';
import { formatTime, normaliseWallClock } from '../../../../common/utils/time';
import TimeInputFlow from '../../time-input-flow/TimeInputFlow';
import style from '../EntryEditor.module.scss';
interface EventEditorScheduleProps {
eventId: string;
timeStart: number;
timeEnd: number;
duration: number;
timeStrategy: TimeStrategy;
linkStart: boolean;
delay: number;
}
/**
* Schedule of a single event
* Schedule values cascade through the rundown, so they are not editable across a selection
*/
export default memo(EventEditorSchedule);
function EventEditorSchedule({
eventId,
timeStart,
timeEnd,
duration,
timeStrategy,
linkStart,
delay,
}: EventEditorScheduleProps) {
const hasDelay = delay !== 0;
const delayedStart = normaliseWallClock(timeStart + delay);
const delayedEnd = normaliseWallClock(timeEnd + delay);
const delayLabel = hasDelay
? `Event is ${millisToDelayString(delay, 'expanded')}. New schedule ${formatTime(delayedStart)}${formatTime(delayedEnd)}`
: '';
return (
<div className={style.column}>
<Editor.Title>Event schedule</Editor.Title>
<div>
<div className={style.inline}>
<TimeInputFlow
eventId={eventId}
timeStart={timeStart}
timeEnd={timeEnd}
duration={duration}
timeStrategy={timeStrategy}
linkStart={linkStart}
delay={delay}
showLabels
/>
</div>
<div className={style.delayLabel}>{delayLabel}</div>
</div>
</div>
);
}
@@ -1,4 +1,4 @@
import { EndAction, OntimeEvent, TimerType } from 'ontime-types'; import { EndAction, TimeStrategy, TimerType } from 'ontime-types';
import { parseUserTime } from 'ontime-utils'; import { parseUserTime } from 'ontime-utils';
import { memo } from 'react'; import { memo } from 'react';
import { IoInformationCircle } from 'react-icons/io5'; import { IoInformationCircle } from 'react-icons/io5';
@@ -8,47 +8,102 @@ import TimeInput from '../../../../common/components/input/time-input/TimeInput'
import Select from '../../../../common/components/select/Select'; import Select from '../../../../common/components/select/Select';
import Switch from '../../../../common/components/switch/Switch'; import Switch from '../../../../common/components/switch/Switch';
import Tooltip from '../../../../common/components/tooltip/Tooltip'; import Tooltip from '../../../../common/components/tooltip/Tooltip';
import { mixedPlaceholder, switchLabel } from '../entryEditor.utils'; import { useEntryActionsContext } from '../../../../common/context/EntryActionsContext';
import { millisToDelayString } from '../../../../common/utils/dateConfig';
import { formatTime } from '../../../../common/utils/time';
import TimeInputFlow from '../../time-input-flow/TimeInputFlow';
import style from '../EntryEditor.module.scss'; import style from '../EntryEditor.module.scss';
interface EventEditorTimesProps { interface EventEditorTimesProps {
countToEnd: boolean | undefined; eventId: string;
endAction: EndAction | undefined; timeStart: number;
timerType: TimerType | undefined; timeEnd: number;
timeWarning: number | undefined; duration: number;
timeDanger: number | undefined; timeStrategy: TimeStrategy;
submit: (patch: Partial<OntimeEvent>) => void; linkStart: boolean;
countToEnd: boolean;
delay: number;
endAction: EndAction;
timerType: TimerType;
timeWarning: number;
timeDanger: number;
} }
type TimeFields = 'timeWarning' | 'timeDanger'; type HandledActions = 'countToEnd' | 'timerType' | 'endAction' | 'timeWarning' | 'timeDanger';
export default memo(EventEditorTimes); export default memo(EventEditorTimes);
function EventEditorTimes({ function EventEditorTimes({
eventId,
timeStart,
timeEnd,
duration,
timeStrategy,
linkStart,
countToEnd, countToEnd,
delay,
endAction, endAction,
timerType, timerType,
timeWarning, timeWarning,
timeDanger, timeDanger,
submit,
}: EventEditorTimesProps) { }: EventEditorTimesProps) {
const handleTimeSubmit = (field: TimeFields, value: string) => { const { updateEntry } = useEntryActionsContext();
submit({ [field]: parseUserTime(value) });
const handleSubmit = (field: HandledActions, value: string | boolean) => {
if (field === 'countToEnd') {
updateEntry({ id: eventId, countToEnd: value as boolean });
return;
}
if (field === 'timeWarning' || field === 'timeDanger') {
const newTime = parseUserTime(value as string);
updateEntry({ id: eventId, [field]: newTime });
return;
}
if (field === 'timerType' || field === 'endAction') {
updateEntry({ id: eventId, [field]: value });
return;
}
}; };
const hasDelay = delay !== 0;
const delayLabel = hasDelay
? `Event is ${millisToDelayString(delay, 'expanded')}. New schedule ${formatTime(timeStart + delay)}${formatTime(timeEnd + delay)}`
: '';
return ( return (
<> <>
<div className={style.column}>
<Editor.Title>Event schedule</Editor.Title>
<div>
<div className={style.inline}>
<TimeInputFlow
eventId={eventId}
timeStart={timeStart}
timeEnd={timeEnd}
duration={duration}
timeStrategy={timeStrategy}
linkStart={linkStart}
delay={delay}
countToEnd={countToEnd}
showLabels
/>
</div>
<div className={style.delayLabel}>{delayLabel}</div>
</div>
</div>
<div className={style.column}> <div className={style.column}>
<Editor.Title>Event Behaviour</Editor.Title> <Editor.Title>Event Behaviour</Editor.Title>
<div className={style.splitTwo}> <div className={style.splitTwo}>
<div> <div>
<Editor.Label htmlFor='endAction'>End Action</Editor.Label> <Editor.Label htmlFor='endAction'>End Action</Editor.Label>
<Select <Select
value={endAction ?? null} value={endAction}
placeholder={mixedPlaceholder}
onValueChange={(value: EndAction | null) => { onValueChange={(value: EndAction | null) => {
if (value === null) return; if (value === null) return;
submit({ endAction: value }); handleSubmit('endAction', value);
}} }}
options={[ options={[
{ value: EndAction.None, label: 'None' }, { value: EndAction.None, label: 'None' },
@@ -62,11 +117,10 @@ function EventEditorTimes({
<Editor.Label className={style.switchLabel}> <Editor.Label className={style.switchLabel}>
<Switch <Switch
id='countToEnd' id='countToEnd'
checked={countToEnd ?? false} checked={countToEnd}
mixed={countToEnd === undefined} onCheckedChange={(value) => handleSubmit('countToEnd', value)}
onCheckedChange={(value) => submit({ countToEnd: value })}
/> />
{switchLabel(countToEnd)} {countToEnd ? 'On' : 'Off'}
</Editor.Label> </Editor.Label>
</div> </div>
</div> </div>
@@ -86,11 +140,10 @@ function EventEditorTimes({
<div> <div>
<Editor.Label htmlFor='timerType'>Timer Type</Editor.Label> <Editor.Label htmlFor='timerType'>Timer Type</Editor.Label>
<Select <Select
value={timerType ?? null} value={timerType}
placeholder={mixedPlaceholder}
onValueChange={(value: TimerType | null) => { onValueChange={(value: TimerType | null) => {
if (value === null) return; if (value === null) return;
submit({ timerType: value }); handleSubmit('timerType', value);
}} }}
options={[ options={[
{ value: TimerType.CountDown, label: 'Count down' }, { value: TimerType.CountDown, label: 'Count down' },
@@ -107,9 +160,9 @@ function EventEditorTimes({
<TimeInput <TimeInput
id='timeWarning' id='timeWarning'
name='timeWarning' name='timeWarning'
submitHandler={handleTimeSubmit} submitHandler={handleSubmit}
time={timeWarning} time={timeWarning}
placeholder={timeWarning === undefined ? mixedPlaceholder : 'Duration'} placeholder='Duration'
/> />
</div> </div>
<div> <div>
@@ -117,9 +170,9 @@ function EventEditorTimes({
<TimeInput <TimeInput
id='timeDanger' id='timeDanger'
name='timeDanger' name='timeDanger'
submitHandler={handleTimeSubmit} submitHandler={handleSubmit}
time={timeDanger} time={timeDanger}
placeholder={timeDanger === undefined ? mixedPlaceholder : 'Duration'} placeholder='Duration'
/> />
</div> </div>
</div> </div>
@@ -1,36 +1,34 @@
import { OntimeEvent } from 'ontime-types';
import { memo } from 'react'; import { memo } from 'react';
import * as Editor from '../../../../common/components/editor-utils/EditorUtils'; import * as Editor from '../../../../common/components/editor-utils/EditorUtils';
import SwatchSelect from '../../../../common/components/input/colour-input/SwatchSelect'; import SwatchSelect from '../../../../common/components/input/colour-input/SwatchSelect';
import Input from '../../../../common/components/input/input/Input'; import Input from '../../../../common/components/input/input/Input';
import Switch from '../../../../common/components/switch/Switch'; import Switch from '../../../../common/components/switch/Switch';
import { enDash } from '../../../../common/utils/styleUtils'; import { useEntryActionsContext } from '../../../../common/context/EntryActionsContext';
import { mixedPlaceholder, switchLabel } from '../entryEditor.utils';
import EventTextArea from './EventTextArea'; import EventTextArea from './EventTextArea';
import EntryEditorTextInput from './EventTextInput'; import EntryEditorTextInput from './EventTextInput';
import style from '../EntryEditor.module.scss'; import style from '../EntryEditor.module.scss';
interface EventEditorTitlesProps { interface EventEditorTitlesProps {
/** id of the event being edited, null when editing several events */ eventId: string;
eventId: string | null;
/** amount of events being edited */
eventCount: number;
cue: string; cue: string;
flag: boolean | undefined; flag: boolean;
title: string | undefined; title: string;
note: string | undefined; note: string;
colour: string | undefined; colour: string;
submit: (patch: Partial<OntimeEvent>) => void;
} }
export default memo(EventEditorTitles); export default memo(EventEditorTitles);
function EventEditorTitles({ eventId, eventCount, cue, flag, title, note, colour, submit }: EventEditorTitlesProps) { function EventEditorTitles({ eventId, cue, flag, title, note, colour }: EventEditorTitlesProps) {
const isMulti = eventId === null; const { updateEntry } = useEntryActionsContext();
const flagSubmitHandler = (newValue: boolean) => {
updateEntry({ id: eventId, flag: newValue });
};
const textSubmitHandler = (field: string, newValue: string) => { const textSubmitHandler = (field: string, newValue: string) => {
submit({ [field]: newValue }); updateEntry({ id: eventId, [field]: newValue });
}; };
return ( return (
@@ -38,39 +36,21 @@ function EventEditorTitles({ eventId, eventCount, cue, flag, title, note, colour
<Editor.Title>Event Data</Editor.Title> <Editor.Title>Event Data</Editor.Title>
<div className={style.splitThree}> <div className={style.splitThree}>
<div> <div>
<Editor.Label htmlFor='eventId'>{isMulti ? 'Selection (read only)' : 'Event ID (read only)'}</Editor.Label> <Editor.Label htmlFor='eventId'>Event ID (read only)</Editor.Label>
<Input <Input id='eventId' data-testid='input-textfield' value={eventId} readOnly fluid />
id='eventId'
data-testid='input-textfield'
value={isMulti ? `${eventCount} events selected` : eventId}
readOnly
fluid
/>
</div> </div>
{isMulti ? ( <EntryEditorTextInput
<div> field='cue'
<Editor.Label htmlFor='cue'>Cue (not available)</Editor.Label> label='Cue'
<Input id='cue' value={enDash} readOnly fluid /> initialValue={cue}
</div> submitHandler={textSubmitHandler}
) : ( maxLength={10}
<EntryEditorTextInput />
field='cue'
label='Cue'
initialValue={cue}
submitHandler={textSubmitHandler}
maxLength={10}
/>
)}
<div> <div>
<Editor.Label htmlFor='flag'>Flag</Editor.Label> <Editor.Label htmlFor='flag'>Flag</Editor.Label>
<Editor.Label className={style.switchLabel}> <Editor.Label className={style.switchLabel}>
<Switch <Switch id='flag' checked={flag} onCheckedChange={flagSubmitHandler} />
id='flag' {flag ? 'On' : 'Off'}
checked={flag ?? false}
mixed={flag === undefined}
onCheckedChange={(newValue) => submit({ flag: newValue })}
/>
{switchLabel(flag)}
</Editor.Label> </Editor.Label>
</div> </div>
</div> </div>
@@ -78,20 +58,8 @@ function EventEditorTitles({ eventId, eventCount, cue, flag, title, note, colour
<Editor.Label>Colour</Editor.Label> <Editor.Label>Colour</Editor.Label>
<SwatchSelect name='colour' value={colour} handleChange={textSubmitHandler} /> <SwatchSelect name='colour' value={colour} handleChange={textSubmitHandler} />
</div> </div>
<EntryEditorTextInput <EntryEditorTextInput field='title' label='Title' initialValue={title} submitHandler={textSubmitHandler} />
field='title' <EventTextArea field='note' label='Note' initialValue={note} submitHandler={textSubmitHandler} />
label='Title'
initialValue={title}
placeholder={title === undefined ? mixedPlaceholder : undefined}
submitHandler={textSubmitHandler}
/>
<EventTextArea
field='note'
label='Note'
initialValue={note}
placeholder={note === undefined ? mixedPlaceholder : undefined}
submitHandler={textSubmitHandler}
/>
</div> </div>
); );
} }
@@ -5,15 +5,11 @@ import { AutoTextarea } from '../../../../common/components/input/auto-textarea/
import useReactiveTextInput from '../../../../common/components/input/text-input/useReactiveTextInput'; import useReactiveTextInput from '../../../../common/components/input/text-input/useReactiveTextInput';
import { EventEditorUpdateFields } from '../EventEditor'; import { EventEditorUpdateFields } from '../EventEditor';
import style from '../EntryEditor.module.scss';
interface CountedTextAreaProps { interface CountedTextAreaProps {
className?: string; className?: string;
field: EventEditorUpdateFields; field: EventEditorUpdateFields;
label: string; label: string;
/** undefined represents values which do not agree across the edited entries */ initialValue: string;
initialValue: string | undefined;
placeholder?: string;
style?: CSSProperties; style?: CSSProperties;
submitHandler: (field: EventEditorUpdateFields, value: string) => void; submitHandler: (field: EventEditorUpdateFields, value: string) => void;
} }
@@ -23,7 +19,6 @@ export default function EventTextArea({
field, field,
label, label,
initialValue, initialValue,
placeholder,
style: givenStyles, style: givenStyles,
submitHandler, submitHandler,
}: CountedTextAreaProps) { }: CountedTextAreaProps) {
@@ -34,27 +29,16 @@ export default function EventTextArea({
submitOnCtrlEnter: true, submitOnCtrlEnter: true,
}); });
// an unknown value cannot be cleared by emptying the field, we offer an explicit action
const canClear = initialValue === undefined;
return ( return (
<div> <div>
<div className={style.labelRow}> <Editor.Label className={className} htmlFor={field} style={givenStyles}>
<Editor.Label className={className} htmlFor={field} style={givenStyles}> {label}
{label} </Editor.Label>
</Editor.Label>
{canClear && (
<button type='button' className={style.clearAction} onClick={() => submitCallback('')}>
Clear
</button>
)}
</div>
<AutoTextarea <AutoTextarea
id={field} id={field}
inputref={ref} inputref={ref}
rows={1} rows={1}
data-testid='input-textarea' data-testid='input-textarea'
placeholder={placeholder}
fluid fluid
value={value} value={value}
onChange={onChange} onChange={onChange}
@@ -6,13 +6,10 @@ import useReactiveTextInput from '../../../../common/components/input/text-input
import { EventEditorUpdateFields } from '../EventEditor'; import { EventEditorUpdateFields } from '../EventEditor';
import { GroupEditorUpdateTextFields } from '../GroupEditor'; import { GroupEditorUpdateTextFields } from '../GroupEditor';
import style from '../EntryEditor.module.scss';
interface EntryEditorTextInputProps extends InputProps { interface EntryEditorTextInputProps extends InputProps {
field: EventEditorUpdateFields | GroupEditorUpdateTextFields; field: EventEditorUpdateFields | GroupEditorUpdateTextFields;
label: string; label: string;
/** undefined represents values which do not agree across the edited entries */ initialValue: string;
initialValue: string | undefined;
placeholder?: string; placeholder?: string;
submitHandler: (field: EventEditorUpdateFields, value: string) => void; submitHandler: (field: EventEditorUpdateFields, value: string) => void;
} }
@@ -34,21 +31,11 @@ export default function EntryEditorTextInput({
submitOnEnter: true, submitOnEnter: true,
}); });
// an unknown value cannot be cleared by emptying the field, we offer an explicit action
const canClear = initialValue === undefined;
return ( return (
<div> <div>
<div className={style.labelRow}> <Editor.Label className={className} htmlFor={field} style={givenStyles}>
<Editor.Label className={className} htmlFor={field} style={givenStyles}> {label}
{label} </Editor.Label>
</Editor.Label>
{canClear && (
<button type='button' className={style.clearAction} onClick={() => submitCallback('')}>
Clear
</button>
)}
</div>
<Input <Input
id={field} id={field}
ref={ref} ref={ref}
@@ -1,10 +0,0 @@
/** Shown in place of a value when the entries being edited do not agree */
export const mixedPlaceholder = 'Mixed';
/** Returns the label of a switch which may represent values that do not agree */
export function switchLabel(value: boolean | undefined): string {
if (value === undefined) {
return mixedPlaceholder;
}
return value ? 'On' : 'Off';
}
@@ -1,108 +0,0 @@
import { CustomFieldKey, OntimeEvent } from 'ontime-types';
/**
* Marks a value which is not the same across the entries being edited
* This is a distinct value from undefined, which means that the field is absent
* Being a symbol, it also cannot be assigned to a patch by accident
*/
export const conflict: unique symbol = Symbol('conflict');
export type Conflict = typeof conflict;
/** A value which may not be the same across the entries being edited */
export type MergedValue<T> = T | Conflict;
export function isConflict<T>(value: MergedValue<T>): value is Conflict {
return value === conflict;
}
/** Resolves a merged value for the UI, where an unknown value is represented by undefined */
export function resolveConflict<T>(value: MergedValue<T>): T | undefined {
return isConflict(value) ? undefined : value;
}
/**
* Fields which can be edited across a selection of events
* timeStart and timeEnd are absolute points in time and are excluded:
* giving several events the same start or end collapses their durations
*/
export const batchEditableFields = [
'title',
'note',
'colour',
'flag',
'duration',
'endAction',
'countToEnd',
'timerType',
'timeWarning',
'timeDanger',
'linkStart',
] as const;
type BatchEditableField = (typeof batchEditableFields)[number];
/**
* Custom fields of the entries being edited
* A missing key means that none of the entries have a value for that field
*/
export type MergedCustomFields = Record<CustomFieldKey, MergedValue<string>>;
/**
* A merged view over a set of events
* A field holds the conflict symbol when the events do not agree on its value
*/
export type MergedEvent = {
[K in BatchEditableField]: MergedValue<OntimeEvent[K]>;
} & {
custom: MergedCustomFields;
};
/**
* Merges a list of events into a single view
* For a single event, no field is in conflict and every value matches the event
* @param events - events to merge, must contain at least one element
*/
export function mergeEvents(events: OntimeEvent[]): MergedEvent {
return {
title: mergeField(events, 'title'),
note: mergeField(events, 'note'),
colour: mergeField(events, 'colour'),
flag: mergeField(events, 'flag'),
duration: mergeField(events, 'duration'),
endAction: mergeField(events, 'endAction'),
countToEnd: mergeField(events, 'countToEnd'),
timerType: mergeField(events, 'timerType'),
timeWarning: mergeField(events, 'timeWarning'),
timeDanger: mergeField(events, 'timeDanger'),
linkStart: mergeField(events, 'linkStart'),
custom: mergeCustomFields(events),
};
}
/**
* Returns the shared value of a field, or the conflict symbol if the events disagree
*/
function mergeField<K extends BatchEditableField>(events: OntimeEvent[], field: K): MergedValue<OntimeEvent[K]> {
const value = events[0][field];
return events.some((event) => event[field] !== value) ? conflict : value;
}
/**
* Merges the custom fields of a list of events
* Fields missing from an entry are considered empty
*/
function mergeCustomFields(events: OntimeEvent[]): MergedCustomFields {
const merged: MergedCustomFields = {};
for (const event of events) {
for (const key of Object.keys(event.custom)) {
if (key in merged) {
continue;
}
const value = event.custom[key] ?? '';
merged[key] = events.some((other) => (other.custom[key] ?? '') !== value) ? conflict : value;
}
}
return merged;
}
@@ -11,17 +11,13 @@ import style from './QuickAddButtons.module.scss';
interface QuickAddButtonsProps { interface QuickAddButtonsProps {
previousEventId: MaybeString; previousEventId: MaybeString;
nextEventId?: MaybeString;
parentGroup: MaybeString; parentGroup: MaybeString;
backgroundColor?: string; backgroundColor?: string;
} }
export default memo(QuickAddButtons); export default memo(QuickAddButtons);
function QuickAddButtons({ previousEventId, nextEventId, parentGroup, backgroundColor }: QuickAddButtonsProps) { function QuickAddButtons({ previousEventId, parentGroup, backgroundColor }: QuickAddButtonsProps) {
const { addEntry } = useEntryActionsContext(); const { addEntry } = useEntryActionsContext();
const appendOptions = previousEventId ? { after: previousEventId, lastEventId: previousEventId } : undefined;
const prependOptions = nextEventId ? { before: nextEventId } : undefined;
const insertionOptions = appendOptions ?? prependOptions;
const addEvent = () => { const addEvent = () => {
addEntry( addEntry(
@@ -29,23 +25,44 @@ function QuickAddButtons({ previousEventId, nextEventId, parentGroup, background
type: SupportedEntry.Event, type: SupportedEntry.Event,
parent: parentGroup, parent: parentGroup,
}, },
insertionOptions, {
after: previousEventId,
lastEventId: previousEventId,
},
); );
}; };
const addDelay = () => { const addDelay = () => {
addEntry({ type: SupportedEntry.Delay, parent: parentGroup }, insertionOptions); addEntry(
{ type: SupportedEntry.Delay, parent: parentGroup },
{
lastEventId: previousEventId,
after: previousEventId,
},
);
}; };
const addMilestone = () => { const addMilestone = () => {
addEntry({ type: SupportedEntry.Milestone, parent: parentGroup }, insertionOptions); addEntry(
{ type: SupportedEntry.Milestone, parent: parentGroup },
{
lastEventId: previousEventId,
after: previousEventId,
},
);
}; };
const addGroup = () => { const addGroup = () => {
if (parentGroup !== null) { if (parentGroup !== null) {
return; return;
} }
addEntry({ type: SupportedEntry.Group }, insertionOptions); addEntry(
{ type: SupportedEntry.Group },
{
lastEventId: previousEventId,
after: previousEventId,
},
);
}; };
/** /**
@@ -96,7 +96,7 @@ export function useRundownCommands({
addEntry(patch, { addEntry(patch, {
after: id && !above ? id : undefined, after: id && !above ? id : undefined,
before: id && above ? id : undefined, before: id && above ? id : undefined,
lastEventId: id && !above ? id : undefined, lastEventId: !above && id ? id : undefined,
}); });
}, },
[addEntry], [addEntry],
@@ -19,7 +19,6 @@ interface UseRundownKeyboardOptions {
}; };
clearSelectedEvents: () => void; clearSelectedEvents: () => void;
setEntryCopyId: (id: EntryId | null, mode?: 'copy' | 'cut') => void; setEntryCopyId: (id: EntryId | null, mode?: 'copy' | 'cut') => void;
jumpToCurrent: () => void;
} }
/** /**
@@ -41,7 +40,6 @@ export function useRundownKeyboard({
commands, commands,
clearSelectedEvents, clearSelectedEvents,
setEntryCopyId, setEntryCopyId,
jumpToCurrent,
}: UseRundownKeyboardOptions) { }: UseRundownKeyboardOptions) {
const scrollToEntry = useEventSelection((state) => state.scrollToEntry); const scrollToEntry = useEventSelection((state) => state.scrollToEntry);
@@ -129,8 +127,6 @@ export function useRundownKeyboard({
{ preventDefault: true, usePhysicalKeys: true }, { preventDefault: true, usePhysicalKeys: true },
], ],
['alt + L', () => jumpToCurrent(), { preventDefault: true, usePhysicalKeys: true }],
[ [
'alt + mod + ArrowDown', 'alt + mod + ArrowDown',
() => commands.moveEntry(cursor, 'down'), () => commands.moveEntry(cursor, 'down'),
@@ -7,7 +7,6 @@ $skip-opacity: 0.2;
background-color: $block-bg; background-color: $block-bg;
margin-block: 0.25rem; margin-block: 0.25rem;
overflow: initial; overflow: initial;
position: relative;
display: grid; display: grid;
grid-template-areas: grid-template-areas:
@@ -17,7 +16,7 @@ $skip-opacity: 0.2;
'binder pb-actions estatus estatus' 'binder pb-actions estatus estatus'
'binder ... ... ...'; 'binder ... ... ...';
grid-template-columns: $block-binder-width 3rem minmax(0, 1fr) 3rem; grid-template-columns: $block-binder-width 3rem 1fr 3rem;
grid-template-rows: 0.125rem 2rem 2rem auto 0.125rem; grid-template-rows: 0.125rem 2rem 2rem auto 0.125rem;
align-items: center; align-items: center;
padding-right: $block-clearance; padding-right: $block-clearance;
@@ -137,7 +136,6 @@ $skip-opacity: 0.2;
align-items: center; align-items: center;
gap: $block-clearance; gap: $block-clearance;
height: 100%; height: 100%;
min-width: 0;
} }
.eventTimers.editMode:hover { .eventTimers.editMode:hover {
@@ -157,7 +155,7 @@ $skip-opacity: 0.2;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
.warningMeta { .nextTag {
font-size: 1rem; font-size: 1rem;
color: $orange-500; color: $orange-500;
letter-spacing: 0.03px; letter-spacing: 0.03px;
@@ -233,10 +231,6 @@ $skip-opacity: 0.2;
color: var(--status-color-active-override, $active-indicator); color: var(--status-color-active-override, $active-indicator);
} }
.statusIcon.countToEndStatus {
color: $orange-400;
}
.statusIcon.warning { .statusIcon.warning {
color: $orange-500; color: $orange-500;
} }
@@ -18,7 +18,7 @@ import { TbFlagFilled, TbListNumbers } from 'react-icons/tb';
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext'; import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
import { useContextMenu } from '../../../common/hooks/useContextMenu'; import { useContextMenu } from '../../../common/hooks/useContextMenu';
import { useEntryCopy } from '../../../common/stores/entryCopyStore'; import { useEntryCopy } from '../../../common/stores/entryCopyStore';
import { deviceAlt, deviceMod } from '../../../common/utils/deviceUtils'; import { deviceMod } from '../../../common/utils/deviceUtils';
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils'; import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
import { useRenumberCuesDialogStore } from '../renumber-cues-dialog/RenumberCuesDialog'; import { useRenumberCuesDialogStore } from '../renumber-cues-dialog/RenumberCuesDialog';
import { useEventIdSwapping } from '../useEventIdSwapping'; import { useEventIdSwapping } from '../useEventIdSwapping';
@@ -156,7 +156,7 @@ export default function RundownEvent({
type: 'item', type: 'item',
label: 'Delete', label: 'Delete',
icon: IoTrash, icon: IoTrash,
shortcut: `${deviceAlt}+Backspace`, shortcut: `${deviceMod}+Del`,
onClick: () => { onClick: () => {
clearSelectedEvents(); clearSelectedEvents();
deleteEntry(Array.from(selectedEvents)); deleteEntry(Array.from(selectedEvents));
@@ -202,7 +202,7 @@ export default function RundownEvent({
type: 'item', type: 'item',
label: 'Delete', label: 'Delete',
icon: IoTrash, icon: IoTrash,
shortcut: `${deviceAlt}+Backspace`, shortcut: `${deviceMod}+Del`,
onClick: () => { onClick: () => {
deleteEntry([eventId]); deleteEntry([eventId]);
unselect(eventId); unselect(eventId);
@@ -115,12 +115,12 @@ function RundownEventInner({
delay={delay} delay={delay}
timeStrategy={timeStrategy} timeStrategy={timeStrategy}
linkStart={linkStart} linkStart={linkStart}
countToEnd={countToEnd}
/> />
</div> </div>
<div className={style.titleSection}> <div className={style.titleSection}>
<TitleEditor title={title} entryId={eventId} placeholder='Event title' className={style.eventTitle} /> <TitleEditor title={title} entryId={eventId} placeholder='Event title' className={style.eventTitle} />
{isNext && <span className={style.warningMeta}>UP NEXT</span>} {isNext && <span className={style.nextTag}>UP NEXT</span>}
{!isNext && countToEnd && <span className={style.warningMeta}>COUNT TO END</span>}
</div> </div>
<EventBlockPlayback <EventBlockPlayback
eventId={eventId} eventId={eventId}
@@ -157,13 +157,7 @@ function RundownEventInner({
<EndActionIcon action={endAction} className={style.statusIcon} /> <EndActionIcon action={endAction} className={style.statusIcon} />
</Tooltip> </Tooltip>
<Tooltip text={`${countToEnd ? 'Count to End' : 'Count duration'}`} render={<span />}> <Tooltip text={`${countToEnd ? 'Count to End' : 'Count duration'}`} render={<span />}>
<LuArrowDownToLine <LuArrowDownToLine className={`${style.statusIcon} ${countToEnd ? style.active : style.disabled}`} />
className={cx([
style.statusIcon,
countToEnd ? style.active : style.disabled,
countToEnd && style.countToEndStatus,
])}
/>
</Tooltip> </Tooltip>
<Tooltip text={automationTooltip} render={<span />}> <Tooltip text={automationTooltip} render={<span />}>
<IoFlash className={automationIconClasses} /> <IoFlash className={automationIconClasses} />
@@ -1,33 +1,10 @@
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE } from 'ontime-utils';
import { formatDelay } from '../rundownEvent.utils'; import { formatDelay } from '../rundownEvent.utils';
describe('formatDelay()', () => { describe('formatDelay()', () => {
it('adds a given delay to the start time', () => { it('adds a given delay to the start time', () => {
const timeStart = 1 * MILLIS_PER_MINUTE; // 00:01 const timeStart = 60000; // 1 min
const delay = 1 * MILLIS_PER_MINUTE; // 00:01 const delay = 60000; // 1 min
const result = formatDelay(timeStart, delay); const result = formatDelay(timeStart, delay);
expect(result).toEqual('New start 00:02'); expect(result).toEqual('New start 00:02');
}); });
it('wraps negative delayed starts under midnight', () => {
const timeStart = 1 * MILLIS_PER_MINUTE; // 00:01
const delay = -2 * MILLIS_PER_MINUTE; // -00:02
const result = formatDelay(timeStart, delay);
expect(result).toEqual('New start 23:59');
});
it('wraps later-day negative delays using delay as the source of truth', () => {
const timeStart = 1 * MILLIS_PER_HOUR; // 01:00
const delay = -(1 * MILLIS_PER_HOUR + 30 * MILLIS_PER_MINUTE); // -01:30
const result = formatDelay(timeStart, delay);
expect(result).toEqual('New start 23:30');
});
it('displays positive delays as wall-clock time', () => {
const timeStart = 1 * MILLIS_PER_HOUR; // 01:00
const delay = 1 * MILLIS_PER_HOUR + 30 * MILLIS_PER_MINUTE; // 01:30
const result = formatDelay(timeStart, delay);
expect(result).toEqual('New start 02:30');
});
}); });
@@ -3,5 +3,7 @@
width: 0; width: 0;
border-radius: 1px 0 0 1px; border-radius: 1px 0 0 1px;
transition: 1s linear;
transition-property: width;
background-color: $gray-200; background-color: $gray-200;
} }
@@ -1,12 +1,12 @@
import { useAnimatedProgress } from '../../../../common/hooks/useAnimatedProgress';
import { useTimer } from '../../../../common/hooks/useSocket'; import { useTimer } from '../../../../common/hooks/useSocket';
import { getProgress } from '../../../../common/utils/getProgress';
import style from './RundownEventProgressBar.module.scss'; import style from './RundownEventProgressBar.module.scss';
export default function RundownEventProgressBar() { export default function RundownEventProgressBar() {
const timer = useTimer(); const timer = useTimer();
const progress = useAnimatedProgress(timer.current, timer.duration); const progress = getProgress(timer.current, timer.duration);
return <div className={style.progressBar} style={{ width: `${progress}%` }} />; return <div className={style.progressBar} style={{ width: `${progress}%` }} />;
} }
@@ -1,16 +1,15 @@
import { millisToString, removeTrailingZero } from 'ontime-utils'; import { millisToString, removeTrailingZero } from 'ontime-utils';
import { formatDuration, normaliseWallClock } from '../../../common/utils/time'; import { formatDuration } from '../../../common/utils/time';
export function formatDelay(timeStart: number, delay: number): string | undefined { export function formatDelay(timeStart: number, delay: number): string | undefined {
if (!delay) return; if (!delay) return;
const delayedStart = normaliseWallClock(timeStart + delay); const delayedStart = Math.max(0, timeStart + delay);
const timeTag = removeTrailingZero(millisToString(delayedStart)); const timeTag = removeTrailingZero(millisToString(delayedStart));
return `New start ${timeTag}`; return `New start ${timeTag}`;
} }
export function formatGap(gap: number, isNextDay: boolean) { export function formatGap(gap: number, isNextDay: boolean) {
if (gap === 0) { if (gap === 0) {
if (isNextDay) { if (isNextDay) {
@@ -82,17 +82,6 @@
margin-right: 0.25rem; margin-right: 0.25rem;
color: $ui-white; color: $ui-white;
} }
.duration {
display: flex;
align-items: center;
gap: 0.25rem;
}
.lockIcon {
opacity: 0.6;
}
.over { .over {
color: $playback-over; color: $playback-over;
.strike { .strike {
@@ -8,7 +8,6 @@ import {
IoChevronUp, IoChevronUp,
IoDuplicateOutline, IoDuplicateOutline,
IoFolderOpenOutline, IoFolderOpenOutline,
IoLockClosed,
IoReorderTwo, IoReorderTwo,
IoTrash, IoTrash,
} from 'react-icons/io5'; } from 'react-icons/io5';
@@ -18,7 +17,7 @@ import Tag from '../../../common/components/tag/Tag';
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext'; import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
import { useContextMenu } from '../../../common/hooks/useContextMenu'; import { useContextMenu } from '../../../common/hooks/useContextMenu';
import { useEntryCopy } from '../../../common/stores/entryCopyStore'; import { useEntryCopy } from '../../../common/stores/entryCopyStore';
import { deviceAlt, deviceMod } from '../../../common/utils/deviceUtils'; import { deviceMod } from '../../../common/utils/deviceUtils';
import { getOffsetState } from '../../../common/utils/offset'; import { getOffsetState } from '../../../common/utils/offset';
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils'; import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
import { formatDuration, formatTime } from '../../../common/utils/time'; import { formatDuration, formatTime } from '../../../common/utils/time';
@@ -66,7 +65,7 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }:
type: 'item', type: 'item',
label: 'Delete Group', label: 'Delete Group',
icon: IoTrash, icon: IoTrash,
shortcut: `${deviceAlt}+Backspace`, shortcut: `${deviceMod}+Del`,
onClick: () => deleteEntry([data.id]), onClick: () => deleteEntry([data.id]),
}, },
]); ]);
@@ -177,17 +176,14 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }:
</div> </div>
<div className={style.metaEntry}> <div className={style.metaEntry}>
<div className={style.metaLabel}>Duration</div> <div className={style.metaLabel}>Duration</div>
<div className={style.duration}> {planOffset === null ? (
{planOffset === null ? ( <div>{formatDuration(data.duration)}</div>
formatDuration(data.duration) ) : (
) : ( <div className={cx([planOffsetLabel && style[planOffsetLabel]])}>
<span className={cx([planOffsetLabel && style[planOffsetLabel]])}> <span className={style.strike}>{formatDuration(data.duration)}</span>
<span className={style.strike}>{formatDuration(data.duration)}</span> <Tag className={style.offsetLabel}>{planOffset}</Tag>
<Tag className={style.offsetLabel}>{planOffset}</Tag> </div>
</span> )}
)}
{data.targetDuration !== null && <IoLockClosed className={style.lockIcon} />}
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -9,7 +9,7 @@ import useReactiveTextInput from '../../../common/components/input/text-input/us
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext'; import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
import { useContextMenu } from '../../../common/hooks/useContextMenu'; import { useContextMenu } from '../../../common/hooks/useContextMenu';
import { useEntryCopy } from '../../../common/stores/entryCopyStore'; import { useEntryCopy } from '../../../common/stores/entryCopyStore';
import { deviceAlt } from '../../../common/utils/deviceUtils'; import { deviceMod } from '../../../common/utils/deviceUtils';
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils'; import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
import { useEventSelection } from '../useEventSelection'; import { useEventSelection } from '../useEventSelection';
@@ -38,7 +38,7 @@ export default function RundownMilestone({ colour, cue, entryId, hasCursor, titl
type: 'item', type: 'item',
label: 'Delete', label: 'Delete',
icon: IoTrash, icon: IoTrash,
shortcut: `${deviceAlt}+Backspace`, shortcut: `${deviceMod}+Del`,
onClick: () => deleteEntry([entryId]), onClick: () => deleteEntry([entryId]),
}, },
]); ]);

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