mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-17 13:23:35 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 69aeb4aabb | |||
| 34ca715fb7 | |||
| b981bae63f |
@@ -6,7 +6,8 @@ import SwatchPicker from './SwatchPicker';
|
|||||||
import style from './SwatchSelect.module.scss';
|
import style from './SwatchSelect.module.scss';
|
||||||
|
|
||||||
interface ColourInputProps {
|
interface ColourInputProps {
|
||||||
value: string;
|
/** undefined represents values which do not agree, no swatch is highlighted */
|
||||||
|
value: string | undefined;
|
||||||
name: 'colour';
|
name: 'colour';
|
||||||
handleChange: (newValue: 'colour', name: string) => void;
|
handleChange: (newValue: 'colour', name: string) => void;
|
||||||
}
|
}
|
||||||
@@ -44,7 +45,11 @@ 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 color={value} onChange={setColour} isSelected={!colours.includes(value)} />
|
<SwatchPicker
|
||||||
|
color={value ?? ''}
|
||||||
|
onChange={setColour}
|
||||||
|
isSelected={value !== undefined && !colours.includes(value)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+31
@@ -0,0 +1,31 @@
|
|||||||
|
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,6 +1,23 @@
|
|||||||
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;
|
||||||
@@ -9,7 +26,8 @@ interface UseReactiveTextInputReturn {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function useReactiveTextInput(
|
export default function useReactiveTextInput(
|
||||||
initialText: string,
|
/** undefined represents an unknown value, the field is shown empty and blurring it submits nothing */
|
||||||
|
initialText: string | undefined,
|
||||||
submitCallback: (newValue: string) => void,
|
submitCallback: (newValue: string) => void,
|
||||||
ref: RefObject<HTMLInputElement | HTMLTextAreaElement | null>,
|
ref: RefObject<HTMLInputElement | HTMLTextAreaElement | null>,
|
||||||
options?: {
|
options?: {
|
||||||
@@ -22,7 +40,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
|
||||||
@@ -57,8 +75,7 @@ export default function useReactiveTextInput(
|
|||||||
*/
|
*/
|
||||||
const handleSubmit = useCallback(
|
const handleSubmit = useCallback(
|
||||||
(valueToSubmit: string) => {
|
(valueToSubmit: string) => {
|
||||||
// No need to update if it hasn't changed
|
if (!shouldSubmitValue(valueToSubmit, initialText, options?.allowSubmitSameValue)) {
|
||||||
if (valueToSubmit === initialText && !options?.allowSubmitSameValue) {
|
|
||||||
options?.onCancelUpdate?.();
|
options?.onCancelUpdate?.();
|
||||||
} else {
|
} else {
|
||||||
const cleanVal = valueToSubmit.trim();
|
const cleanVal = valueToSubmit.trim();
|
||||||
@@ -86,10 +103,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,7 +41,10 @@ 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 (typeof time !== 'number' || isNaN(time)) {
|
if (time === undefined) {
|
||||||
|
// 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));
|
||||||
|
|||||||
@@ -16,13 +16,21 @@ 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>({ options, fluid, size = 'medium', ...selectRootProps }: SelectProps<T>) {
|
export default function Select<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 />
|
<BaseSelect.Value placeholder={placeholder} />
|
||||||
<BaseSelect.Icon className={styles.selectIcon}>
|
<BaseSelect.Icon className={styles.selectIcon}>
|
||||||
<LuChevronsUpDown />
|
<LuChevronsUpDown />
|
||||||
</BaseSelect.Icon>
|
</BaseSelect.Icon>
|
||||||
|
|||||||
@@ -29,6 +29,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 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,11 +6,13 @@ 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', ...switchProps }: SwitchProps) {
|
export default function Switch({ size = 'medium', mixed, ...switchProps }: SwitchProps) {
|
||||||
return (
|
return (
|
||||||
<BaseSwitch.Root className={cx([style.switch, style[size]])} {...switchProps}>
|
<BaseSwitch.Root className={cx([style.switch, style[size], mixed && style.mixed])} {...switchProps}>
|
||||||
<BaseSwitch.Thumb className={style.thumb} />
|
<BaseSwitch.Thumb className={style.thumb} />
|
||||||
</BaseSwitch.Root>
|
</BaseSwitch.Root>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
@@ -52,6 +52,7 @@ 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)
|
||||||
@@ -483,21 +484,9 @@ 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);
|
||||||
|
|
||||||
if (previousRundown) {
|
// when the result cannot be resolved here we wait for the recalculated rundown from the server
|
||||||
const eventIds = new Set(data.ids);
|
if (previousRundown && canPredictBatchResult(data.data)) {
|
||||||
const newRundown = { ...previousRundown.entries };
|
const newRundown = applyPatchToEvents(previousRundown.entries, data.ids, data.data);
|
||||||
|
|
||||||
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,
|
||||||
|
|||||||
@@ -22,10 +22,13 @@ 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]);
|
||||||
|
|
||||||
if (isOntimeEvent(entry)) {
|
// the cuesheet always edits a single 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 event={entry} />
|
<EventEditor events={events} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,6 +75,26 @@
|
|||||||
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,14 +1,19 @@
|
|||||||
import { OntimeEvent } from 'ontime-types';
|
import { OntimeEvent } from 'ontime-types';
|
||||||
import { useCallback } from 'react';
|
import { useCallback, useMemo } 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';
|
||||||
|
|
||||||
@@ -16,66 +21,115 @@ 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 {
|
||||||
event: OntimeEvent;
|
/** events being edited, editing several events at once shows a merged view of their values */
|
||||||
|
events: OntimeEvent[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function EventEditor({ event }: EventEditorProps) {
|
export default function EventEditor({ events }: EventEditorProps) {
|
||||||
const { data: customFields } = useCustomFields();
|
const { data: customFields } = useCustomFields();
|
||||||
const { updateEntry } = useEntryActionsContext();
|
const { updateEntry, batchUpdateEvents } = 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];
|
||||||
updateEntry({ id: event.id, custom: { [fieldLabel]: value } });
|
submit({ custom: { [fieldLabel]: value } });
|
||||||
} else {
|
} else {
|
||||||
updateEntry({ id: event.id, [field]: value });
|
submit({ [field]: value });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[event.id, updateEntry],
|
[submit],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 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={`${event.id}-times`}
|
key={`${editorKey}-times`}
|
||||||
eventId={event.id}
|
countToEnd={resolveConflict(merged.countToEnd)}
|
||||||
timeStart={event.timeStart}
|
endAction={resolveConflict(merged.endAction)}
|
||||||
timeEnd={event.timeEnd}
|
timerType={resolveConflict(merged.timerType)}
|
||||||
duration={event.duration}
|
timeWarning={resolveConflict(merged.timeWarning)}
|
||||||
timeStrategy={event.timeStrategy}
|
timeDanger={resolveConflict(merged.timeDanger)}
|
||||||
linkStart={event.linkStart}
|
submit={submit}
|
||||||
countToEnd={event.countToEnd}
|
|
||||||
delay={event.delay}
|
|
||||||
endAction={event.endAction}
|
|
||||||
timerType={event.timerType}
|
|
||||||
timeWarning={event.timeWarning}
|
|
||||||
timeDanger={event.timeDanger}
|
|
||||||
/>
|
/>
|
||||||
<EventEditorTitles
|
<EventEditorTitles
|
||||||
key={`${event.id}-titles`}
|
key={`${editorKey}-titles`}
|
||||||
eventId={event.id}
|
eventId={singleEvent?.id ?? null}
|
||||||
cue={event.cue}
|
eventCount={events.length}
|
||||||
flag={event.flag}
|
cue={singleEvent?.cue ?? ''}
|
||||||
title={event.title}
|
flag={resolveConflict(merged.flag)}
|
||||||
note={event.note}
|
title={resolveConflict(merged.title)}
|
||||||
colour={event.colour}
|
note={resolveConflict(merged.note)}
|
||||||
|
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 fields={customFields} handleSubmit={handleSubmit} entry={event} />
|
<EntryEditorCustomFields
|
||||||
|
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 && <AppLink search='settings=automation'>Manage Automations</AppLink>}
|
{isEditor && singleEvent && <AppLink search='settings=automation'>Manage Automations</AppLink>}
|
||||||
</Editor.Title>
|
</Editor.Title>
|
||||||
<EventEditorTriggers triggers={event.triggers} eventId={event.id} />
|
{singleEvent ? (
|
||||||
|
<EventEditorTriggers triggers={singleEvent.triggers} eventId={singleEvent.id} />
|
||||||
|
) : (
|
||||||
|
<Info>Automations are not available when editing multiple events</Info>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -112,7 +112,12 @@ 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 fields={customFields} handleSubmit={handleSubmit} entry={group} />
|
<EntryEditorCustomFields
|
||||||
|
fields={customFields}
|
||||||
|
handleSubmit={handleSubmit}
|
||||||
|
custom={group.custom}
|
||||||
|
idKey={group.id}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -69,7 +69,12 @@ 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 fields={customFields} handleSubmit={handleSubmit} entry={milestone} />
|
<EntryEditorCustomFields
|
||||||
|
fields={customFields}
|
||||||
|
handleSubmit={handleSubmit}
|
||||||
|
custom={milestone.custom}
|
||||||
|
idKey={milestone.id}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { OntimeEntry, isOntimeEvent, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
|
import { OntimeEntry, OntimeEvent, 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,6 +15,25 @@ 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;
|
||||||
@@ -29,19 +48,20 @@ export default function RundownEntryEditor() {
|
|||||||
return event ?? null;
|
return event ?? null;
|
||||||
}, [data.order.length, data.entries, selectedEvents]);
|
}, [data.order.length, data.entries, selectedEvents]);
|
||||||
|
|
||||||
if (!entry) {
|
if (events.length > 0) {
|
||||||
return <EventEditorEmpty />;
|
const singleEvent = events.length === 1 ? events[0] : null;
|
||||||
}
|
|
||||||
|
|
||||||
if (isOntimeEvent(entry)) {
|
|
||||||
return (
|
return (
|
||||||
<div className={style.rundownEditor} data-testid='editor-container'>
|
<div className={style.rundownEditor} data-testid='editor-container'>
|
||||||
<EventEditor event={entry} />
|
<EventEditor events={events} />
|
||||||
<EventEditorFooter id={entry.id} cue={entry.cue} />
|
{singleEvent && <EventEditorFooter id={singleEvent.id} cue={singleEvent.cue} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!entry) {
|
||||||
|
return <EventEditorEmpty />;
|
||||||
|
}
|
||||||
|
|
||||||
if (isOntimeMilestone(entry)) {
|
if (isOntimeMilestone(entry)) {
|
||||||
return (
|
return (
|
||||||
<div className={style.rundownEditor} data-testid='editor-container'>
|
<div className={style.rundownEditor} data-testid='editor-container'>
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+17
-7
@@ -1,8 +1,9 @@
|
|||||||
import { CustomFields, OntimeEvent, OntimeGroup, OntimeMilestone } from 'ontime-types';
|
import { CustomFields, EntryCustomFields } 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';
|
||||||
@@ -11,21 +12,29 @@ import style from '../EntryEditor.module.scss';
|
|||||||
|
|
||||||
interface EntryEditorCustomFieldsProps {
|
interface EntryEditorCustomFieldsProps {
|
||||||
fields: CustomFields;
|
fields: CustomFields;
|
||||||
entry: OntimeEvent | OntimeGroup | OntimeMilestone;
|
/** values of the entries being edited, undefined when the entries do not agree */
|
||||||
|
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 = `${entry.id}-${fieldKey}`;
|
const key = `${idKey}-${fieldKey}`;
|
||||||
const fieldName = `custom-${fieldKey}`;
|
const fieldName = `custom-${fieldKey}`;
|
||||||
const initialValue = entry.custom[fieldKey] ?? '';
|
// a key which is absent from the merged view is not present in any of the entries
|
||||||
|
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;
|
||||||
|
|
||||||
@@ -36,6 +45,7 @@ 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}
|
||||||
@@ -51,13 +61,13 @@ export default function EntryEditorCustomFields({
|
|||||||
field={fieldName}
|
field={fieldName}
|
||||||
label={labelText}
|
label={labelText}
|
||||||
initialValue={initialValue}
|
initialValue={initialValue}
|
||||||
placeholder='Paste image URL'
|
placeholder={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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
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, TimeStrategy, TimerType } from 'ontime-types';
|
import { EndAction, OntimeEvent, 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,103 +8,47 @@ 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 { useEntryActionsContext } from '../../../../common/context/EntryActionsContext';
|
import { mixedPlaceholder, switchLabel } from '../entryEditor.utils';
|
||||||
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';
|
import style from '../EntryEditor.module.scss';
|
||||||
|
|
||||||
interface EventEditorTimesProps {
|
interface EventEditorTimesProps {
|
||||||
eventId: string;
|
countToEnd: boolean | undefined;
|
||||||
timeStart: number;
|
endAction: EndAction | undefined;
|
||||||
timeEnd: number;
|
timerType: TimerType | undefined;
|
||||||
duration: number;
|
timeWarning: number | undefined;
|
||||||
timeStrategy: TimeStrategy;
|
timeDanger: number | undefined;
|
||||||
linkStart: boolean;
|
submit: (patch: Partial<OntimeEvent>) => void;
|
||||||
countToEnd: boolean;
|
|
||||||
delay: number;
|
|
||||||
endAction: EndAction;
|
|
||||||
timerType: TimerType;
|
|
||||||
timeWarning: number;
|
|
||||||
timeDanger: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type HandledActions = 'countToEnd' | 'timerType' | 'endAction' | 'timeWarning' | 'timeDanger';
|
type TimeFields = '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 { updateEntry } = useEntryActionsContext();
|
const handleTimeSubmit = (field: TimeFields, value: string) => {
|
||||||
|
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 delayedStart = normaliseWallClock(timeStart + delay);
|
|
||||||
const delayedEnd = normaliseWallClock(timeEnd + delay);
|
|
||||||
const delayLabel = hasDelay
|
|
||||||
? `Event is ${millisToDelayString(delay, 'expanded')}. New schedule ${formatTime(delayedStart)} → ${formatTime(delayedEnd)}`
|
|
||||||
: '';
|
|
||||||
|
|
||||||
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}
|
|
||||||
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}
|
value={endAction ?? null}
|
||||||
|
placeholder={mixedPlaceholder}
|
||||||
onValueChange={(value: EndAction | null) => {
|
onValueChange={(value: EndAction | null) => {
|
||||||
if (value === null) return;
|
if (value === null) return;
|
||||||
handleSubmit('endAction', value);
|
submit({ endAction: value });
|
||||||
}}
|
}}
|
||||||
options={[
|
options={[
|
||||||
{ value: EndAction.None, label: 'None' },
|
{ value: EndAction.None, label: 'None' },
|
||||||
@@ -118,10 +62,11 @@ function EventEditorTimes({
|
|||||||
<Editor.Label className={style.switchLabel}>
|
<Editor.Label className={style.switchLabel}>
|
||||||
<Switch
|
<Switch
|
||||||
id='countToEnd'
|
id='countToEnd'
|
||||||
checked={countToEnd}
|
checked={countToEnd ?? false}
|
||||||
onCheckedChange={(value) => handleSubmit('countToEnd', value)}
|
mixed={countToEnd === undefined}
|
||||||
|
onCheckedChange={(value) => submit({ countToEnd: value })}
|
||||||
/>
|
/>
|
||||||
{countToEnd ? 'On' : 'Off'}
|
{switchLabel(countToEnd)}
|
||||||
</Editor.Label>
|
</Editor.Label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -141,10 +86,11 @@ function EventEditorTimes({
|
|||||||
<div>
|
<div>
|
||||||
<Editor.Label htmlFor='timerType'>Timer Type</Editor.Label>
|
<Editor.Label htmlFor='timerType'>Timer Type</Editor.Label>
|
||||||
<Select
|
<Select
|
||||||
value={timerType}
|
value={timerType ?? null}
|
||||||
|
placeholder={mixedPlaceholder}
|
||||||
onValueChange={(value: TimerType | null) => {
|
onValueChange={(value: TimerType | null) => {
|
||||||
if (value === null) return;
|
if (value === null) return;
|
||||||
handleSubmit('timerType', value);
|
submit({ timerType: value });
|
||||||
}}
|
}}
|
||||||
options={[
|
options={[
|
||||||
{ value: TimerType.CountDown, label: 'Count down' },
|
{ value: TimerType.CountDown, label: 'Count down' },
|
||||||
@@ -161,9 +107,9 @@ function EventEditorTimes({
|
|||||||
<TimeInput
|
<TimeInput
|
||||||
id='timeWarning'
|
id='timeWarning'
|
||||||
name='timeWarning'
|
name='timeWarning'
|
||||||
submitHandler={handleSubmit}
|
submitHandler={handleTimeSubmit}
|
||||||
time={timeWarning}
|
time={timeWarning}
|
||||||
placeholder='Duration'
|
placeholder={timeWarning === undefined ? mixedPlaceholder : 'Duration'}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -171,9 +117,9 @@ function EventEditorTimes({
|
|||||||
<TimeInput
|
<TimeInput
|
||||||
id='timeDanger'
|
id='timeDanger'
|
||||||
name='timeDanger'
|
name='timeDanger'
|
||||||
submitHandler={handleSubmit}
|
submitHandler={handleTimeSubmit}
|
||||||
time={timeDanger}
|
time={timeDanger}
|
||||||
placeholder='Duration'
|
placeholder={timeDanger === undefined ? mixedPlaceholder : 'Duration'}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,34 +1,36 @@
|
|||||||
|
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 { useEntryActionsContext } from '../../../../common/context/EntryActionsContext';
|
import { enDash } from '../../../../common/utils/styleUtils';
|
||||||
|
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 {
|
||||||
eventId: string;
|
/** id of the event being edited, null when editing several events */
|
||||||
|
eventId: string | null;
|
||||||
|
/** amount of events being edited */
|
||||||
|
eventCount: number;
|
||||||
cue: string;
|
cue: string;
|
||||||
flag: boolean;
|
flag: boolean | undefined;
|
||||||
title: string;
|
title: string | undefined;
|
||||||
note: string;
|
note: string | undefined;
|
||||||
colour: string;
|
colour: string | undefined;
|
||||||
|
submit: (patch: Partial<OntimeEvent>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default memo(EventEditorTitles);
|
export default memo(EventEditorTitles);
|
||||||
function EventEditorTitles({ eventId, cue, flag, title, note, colour }: EventEditorTitlesProps) {
|
function EventEditorTitles({ eventId, eventCount, cue, flag, title, note, colour, submit }: EventEditorTitlesProps) {
|
||||||
const { updateEntry } = useEntryActionsContext();
|
const isMulti = eventId === null;
|
||||||
|
|
||||||
const flagSubmitHandler = (newValue: boolean) => {
|
|
||||||
updateEntry({ id: eventId, flag: newValue });
|
|
||||||
};
|
|
||||||
|
|
||||||
const textSubmitHandler = (field: string, newValue: string) => {
|
const textSubmitHandler = (field: string, newValue: string) => {
|
||||||
updateEntry({ id: eventId, [field]: newValue });
|
submit({ [field]: newValue });
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -36,21 +38,39 @@ function EventEditorTitles({ eventId, cue, flag, title, note, colour }: EventEdi
|
|||||||
<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'>Event ID (read only)</Editor.Label>
|
<Editor.Label htmlFor='eventId'>{isMulti ? 'Selection (read only)' : 'Event ID (read only)'}</Editor.Label>
|
||||||
<Input id='eventId' data-testid='input-textfield' value={eventId} readOnly fluid />
|
<Input
|
||||||
|
id='eventId'
|
||||||
|
data-testid='input-textfield'
|
||||||
|
value={isMulti ? `${eventCount} events selected` : eventId}
|
||||||
|
readOnly
|
||||||
|
fluid
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<EntryEditorTextInput
|
{isMulti ? (
|
||||||
field='cue'
|
<div>
|
||||||
label='Cue'
|
<Editor.Label htmlFor='cue'>Cue (not available)</Editor.Label>
|
||||||
initialValue={cue}
|
<Input id='cue' value={enDash} readOnly fluid />
|
||||||
submitHandler={textSubmitHandler}
|
</div>
|
||||||
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 id='flag' checked={flag} onCheckedChange={flagSubmitHandler} />
|
<Switch
|
||||||
{flag ? 'On' : 'Off'}
|
id='flag'
|
||||||
|
checked={flag ?? false}
|
||||||
|
mixed={flag === undefined}
|
||||||
|
onCheckedChange={(newValue) => submit({ flag: newValue })}
|
||||||
|
/>
|
||||||
|
{switchLabel(flag)}
|
||||||
</Editor.Label>
|
</Editor.Label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -58,8 +78,20 @@ function EventEditorTitles({ eventId, cue, flag, title, note, colour }: EventEdi
|
|||||||
<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 field='title' label='Title' initialValue={title} submitHandler={textSubmitHandler} />
|
<EntryEditorTextInput
|
||||||
<EventTextArea field='note' label='Note' initialValue={note} submitHandler={textSubmitHandler} />
|
field='title'
|
||||||
|
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,11 +5,15 @@ 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;
|
||||||
initialValue: string;
|
/** undefined represents values which do not agree across the edited entries */
|
||||||
|
initialValue: string | undefined;
|
||||||
|
placeholder?: string;
|
||||||
style?: CSSProperties;
|
style?: CSSProperties;
|
||||||
submitHandler: (field: EventEditorUpdateFields, value: string) => void;
|
submitHandler: (field: EventEditorUpdateFields, value: string) => void;
|
||||||
}
|
}
|
||||||
@@ -19,6 +23,7 @@ export default function EventTextArea({
|
|||||||
field,
|
field,
|
||||||
label,
|
label,
|
||||||
initialValue,
|
initialValue,
|
||||||
|
placeholder,
|
||||||
style: givenStyles,
|
style: givenStyles,
|
||||||
submitHandler,
|
submitHandler,
|
||||||
}: CountedTextAreaProps) {
|
}: CountedTextAreaProps) {
|
||||||
@@ -29,16 +34,27 @@ 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>
|
||||||
<Editor.Label className={className} htmlFor={field} style={givenStyles}>
|
<div className={style.labelRow}>
|
||||||
{label}
|
<Editor.Label className={className} htmlFor={field} style={givenStyles}>
|
||||||
</Editor.Label>
|
{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,10 +6,13 @@ 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;
|
||||||
initialValue: string;
|
/** undefined represents values which do not agree across the edited entries */
|
||||||
|
initialValue: string | undefined;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
submitHandler: (field: EventEditorUpdateFields, value: string) => void;
|
submitHandler: (field: EventEditorUpdateFields, value: string) => void;
|
||||||
}
|
}
|
||||||
@@ -31,11 +34,21 @@ 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>
|
||||||
<Editor.Label className={className} htmlFor={field} style={givenStyles}>
|
<div className={style.labelRow}>
|
||||||
{label}
|
<Editor.Label className={className} htmlFor={field} style={givenStyles}>
|
||||||
</Editor.Label>
|
{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}
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
/** 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';
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
test('Editing multiple events', async ({ page }) => {
|
||||||
|
await page.goto('/editor');
|
||||||
|
await page.getByRole('button', { name: 'Edit' }).click();
|
||||||
|
|
||||||
|
// clear rundown
|
||||||
|
await page.getByRole('button', { name: 'Rundown menu' }).click();
|
||||||
|
await page.getByRole('menuitem', { name: 'Clear all' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Delete all' }).click();
|
||||||
|
|
||||||
|
// create two events with distinct titles
|
||||||
|
await page.getByRole('button', { name: 'Create Event' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Event', exact: true }).nth(1).click();
|
||||||
|
await expect(page.getByTestId('rundown-event')).toHaveCount(2);
|
||||||
|
|
||||||
|
await page.getByTestId('entry-1').getByTestId('entry__title').fill('first');
|
||||||
|
await page.getByTestId('entry-1').getByTestId('entry__title').press('Enter');
|
||||||
|
await page.getByTestId('entry-2').getByTestId('entry__title').fill('second');
|
||||||
|
await page.getByTestId('entry-2').getByTestId('entry__title').press('Enter');
|
||||||
|
|
||||||
|
const editor = page.getByTestId('editor-container');
|
||||||
|
|
||||||
|
// a single selection shows the full schedule and the event id
|
||||||
|
await page.getByTestId('entry-1').getByTestId('rundown-event').click();
|
||||||
|
await expect(editor.getByTestId('time-input-timeStart')).toBeVisible();
|
||||||
|
await expect(editor.getByLabel('Title', { exact: true })).toHaveValue('first');
|
||||||
|
|
||||||
|
// selecting both events shows a merged view
|
||||||
|
await page
|
||||||
|
.getByTestId('entry-2')
|
||||||
|
.getByTestId('rundown-event')
|
||||||
|
.click({ modifiers: ['Shift'] });
|
||||||
|
await expect(editor.locator('#eventId')).toHaveValue('2 events selected');
|
||||||
|
await expect(editor.getByText('Automations are not available when editing multiple events')).toBeVisible();
|
||||||
|
|
||||||
|
// start and end times are unique to an event, only the duration can be batched
|
||||||
|
await expect(editor.getByTestId('time-input-timeStart')).toBeHidden();
|
||||||
|
await expect(editor.getByTestId('time-input-timeEnd')).toBeHidden();
|
||||||
|
await expect(editor.getByTestId('time-input-duration')).toBeVisible();
|
||||||
|
|
||||||
|
// fields which do not agree are shown as mixed
|
||||||
|
const title = editor.getByLabel('Title', { exact: true });
|
||||||
|
await expect(title).toHaveValue('');
|
||||||
|
await expect(title).toHaveAttribute('placeholder', 'Mixed');
|
||||||
|
|
||||||
|
// leaving a mixed field without editing it does not overwrite the events
|
||||||
|
await title.click();
|
||||||
|
await page.keyboard.press('Tab');
|
||||||
|
await expect(page.getByTestId('entry-1').getByTestId('entry__title')).toHaveValue('first');
|
||||||
|
await expect(page.getByTestId('entry-2').getByTestId('entry__title')).toHaveValue('second');
|
||||||
|
|
||||||
|
// a mixed field cannot be cleared by emptying it, so an explicit action is offered
|
||||||
|
const clearTitle = editor.getByRole('button', { name: 'Clear', exact: true }).first();
|
||||||
|
await expect(clearTitle).toBeVisible();
|
||||||
|
await clearTitle.click();
|
||||||
|
await expect(page.getByTestId('entry-1').getByTestId('entry__title')).toHaveValue('');
|
||||||
|
await expect(page.getByTestId('entry-2').getByTestId('entry__title')).toHaveValue('');
|
||||||
|
|
||||||
|
// the events now agree, so the field can be cleared by hand and the action is withdrawn
|
||||||
|
await expect(editor.getByLabel('Title', { exact: true })).not.toHaveAttribute('placeholder', 'Mixed');
|
||||||
|
|
||||||
|
// editing a field applies it to the whole selection
|
||||||
|
await title.fill('shared title');
|
||||||
|
await title.press('Enter');
|
||||||
|
await expect(page.getByTestId('entry-1').getByTestId('entry__title')).toHaveValue('shared title');
|
||||||
|
await expect(page.getByTestId('entry-2').getByTestId('entry__title')).toHaveValue('shared title');
|
||||||
|
|
||||||
|
// the value is no longer mixed
|
||||||
|
await expect(editor.getByLabel('Title', { exact: true })).toHaveValue('shared title');
|
||||||
|
|
||||||
|
// a batched duration is applied to every event and the rundown is recalculated once
|
||||||
|
const duration = editor.getByTestId('time-input-duration');
|
||||||
|
await duration.click();
|
||||||
|
await duration.fill('5m');
|
||||||
|
await duration.press('Enter');
|
||||||
|
await expect(page.getByTestId('entry-1').getByTestId('time-input-duration')).toHaveValue('00:05:00');
|
||||||
|
await expect(page.getByTestId('entry-2').getByTestId('time-input-duration')).toHaveValue('00:05:00');
|
||||||
|
// the second event is linked, so its start follows the new end of the first
|
||||||
|
await expect(page.getByTestId('entry-2').getByTestId('time-input-timeStart')).toHaveValue('00:05:00');
|
||||||
|
await expect(page.getByTestId('entry-2').getByTestId('time-input-timeEnd')).toHaveValue('00:10:00');
|
||||||
|
|
||||||
|
// going back to a single selection restores the full editor
|
||||||
|
await page.getByTestId('entry-1').getByTestId('rundown-event').click();
|
||||||
|
await expect(editor.getByTestId('time-input-timeStart')).toBeVisible();
|
||||||
|
await expect(editor.locator('#eventId')).not.toHaveValue('2 events selected');
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user