feat: edit groups

This commit is contained in:
Carlos Valente
2025-06-25 06:10:12 +02:00
committed by Carlos Valente
parent 4c08482258
commit 473f50b493
75 changed files with 916 additions and 511 deletions
@@ -0,0 +1,71 @@
import { CSSProperties, Fragment } from 'react';
import { CustomFields, OntimeBlock, OntimeEvent } from 'ontime-types';
import { getAccessibleColour } from '../../../../common/utils/styleUtils';
import { EventEditorUpdateFields } from '../EventEditor';
import EventEditorImage from './EventEditorImage';
import EventTextArea from './EventTextArea';
import EntryEditorTextInput from './EventTextInput';
import style from '../EntryEditor.module.scss';
interface EntryEditorCustomFieldsProps {
fields: CustomFields;
event: OntimeEvent | OntimeBlock;
handleSubmit: (field: EventEditorUpdateFields, value: string) => void;
}
export default function EntryEditorCustomFields({
fields: customFields,
handleSubmit,
event,
}: EntryEditorCustomFieldsProps) {
return (
<Fragment>
{Object.keys(customFields).map((fieldKey) => {
const key = `${event.id}-${fieldKey}`;
const fieldName = `custom-${fieldKey}`;
const initialValue = event.custom[fieldKey] ?? '';
const { backgroundColor, color } = getAccessibleColour(customFields[fieldKey].colour);
const labelText = customFields[fieldKey].label;
if (customFields[fieldKey].type === 'string') {
return (
<EventTextArea
key={key}
field={fieldName}
label={labelText}
initialValue={initialValue}
submitHandler={handleSubmit}
className={style.decorated}
style={{ '--decorator-bg': backgroundColor, '--decorator-color': color } as CSSProperties}
/>
);
}
if (customFields[fieldKey].type === 'image') {
return (
<div key={key} className={style.customImage}>
<EntryEditorTextInput
key={key}
field={fieldName}
label={labelText}
initialValue={initialValue}
placeholder='Paste image URL'
submitHandler={handleSubmit}
className={style.decorated}
maxLength={255}
style={{ '--decorator-bg': backgroundColor, '--decorator-color': color } as CSSProperties}
/>
<EventEditorImage src={initialValue} />
</div>
);
}
// we should have exhausted all types by now
return null;
})}
</Fragment>
);
}
@@ -0,0 +1,7 @@
.footer {
border-top: 1px solid $white-10;
padding-top: 1rem;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
@@ -0,0 +1,27 @@
import { memo } from 'react';
import CopyTag from '../../../../common/components/copy-tag/CopyTag';
import style from './EventEditorFooter.module.scss';
interface EventEditorFooterProps {
id: string;
cue: string;
}
export default memo(EventEditorFooter);
function EventEditorFooter({ id, cue }: EventEditorFooterProps) {
const loadById = `/ontime/load/id "${id}"`;
const loadByCue = `/ontime/load/cue "${cue}"`;
return (
<div className={style.footer}>
<CopyTag copyValue={loadById} label='OSC trigger by ID'>
{loadById}
</CopyTag>
<CopyTag copyValue={loadByCue} label='OSC trigger by cue'>
{loadByCue}
</CopyTag>
</div>
);
}
@@ -0,0 +1,12 @@
.imageContainer {
width: 100%;
height: 100%;
background-color: $gray-1250;
display: grid;
place-content: center;
}
.imageOverlay {
width: 100%;
height: 100%;
}
@@ -0,0 +1,14 @@
import style from './EventEditorImage.module.scss';
interface EventEditorImageProps {
src: string;
}
export default function EventEditorImage({ src }: EventEditorImageProps) {
return (
<div className={style.imageContainer}>
<img loading='lazy' src={src} />
<div className={style.imageOverlay} />
</div>
);
}
@@ -0,0 +1,177 @@
import { memo } from 'react';
import { IoInformationCircle } from 'react-icons/io5';
import { Tooltip } from '@chakra-ui/react';
import { EndAction, TimerType, TimeStrategy } from 'ontime-types';
import { millisToString, parseUserTime } from 'ontime-utils';
import * as Editor from '../../../../common/components/editor-utils/EditorUtils';
import TimeInput from '../../../../common/components/input/time-input/TimeInput';
import Select from '../../../../common/components/select/Select';
import Switch from '../../../../common/components/switch/Switch';
import { useEntryActions } from '../../../../common/hooks/useEntryAction';
import { millisToDelayString } from '../../../../common/utils/dateConfig';
import TimeInputFlow from '../../time-input-flow/TimeInputFlow';
import style from '../EntryEditor.module.scss';
interface EventEditorTimesProps {
eventId: string;
timeStart: number;
timeEnd: number;
duration: number;
timeStrategy: TimeStrategy;
linkStart: boolean;
countToEnd: boolean;
delay: number;
endAction: EndAction;
timerType: TimerType;
timeWarning: number;
timeDanger: number;
}
type HandledActions = 'countToEnd' | 'timerType' | 'endAction' | 'timeWarning' | 'timeDanger';
export default memo(EventEditorTimes);
function EventEditorTimes({
eventId,
timeStart,
timeEnd,
duration,
timeStrategy,
linkStart,
countToEnd,
delay,
endAction,
timerType,
timeWarning,
timeDanger,
}: EventEditorTimesProps) {
const { updateEntry } = useEntryActions();
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 ${millisToString(
timeStart + delay,
)}${millisToString(timeEnd + delay)}`
: '';
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}>
<Editor.Title>Event Behaviour</Editor.Title>
<div className={style.splitTwo}>
<div>
<Editor.Label htmlFor='endAction'>End Action</Editor.Label>
<Select
value={endAction}
onChange={(value) => handleSubmit('endAction', value)}
options={[
{ value: EndAction.None, label: 'None' },
{ value: EndAction.LoadNext, label: 'Load next event' },
{ value: EndAction.PlayNext, label: 'Play next event' },
]}
/>
</div>
<div>
<Editor.Label htmlFor='countToEnd'>Count to End</Editor.Label>
<Editor.Label className={style.switchLabel}>
<Switch
id='countToEnd'
checked={countToEnd}
onCheckedChange={(value) => handleSubmit('countToEnd', value)}
/>
{countToEnd ? 'On' : 'Off'}
</Editor.Label>
</div>
</div>
</div>
<div className={style.column}>
<Editor.Title>
<Tooltip label='Changes how the timer is displayed in different views. It is not reflected in the rundown'>
<span>
Display Options
<IoInformationCircle className={style.tooltipIcon} />
</span>
</Tooltip>
</Editor.Title>
<div className={style.splitTwo}>
<div>
<Editor.Label htmlFor='timerType'>Timer Type</Editor.Label>
<Select
value={timerType}
onChange={(value) => handleSubmit('timerType', value)}
options={[
{ value: TimerType.CountDown, label: 'Count down' },
{ value: TimerType.CountUp, label: 'Count up' },
{ value: TimerType.Clock, label: 'Clock' },
{ value: TimerType.None, label: 'None' },
]}
/>
</div>
<div className={style.inline}>
<div>
<Editor.Label htmlFor='timeWarning'>Warning Time</Editor.Label>
<TimeInput
id='timeWarning'
name='timeWarning'
submitHandler={handleSubmit}
time={timeWarning}
placeholder='Duration'
/>
</div>
<div>
<Editor.Label htmlFor='timeDanger'>Danger Time</Editor.Label>
<TimeInput
id='timeDanger'
name='timeDanger'
submitHandler={handleSubmit}
time={timeDanger}
placeholder='Duration'
/>
</div>
</div>
</div>
</div>
</>
);
}
@@ -0,0 +1,53 @@
import { memo } from 'react';
import { sanitiseCue } from 'ontime-utils';
import * as Editor from '../../../../common/components/editor-utils/EditorUtils';
import SwatchSelect from '../../../../common/components/input/colour-input/SwatchSelect';
import Input from '../../../../common/components/input/input/Input';
import { type EventEditorUpdateFields } from '../EventEditor';
import EventTextArea from './EventTextArea';
import EntryEditorTextInput from './EventTextInput';
import style from '../EntryEditor.module.scss';
interface EventEditorTitlesProps {
eventId: string;
cue: string;
title: string;
note: string;
colour: string;
handleSubmit: (field: EventEditorUpdateFields, value: string) => void;
}
export default memo(EventEditorTitles);
function EventEditorTitles({ eventId, cue, title, note, colour, handleSubmit }: EventEditorTitlesProps) {
const cueSubmitHandler = (_field: string, newValue: string) => {
handleSubmit('cue', sanitiseCue(newValue));
};
return (
<div className={style.column}>
<Editor.Title>Event Data</Editor.Title>
<div className={style.splitTwo}>
<div>
<Editor.Label htmlFor='eventId'>Event ID (read only)</Editor.Label>
<Input id='eventId' data-testid='input-textfield' value={eventId} readOnly fluid />
</div>
<EntryEditorTextInput
field='cue'
label='Cue'
initialValue={cue}
submitHandler={cueSubmitHandler}
maxLength={10}
/>
</div>
<div>
<Editor.Label>Colour</Editor.Label>
<SwatchSelect name='colour' value={colour} handleChange={handleSubmit} />
</div>
<EntryEditorTextInput field='title' label='Title' initialValue={title} submitHandler={handleSubmit} />
<EventTextArea field='note' label='Note' initialValue={note} submitHandler={handleSubmit} />
</div>
);
}
@@ -0,0 +1,30 @@
.triggerForm {
padding-block: 0.5rem;
display: grid;
grid-template-columns: 1fr 1fr auto auto;
gap: 0.5rem;
align-items: center;
}
.trigger {
padding: 0.25rem 0.5rem;
display: grid;
grid-template-columns: 1fr 1fr auto;
align-items: center;
&:nth-child(even) {
background-color: $white-1;
}
& > span {
width: fit-content;
}
}
.errorLabel {
color: $red-500;
}
.success {
color: $green-500;
}
@@ -0,0 +1,150 @@
import { Fragment, useCallback, useState } from 'react';
import { IoAlertCircle, IoCheckmarkCircle, IoTrash } from 'react-icons/io5';
import { Tooltip } from '@chakra-ui/react';
import { TimerLifeCycle, timerLifecycleValues, Trigger } from 'ontime-types';
import { generateId } from 'ontime-utils';
import Button from '../../../../common/components/buttons/Button';
import IconButton from '../../../../common/components/buttons/IconButton';
import Select from '../../../../common/components/select/Select';
import Tag from '../../../../common/components/tag/Tag';
import { useEntryActions } from '../../../../common/hooks/useEntryAction';
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
import { eventTriggerOptions } from './eventTrigger.constants';
import style from './EventEditorTriggers.module.scss';
interface EventEditorTriggersProps {
eventId: string;
triggers: Trigger[];
}
export default function EventEditorTriggers({ triggers, eventId }: EventEditorTriggersProps) {
const showTriggers = triggers.length > 0;
return (
<>
{showTriggers && <ExistingEventTriggers triggers={triggers} eventId={eventId} />}
<EventTriggerForm triggers={triggers} eventId={eventId} />
</>
);
}
interface EventTriggerFormProps {
eventId: string;
triggers?: Trigger[];
}
function EventTriggerForm({ eventId, triggers }: EventTriggerFormProps) {
const { data: automationSettings } = useAutomationSettings();
const { updateEntry } = useEntryActions();
const [automationId, setAutomationId] = useState<string | undefined>(undefined);
const [cycleValue, setCycleValue] = useState(TimerLifeCycle.onStart);
const handleSubmit = (triggerLifeCycle: TimerLifeCycle, automationId: string) => {
const newTriggers = triggers ?? new Array<Trigger>();
const id = generateId();
newTriggers.push({ id, title: '', trigger: triggerLifeCycle, automationId });
updateEntry({ id: eventId, triggers: newTriggers });
};
const getValidationError = (cycle: TimerLifeCycle, automationId?: string): string | undefined => {
if (automationId === undefined) {
return 'Select an automation';
}
if (!Object.keys(automationSettings.automations).includes(automationId)) {
return 'This automation does not exist';
}
if (triggers === undefined) {
return;
}
return Object.values(triggers).some((t) => t.automationId === automationId && t.trigger === cycle)
? 'Automation can only be used once'
: undefined;
};
const validationError = getValidationError(cycleValue, automationId);
return (
<div className={style.triggerForm}>
<Select
value={cycleValue}
placeholder='Choose a trigger'
onChange={(value) => setCycleValue(value)}
options={eventTriggerOptions.map((cycle) => ({ value: cycle, label: cycle }))}
/>
<Select
value={automationId}
placeholder='Choose an automation'
onChange={(value) => setAutomationId(value)}
options={Object.values(automationSettings.automations).map(({ id, title }) => ({ value: id, label: title }))}
/>
<Button
disabled={validationError !== undefined}
onClick={() => automationId && handleSubmit(cycleValue, automationId)}
>
Add
</Button>
{validationError !== undefined ? (
<Tooltip label={validationError} shouldWrapChildren>
<IoAlertCircle className={style.errorLabel} />
</Tooltip>
) : (
<IoCheckmarkCircle className={style.success} />
)}
</div>
);
}
interface ExistingEventTriggersProps {
eventId: string;
triggers: Trigger[];
}
function ExistingEventTriggers({ eventId, triggers }: ExistingEventTriggersProps) {
const { updateEntry } = useEntryActions();
const { data: automationSettings } = useAutomationSettings();
const handleDelete = useCallback(
(triggerId: string) => {
const newTriggers = triggers.filter((trigger) => trigger.id !== triggerId);
updateEntry({ id: eventId, triggers: newTriggers });
},
[eventId, triggers, updateEntry],
);
const filteredTriggers: Record<string, Trigger[]> = {};
// sort triggers out into groups by the Lifecycle they are on
timerLifecycleValues.forEach((triggerType) => {
const thisTriggerType = triggers.filter((trigger) => trigger.trigger === triggerType);
if (thisTriggerType.length) {
Object.assign(filteredTriggers, { [triggerType]: thisTriggerType });
}
});
return (
<div>
{Object.entries(filteredTriggers).map(([triggerLifeCycle, triggerGroup]) => (
<Fragment key={triggerLifeCycle}>
{triggerGroup.map((trigger) => {
const { id, automationId } = trigger;
const automationTitle = automationSettings.automations[automationId]?.title ?? '<MISSING AUTOMATION>';
return (
<div key={id} className={style.trigger}>
<Tag>{triggerLifeCycle}</Tag>
<Tag>{automationTitle}</Tag>
<IconButton variant='subtle-destructive' onClick={() => handleDelete(id)}>
<IoTrash />
</IconButton>
</div>
);
})}
</Fragment>
))}
</div>
);
}
@@ -0,0 +1,52 @@
import { type CSSProperties, useCallback, useRef } from 'react';
import * as Editor from '../../../../common/components/editor-utils/EditorUtils';
import { AutoTextArea } from '../../../../common/components/input/auto-text-area/AutoTextArea';
import useReactiveTextInput from '../../../../common/components/input/text-input/useReactiveTextInput';
import { EventEditorUpdateFields } from '../EventEditor';
interface CountedTextAreaProps {
className?: string;
field: EventEditorUpdateFields;
label: string;
initialValue: string;
style?: CSSProperties;
submitHandler: (field: EventEditorUpdateFields, value: string) => void;
}
export default function EventTextArea({
className,
field,
label,
initialValue,
style: givenStyles,
submitHandler,
}: CountedTextAreaProps) {
const ref = useRef<HTMLInputElement | null>(null);
const submitCallback = useCallback((newValue: string) => submitHandler(field, newValue), [field, submitHandler]);
const { value, onChange, onBlur, onKeyDown } = useReactiveTextInput(initialValue, submitCallback, ref, {
submitOnCtrlEnter: true,
});
return (
<div>
<Editor.Label className={className} htmlFor={field} style={givenStyles}>
{label}
</Editor.Label>
<AutoTextArea
id={field}
inputref={ref}
rows={1}
size='sm'
resize='none'
variant='ontime-filled'
data-testid='input-textarea'
value={value}
onChange={onChange}
onBlur={onBlur}
onKeyDown={onKeyDown}
/>
</div>
);
}
@@ -0,0 +1,53 @@
import { useCallback, useRef } from 'react';
import * as Editor from '../../../../common/components/editor-utils/EditorUtils';
import Input, { type InputProps } from '../../../../common/components/input/input/Input';
import useReactiveTextInput from '../../../../common/components/input/text-input/useReactiveTextInput';
import { BlockEditorUpdateTextFields } from '../BlockEditor';
import { EventEditorUpdateFields } from '../EventEditor';
interface EntryEditorTextInputProps extends InputProps {
field: EventEditorUpdateFields | BlockEditorUpdateTextFields;
label: string;
initialValue: string;
placeholder?: string;
submitHandler: (field: EventEditorUpdateFields, value: string) => void;
}
export default function EntryEditorTextInput({
className,
field,
label,
initialValue,
style: givenStyles,
submitHandler,
maxLength,
placeholder,
}: EntryEditorTextInputProps) {
const ref = useRef<HTMLInputElement | null>(null);
const submitCallback = useCallback((newValue: string) => submitHandler(field, newValue), [field, submitHandler]);
const { value, onChange, onBlur, onKeyDown } = useReactiveTextInput(initialValue, submitCallback, ref, {
submitOnEnter: true,
});
return (
<div>
<Editor.Label className={className} htmlFor={field} style={givenStyles}>
{label}
</Editor.Label>
<Input
id={field}
ref={ref}
maxLength={maxLength}
fluid
data-testid='input-textfield'
value={value}
placeholder={placeholder}
onChange={onChange}
onBlur={onBlur}
onKeyDown={onKeyDown}
/>
</div>
);
}
@@ -0,0 +1,10 @@
import { TimerLifeCycle } from 'ontime-types';
export const eventTriggerOptions: TimerLifeCycle[] = [
TimerLifeCycle.onLoad,
TimerLifeCycle.onStart,
TimerLifeCycle.onPause,
TimerLifeCycle.onFinish,
TimerLifeCycle.onWarning,
TimerLifeCycle.onDanger,
];