mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-12 10:53:51 +00:00
refactor: UI for linking events (#763)
This commit is contained in:
@@ -17,7 +17,7 @@ export default function DelayIndicator(props: DelayIndicatorProps) {
|
||||
if (typeof delayValue === 'number') {
|
||||
if (delayValue < 0) {
|
||||
return (
|
||||
<Tooltip openDelay={tooltipDelayFast} label={millisToDelayString(delayValue)}>
|
||||
<Tooltip openDelay={tooltipDelayFast} label={millisToDelayString(delayValue, true)}>
|
||||
<span className={style.delaySymbol}>
|
||||
<IoChevronDown />
|
||||
</span>
|
||||
@@ -27,7 +27,7 @@ export default function DelayIndicator(props: DelayIndicatorProps) {
|
||||
|
||||
if (delayValue > 0) {
|
||||
return (
|
||||
<Tooltip openDelay={tooltipDelayFast} label={millisToDelayString(delayValue)}>
|
||||
<Tooltip openDelay={tooltipDelayFast} label={millisToDelayString(delayValue, true)}>
|
||||
<span className={style.delaySymbol}>
|
||||
<IoChevronUp />
|
||||
</span>
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
$input-font-size: 15px;
|
||||
$input-delayed-border-color: #E69056;
|
||||
|
||||
.timeInput {
|
||||
font-size: $input-font-size;
|
||||
letter-spacing: 1px;
|
||||
width: 6.5em;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { millisToString } from 'ontime-utils';
|
||||
|
||||
import { useEmitLog } from '../../../stores/logger';
|
||||
import { forgivingStringToMillis } from '../../../utils/dateConfig';
|
||||
import { cx } from '../../../utils/styleUtils';
|
||||
|
||||
import style from './TimeInput.module.scss';
|
||||
interface TimeInputProps<T extends string> {
|
||||
@@ -11,11 +12,12 @@ interface TimeInputProps<T extends string> {
|
||||
submitHandler: (field: T, value: string) => void;
|
||||
time?: number;
|
||||
placeholder: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
|
||||
const { name, submitHandler, time = 0, placeholder, className } = props;
|
||||
const { name, submitHandler, time = 0, placeholder, disabled, className } = props;
|
||||
const { emitError } = useEmitLog();
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [value, setValue] = useState<string>('');
|
||||
@@ -118,14 +120,16 @@ export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
|
||||
resetValue();
|
||||
}, [resetValue, time]);
|
||||
|
||||
const timeInputClass = className ? className : style.timeInput;
|
||||
const timeInputClasses = cx([style.timeInput, className]);
|
||||
|
||||
return (
|
||||
<Input
|
||||
disabled={disabled}
|
||||
size='sm'
|
||||
ref={inputRef}
|
||||
data-testid={`time-input-${name}`}
|
||||
className={timeInputClass}
|
||||
className={timeInputClasses}
|
||||
fontSize='1rem'
|
||||
type='text'
|
||||
placeholder={placeholder}
|
||||
variant='ontime-filled'
|
||||
|
||||
@@ -1,24 +1,12 @@
|
||||
$input-font-size: 1rem;
|
||||
$input-delayed-border-color: $ontime-delay-text;
|
||||
|
||||
.timeInput {
|
||||
border: 1px solid transparent;
|
||||
&.delayed {
|
||||
border: 1px solid $input-delayed-border-color;
|
||||
}
|
||||
|
||||
.inputLeft,
|
||||
.inputButton {
|
||||
aspect-ratio: 1;
|
||||
}
|
||||
|
||||
.inputField {
|
||||
font-size: $input-font-size;
|
||||
letter-spacing: 1px;
|
||||
width: 7.5em;
|
||||
padding: 0 0 0 2.6em;
|
||||
}
|
||||
|
||||
.inputButton {
|
||||
border-radius: 2px 0 0 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.inputField {
|
||||
max-width: 7.75em;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Button, InputGroup, InputLeftElement, Tooltip } from '@chakra-ui/react';
|
||||
import { PropsWithChildren } from 'react';
|
||||
import { InputGroup, Tooltip } from '@chakra-ui/react';
|
||||
|
||||
import { tooltipDelayFast } from '../../../../ontimeConfig';
|
||||
import { cx } from '../../../utils/styleUtils';
|
||||
@@ -12,30 +13,28 @@ interface TimeInputWithButtonProps<T extends string> {
|
||||
submitHandler: (field: T, value: string) => void;
|
||||
time?: number;
|
||||
hasDelay?: boolean;
|
||||
disabled?: boolean;
|
||||
placeholder: string;
|
||||
}
|
||||
|
||||
export default function TimeInputWithButton<T extends string>(props: TimeInputWithButtonProps<T>) {
|
||||
const { name, submitHandler, time, hasDelay, placeholder } = props;
|
||||
export default function TimeInputWithButton<T extends string>(props: PropsWithChildren<TimeInputWithButtonProps<T>>) {
|
||||
const { name, submitHandler, time, hasDelay, placeholder, disabled, children } = props;
|
||||
|
||||
const inputClasses = cx([style.timeInput, hasDelay ? style.delayed : null]);
|
||||
|
||||
return (
|
||||
<InputGroup size='sm' className={inputClasses} width='fit-content'>
|
||||
<InputLeftElement className={style.inputLeft}>
|
||||
<Tooltip label={placeholder} openDelay={tooltipDelayFast} variant='ontime-ondark'>
|
||||
<Button size='sm' variant='ontime-subtle-white' className={style.inputButton} tabIndex={-1}>
|
||||
{placeholder.charAt(0)}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</InputLeftElement>
|
||||
<TimeInput<T>
|
||||
name={name}
|
||||
submitHandler={submitHandler}
|
||||
time={time}
|
||||
placeholder={placeholder}
|
||||
className={style.inputField}
|
||||
/>
|
||||
<Tooltip label={placeholder} openDelay={tooltipDelayFast} variant='ontime-ondark'>
|
||||
<TimeInput<T>
|
||||
name={name}
|
||||
submitHandler={submitHandler}
|
||||
time={time}
|
||||
placeholder={placeholder}
|
||||
className={style.inputField}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Tooltip>
|
||||
{children}
|
||||
</InputGroup>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { isOntimeEvent, OntimeEvent, OntimeRundownEntry, RundownCached } from 'ontime-types';
|
||||
import { reorderArray, swapEventData } from 'ontime-utils';
|
||||
import { isOntimeEvent, MaybeString, OntimeEvent, OntimeRundownEntry, RundownCached } from 'ontime-types';
|
||||
import { getLinkedTimes, getPreviousEventNormal, reorderArray, swapEventData } from 'ontime-utils';
|
||||
|
||||
import { RUNDOWN } from '../api/apiConstants';
|
||||
import { logAxiosError } from '../api/apiUtils';
|
||||
@@ -186,6 +186,7 @@ export const useEventAction = () => {
|
||||
|
||||
// check for adding time keyword
|
||||
} else if (value.startsWith('+') || value.startsWith('p+') || value.startsWith('p +')) {
|
||||
// TODO: is this logic solid?
|
||||
const remainingString = value.substring(1);
|
||||
newValMillis = getPreviousEnd() + forgivingStringToMillis(remainingString);
|
||||
} else {
|
||||
@@ -205,6 +206,44 @@ export const useEventAction = () => {
|
||||
[_updateEventMutation, queryClient],
|
||||
);
|
||||
|
||||
/**
|
||||
* Toggles link of an event to the previous
|
||||
*/
|
||||
const linkTimer = useCallback(
|
||||
async (eventId: string, linkStart: MaybeString) => {
|
||||
let newEvent: Partial<OntimeEvent> = { id: eventId };
|
||||
|
||||
if (!linkStart) {
|
||||
newEvent.linkStart = null;
|
||||
} else {
|
||||
const cachedRundown = queryClient.getQueryData<RundownCached>(RUNDOWN);
|
||||
if (!cachedRundown) {
|
||||
return;
|
||||
}
|
||||
const currentEvent = cachedRundown.rundown[eventId] as OntimeEvent;
|
||||
if (!isOntimeEvent(currentEvent)) {
|
||||
return;
|
||||
}
|
||||
const { previousEvent } = getPreviousEventNormal(cachedRundown.rundown, cachedRundown.order, eventId);
|
||||
|
||||
if (!previousEvent) {
|
||||
newEvent.linkStart = null;
|
||||
} else {
|
||||
newEvent.linkStart = previousEvent.id;
|
||||
const timePatch = getLinkedTimes(currentEvent, previousEvent);
|
||||
newEvent = { ...newEvent, ...timePatch };
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await _updateEventMutation.mutateAsync(newEvent);
|
||||
} catch (error) {
|
||||
logAxiosError('Error updating event', error);
|
||||
}
|
||||
},
|
||||
[_updateEventMutation, queryClient],
|
||||
);
|
||||
|
||||
/**
|
||||
* Calls mutation to edit multiple events
|
||||
* @private
|
||||
@@ -509,6 +548,7 @@ export const useEventAction = () => {
|
||||
batchUpdateEvents,
|
||||
deleteEvent,
|
||||
deleteAllEvents,
|
||||
linkTimer,
|
||||
reorderEvent,
|
||||
swapEvents,
|
||||
updateEvent,
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import {
|
||||
forgivingStringToMillis,
|
||||
millisToDelayString,
|
||||
} from '../dateConfig';
|
||||
import { forgivingStringToMillis, millisToDelayString } from '../dateConfig';
|
||||
|
||||
describe('test forgivingStringToMillis()', () => {
|
||||
describe('function handles time with no separators', () => {
|
||||
@@ -270,10 +267,10 @@ describe('millisToDelayString()', () => {
|
||||
});
|
||||
describe('converts values in seconds', () => {
|
||||
it('shows a simple string with value in seconds', () => {
|
||||
expect(millisToDelayString(10000)).toBe('+10 sec');
|
||||
expect(millisToDelayString(10000, true)).toBe('+10 sec');
|
||||
});
|
||||
it('... and its negative counterpart', () => {
|
||||
expect(millisToDelayString(-10000)).toBe('-10 sec');
|
||||
expect(millisToDelayString(-10000, true)).toBe('-10 sec');
|
||||
});
|
||||
|
||||
const underAMinute = [1, 500, 1000, 6000, 55000, 59999];
|
||||
@@ -287,32 +284,32 @@ describe('millisToDelayString()', () => {
|
||||
|
||||
describe('converts values in minutes', () => {
|
||||
it('shows a simple string with value in minutes', () => {
|
||||
expect(millisToDelayString(720000)).toBe('+12 min');
|
||||
expect(millisToDelayString(720000, true)).toBe('+12 min');
|
||||
});
|
||||
it('... and its negative counterpart', () => {
|
||||
expect(millisToDelayString(-720000)).toBe('-12 min');
|
||||
expect(millisToDelayString(-720000, true)).toBe('-12 min');
|
||||
});
|
||||
it('shows a simple string with value in minutes and seconds', () => {
|
||||
expect(millisToDelayString(630000)).toBe('+00:10:30');
|
||||
expect(millisToDelayString(630000, true)).toBe('+00:10:30');
|
||||
});
|
||||
it('... and its negative counterpart', () => {
|
||||
expect(millisToDelayString(-630000)).toBe('-00:10:30');
|
||||
expect(millisToDelayString(-630000, true)).toBe('-00:10:30');
|
||||
});
|
||||
|
||||
const underAnHour = [60000, 360000, 720000];
|
||||
underAnHour.forEach((value) => {
|
||||
it(`handles ${value}`, () => {
|
||||
expect(millisToDelayString(value)?.endsWith('min')).toBe(true);
|
||||
expect(millisToDelayString(value, true)?.endsWith('min')).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('converts values with full time string', () => {
|
||||
it('positive added time', () => {
|
||||
expect(millisToDelayString(45015000)).toBe('+12:30:15');
|
||||
expect(millisToDelayString(45015000, true)).toBe('+12:30:15');
|
||||
});
|
||||
it('negative added time', () => {
|
||||
expect(millisToDelayString(-45015000)).toBe('-12:30:15');
|
||||
expect(millisToDelayString(-45015000, true)).toBe('-12:30:15');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -155,19 +155,21 @@ export const forgivingStringToMillis = (value: string): number => {
|
||||
return millis;
|
||||
};
|
||||
|
||||
export function millisToDelayString(millis: number | null): undefined | string | null {
|
||||
export function millisToDelayString(millis: number | null, small = false): undefined | string | null {
|
||||
if (millis == null || millis === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isNegative = millis < 0;
|
||||
const absMillis = Math.abs(millis);
|
||||
const delayed = small ? '+' : 'delayed by ';
|
||||
const ahead = small ? '-' : 'ahead by ';
|
||||
|
||||
if (absMillis < MILLIS_PER_MINUTE) {
|
||||
return `${isNegative ? '-' : '+'}${formatFromMillis(absMillis, 's')} sec`;
|
||||
return `${isNegative ? ahead : delayed}${formatFromMillis(absMillis, 's')} sec`;
|
||||
} else if (absMillis < MILLIS_PER_HOUR && absMillis % MILLIS_PER_MINUTE === 0) {
|
||||
return `${isNegative ? '-' : '+'}${formatFromMillis(absMillis, 'm')} min`;
|
||||
return `${isNegative ? ahead : delayed}${formatFromMillis(absMillis, 'm')} min`;
|
||||
} else {
|
||||
return `${isNegative ? '-' : '+'}${formatFromMillis(absMillis, 'HH:mm:ss')}`;
|
||||
return `${isNegative ? ahead : delayed}${formatFromMillis(absMillis, 'HH:mm:ss')}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ export const cloneEvent = (event: OntimeEvent, after?: string): ClonedEvent => {
|
||||
duration: event.duration,
|
||||
timeEnd: event.timeEnd,
|
||||
timerType: event.timerType,
|
||||
timeStrategy: event.timeStrategy,
|
||||
linkStart: event.linkStart,
|
||||
endAction: event.endAction,
|
||||
isPublic: event.isPublic,
|
||||
skip: event.skip,
|
||||
|
||||
@@ -25,7 +25,8 @@ $table-header-font-size: calc(1rem - 3px);
|
||||
display: flex;
|
||||
}
|
||||
|
||||
th, td {
|
||||
th,
|
||||
td {
|
||||
margin: 1px;
|
||||
font-weight: inherit;
|
||||
font-size: inherit;
|
||||
@@ -102,6 +103,9 @@ $table-header-font-size: calc(1rem - 3px);
|
||||
position: sticky;
|
||||
left: 47.5%; // center of the screen, ish
|
||||
padding: 0.5rem 0;
|
||||
&:first-letter {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -126,6 +126,8 @@ export default function RundownEntry(props: RundownEntryProps) {
|
||||
timeStart={data.timeStart}
|
||||
timeEnd={data.timeEnd}
|
||||
duration={data.duration}
|
||||
timeStrategy={data.timeStrategy}
|
||||
linkStart={data.linkStart}
|
||||
eventId={data.id}
|
||||
isPublic={data.isPublic}
|
||||
endAction={data.endAction}
|
||||
|
||||
@@ -125,12 +125,6 @@ $skip-opacity: 0.1;
|
||||
display: flex;
|
||||
gap: $block-clearance;
|
||||
height: 100%;
|
||||
|
||||
.timerNote {
|
||||
color: $blue-500;
|
||||
margin-right: $block-clearance;
|
||||
font-size: 1.5em;
|
||||
}
|
||||
}
|
||||
|
||||
.eventTitle {
|
||||
@@ -143,6 +137,7 @@ $skip-opacity: 0.1;
|
||||
.eventActions {
|
||||
grid-area: actions;
|
||||
height: 100%;
|
||||
text-align: end;
|
||||
}
|
||||
|
||||
.progressBg {
|
||||
|
||||
@@ -7,7 +7,7 @@ import { IoPeople } from '@react-icons/all-files/io5/IoPeople';
|
||||
import { IoPeopleOutline } from '@react-icons/all-files/io5/IoPeopleOutline';
|
||||
import { IoReorderTwo } from '@react-icons/all-files/io5/IoReorderTwo';
|
||||
import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical';
|
||||
import { EndAction, MaybeNumber, OntimeEvent, Playback, TimerType } from 'ontime-types';
|
||||
import { EndAction, MaybeNumber, MaybeString, OntimeEvent, Playback, TimerType, TimeStrategy } from 'ontime-types';
|
||||
|
||||
import { useContextMenu } from '../../../common/hooks/useContextMenu';
|
||||
import copyToClipboard from '../../../common/utils/copyToClipboard';
|
||||
@@ -26,6 +26,8 @@ interface EventBlockProps {
|
||||
timeStart: number;
|
||||
timeEnd: number;
|
||||
duration: number;
|
||||
timeStrategy: TimeStrategy;
|
||||
linkStart: MaybeString;
|
||||
eventId: string;
|
||||
eventIndex: number;
|
||||
isPublic: boolean;
|
||||
@@ -61,6 +63,8 @@ export default function EventBlock(props: EventBlockProps) {
|
||||
timeStart,
|
||||
timeEnd,
|
||||
duration,
|
||||
timeStrategy,
|
||||
linkStart,
|
||||
isPublic = true,
|
||||
eventIndex,
|
||||
endAction,
|
||||
@@ -251,6 +255,8 @@ export default function EventBlock(props: EventBlockProps) {
|
||||
timeStart={timeStart}
|
||||
timeEnd={timeEnd}
|
||||
duration={duration}
|
||||
linkStart={linkStart}
|
||||
timeStrategy={timeStrategy}
|
||||
eventId={eventId}
|
||||
eventIndex={eventIndex}
|
||||
isPublic={isPublic}
|
||||
|
||||
@@ -9,16 +9,16 @@ import { IoPlayForward } from '@react-icons/all-files/io5/IoPlayForward';
|
||||
import { IoPlaySkipForward } from '@react-icons/all-files/io5/IoPlaySkipForward';
|
||||
import { IoStop } from '@react-icons/all-files/io5/IoStop';
|
||||
import { IoTime } from '@react-icons/all-files/io5/IoTime';
|
||||
import { EndAction, Playback, TimerType } from 'ontime-types';
|
||||
import { EndAction, MaybeString, Playback, TimerType, TimeStrategy } from 'ontime-types';
|
||||
|
||||
import { tooltipDelayMid } from '../../../ontimeConfig';
|
||||
import EditableBlockTitle from '../common/EditableBlockTitle';
|
||||
import { EventItemActions } from '../RundownEntry';
|
||||
import TimeInputFlow from '../time-input-flow/TimeInputFlow';
|
||||
|
||||
import BlockActionMenu from './composite/BlockActionMenu';
|
||||
import EventBlockPlayback from './composite/EventBlockPlayback';
|
||||
import EventBlockProgressBar from './composite/EventBlockProgressBar';
|
||||
import EventBlockTimers from './composite/EventBlockTimers';
|
||||
|
||||
import style from './EventBlock.module.scss';
|
||||
|
||||
@@ -30,6 +30,8 @@ interface EventBlockInnerProps {
|
||||
timeStart: number;
|
||||
timeEnd: number;
|
||||
duration: number;
|
||||
timeStrategy: TimeStrategy;
|
||||
linkStart: MaybeString;
|
||||
eventId: string;
|
||||
eventIndex: number;
|
||||
isPublic: boolean;
|
||||
@@ -51,6 +53,8 @@ const EventBlockInner = (props: EventBlockInnerProps) => {
|
||||
timeStart,
|
||||
timeEnd,
|
||||
duration,
|
||||
timeStrategy,
|
||||
linkStart,
|
||||
eventId,
|
||||
isPublic = true,
|
||||
endAction,
|
||||
@@ -84,7 +88,17 @@ const EventBlockInner = (props: EventBlockInnerProps) => {
|
||||
|
||||
return !renderInner ? null : (
|
||||
<>
|
||||
<EventBlockTimers eventId={eventId} timeStart={timeStart} timeEnd={timeEnd} duration={duration} delay={delay} />
|
||||
<div className={style.eventTimers}>
|
||||
<TimeInputFlow
|
||||
eventId={eventId}
|
||||
timeStart={timeStart}
|
||||
timeEnd={timeEnd}
|
||||
duration={duration}
|
||||
delay={delay}
|
||||
timeStrategy={timeStrategy}
|
||||
linkStart={linkStart}
|
||||
/>
|
||||
</div>
|
||||
<EditableBlockTitle title={title} eventId={eventId} placeholder='Event title' className={style.eventTitle} />
|
||||
{next && (
|
||||
<Tooltip label='Next event' {...tooltipProps}>
|
||||
@@ -99,7 +113,7 @@ const EventBlockInner = (props: EventBlockInnerProps) => {
|
||||
selected={selected}
|
||||
disablePlayback={skip || isRolling}
|
||||
/>
|
||||
<div className={style.statusElements}>
|
||||
<div className={style.statusElements} id='block-status' data-ispublic={isPublic}>
|
||||
<span className={style.eventNote}>{note}</span>
|
||||
<div className={selected ? style.progressBg : `${style.progressBg} ${style.hidden}`}>
|
||||
{selected && <EventBlockProgressBar playback={playback} />}
|
||||
@@ -117,10 +131,7 @@ const EventBlockInner = (props: EventBlockInnerProps) => {
|
||||
</Tooltip>
|
||||
<Tooltip label={`${isPublic ? 'Event is public' : 'Event is private'}`} {...tooltipProps}>
|
||||
<span>
|
||||
<IoPeople
|
||||
className={`${style.statusIcon} ${isPublic ? style.active : style.disabled}`}
|
||||
data-ispublic={isPublic}
|
||||
/>
|
||||
<IoPeople className={`${style.statusIcon} ${isPublic ? style.active : style.disabled}`} />
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
@@ -13,7 +13,7 @@ function formatDelay(timeStart: number, delay: number): string | undefined {
|
||||
|
||||
const delayedStart = Math.max(0, timeStart + delay);
|
||||
const timeTag = removeTrailingZero(millisToString(delayedStart));
|
||||
return `New start: ${timeTag}`;
|
||||
return `New start ${timeTag}`;
|
||||
}
|
||||
|
||||
function formatOverlap(previousEnd: number | null, timeStart: number): string | undefined {
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
import { memo } from 'react';
|
||||
import { Tooltip } from '@chakra-ui/react';
|
||||
import { IoAlertCircleOutline } from '@react-icons/all-files/io5/IoAlertCircleOutline';
|
||||
import { OntimeEvent } from 'ontime-types';
|
||||
|
||||
import TimeInputWithButton from '../../../../common/components/input/time-input/TimeInputWithButton';
|
||||
import { useEventAction } from '../../../../common/hooks/useEventAction';
|
||||
import { forgivingStringToMillis } from '../../../../common/utils/dateConfig';
|
||||
import { tooltipDelayFast } from '../../../../ontimeConfig';
|
||||
|
||||
import style from '../EventBlock.module.scss';
|
||||
|
||||
interface EventBlockTimerProps {
|
||||
eventId: string;
|
||||
timeStart: number;
|
||||
timeEnd: number;
|
||||
duration: number;
|
||||
delay: number;
|
||||
}
|
||||
|
||||
type TimeActions = 'timeStart' | 'timeEnd' | 'durationOverride'; // we call it durationOverride to stop from passing as a duration value
|
||||
|
||||
const EventBlockTimers = (props: EventBlockTimerProps) => {
|
||||
const { eventId, timeStart, timeEnd, duration, delay } = props;
|
||||
const { updateEvent, updateTimer } = useEventAction();
|
||||
|
||||
// In sync with EventEditorTimes
|
||||
const handleSubmit = (field: TimeActions, value: string) => {
|
||||
if (field === 'timeStart' || field === 'timeEnd') {
|
||||
updateTimer(eventId, field, value);
|
||||
return;
|
||||
}
|
||||
|
||||
if (field === 'durationOverride') {
|
||||
const timeInMillis = forgivingStringToMillis(value);
|
||||
const newEventData: Partial<OntimeEvent> = { id: eventId, timeEnd: timeStart + timeInMillis };
|
||||
updateEvent(newEventData);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const overMidnight = timeStart > timeEnd;
|
||||
const hasDelay = delay !== 0;
|
||||
|
||||
return (
|
||||
<div className={style.eventTimers}>
|
||||
<TimeInputWithButton<TimeActions>
|
||||
name='timeStart'
|
||||
submitHandler={handleSubmit}
|
||||
time={timeStart}
|
||||
hasDelay={hasDelay}
|
||||
placeholder='Start'
|
||||
/>
|
||||
<TimeInputWithButton<TimeActions>
|
||||
name='timeEnd'
|
||||
submitHandler={handleSubmit}
|
||||
time={timeEnd}
|
||||
hasDelay={hasDelay}
|
||||
placeholder='End'
|
||||
/>
|
||||
<TimeInputWithButton<TimeActions>
|
||||
name='durationOverride'
|
||||
submitHandler={handleSubmit}
|
||||
time={duration}
|
||||
placeholder='Duration'
|
||||
/>
|
||||
{overMidnight && (
|
||||
<div className={style.timerNote}>
|
||||
<Tooltip
|
||||
label='End timer before start'
|
||||
openDelay={tooltipDelayFast}
|
||||
variant='ontime-ondark'
|
||||
shouldWrapChildren
|
||||
>
|
||||
<IoAlertCircleOutline />
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(EventBlockTimers);
|
||||
@@ -17,7 +17,6 @@
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
overflow-y: auto;
|
||||
|
||||
}
|
||||
|
||||
.footer {
|
||||
@@ -45,9 +44,15 @@
|
||||
font-size: calc(1rem - 3px);
|
||||
color: $label-gray;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
&.delayLabel {
|
||||
color: $ontime-delay-text;
|
||||
.delayLabel {
|
||||
font-size: calc(1rem - 3px);
|
||||
color: $ontime-delay-text;
|
||||
margin-top: 0.25rem;
|
||||
|
||||
&::after {
|
||||
content: '\200b';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,8 +60,7 @@
|
||||
font-size: calc(1rem - 3px);
|
||||
color: $label-gray;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
gap: 0.5rem;
|
||||
max-width: max-content;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -100,6 +100,8 @@ export default function EventEditor() {
|
||||
timeStart={event.timeStart}
|
||||
timeEnd={event.timeEnd}
|
||||
duration={event.duration}
|
||||
timeStrategy={event.timeStrategy}
|
||||
linkStart={event.linkStart}
|
||||
delay={event.delay ?? 0}
|
||||
isPublic={event.isPublic}
|
||||
endAction={event.endAction}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { memo } from 'react';
|
||||
import { Select, Switch } from '@chakra-ui/react';
|
||||
import { EndAction, OntimeEvent, TimerType } from 'ontime-types';
|
||||
import { EndAction, MaybeString, TimerType, TimeStrategy } from 'ontime-types';
|
||||
import { millisToString } from 'ontime-utils';
|
||||
|
||||
import TimeInput from '../../../../common/components/input/time-input/TimeInput';
|
||||
import TimeInputWithButton from '../../../../common/components/input/time-input/TimeInputWithButton';
|
||||
import { useEventAction } from '../../../../common/hooks/useEventAction';
|
||||
import { forgivingStringToMillis, millisToDelayString } from '../../../../common/utils/dateConfig';
|
||||
import { cx } from '../../../../common/utils/styleUtils';
|
||||
import TimeInputFlow from '../../time-input-flow/TimeInputFlow';
|
||||
|
||||
import style from '../EventEditor.module.scss';
|
||||
|
||||
@@ -16,6 +15,8 @@ interface EventEditorTimesProps {
|
||||
timeStart: number;
|
||||
timeEnd: number;
|
||||
duration: number;
|
||||
timeStrategy: TimeStrategy;
|
||||
linkStart: MaybeString;
|
||||
delay: number;
|
||||
isPublic: boolean;
|
||||
endAction: EndAction;
|
||||
@@ -25,27 +26,23 @@ interface EventEditorTimesProps {
|
||||
}
|
||||
|
||||
type HandledActions = 'timerType' | 'endAction' | 'isPublic' | 'timeWarning' | 'timeDanger';
|
||||
type TimeActions = 'timeStart' | 'timeEnd' | 'durationOverride'; // we call it durationOverride to stop from passing as a duration value
|
||||
|
||||
const EventEditorTimes = (props: EventEditorTimesProps) => {
|
||||
const { eventId, timeStart, timeEnd, duration, delay, isPublic, endAction, timerType, timeWarning, timeDanger } =
|
||||
props;
|
||||
const { updateEvent, updateTimer } = useEventAction();
|
||||
|
||||
// In sync with EventBlockTimers
|
||||
const handleTimeSubmit = (field: TimeActions, value: string) => {
|
||||
if (field === 'timeStart' || field === 'timeEnd') {
|
||||
updateTimer(eventId, field, value);
|
||||
return;
|
||||
}
|
||||
|
||||
if (field === 'durationOverride') {
|
||||
const timeInMillis = forgivingStringToMillis(value);
|
||||
const newEventData: Partial<OntimeEvent> = { id: eventId, timeEnd: timeStart + timeInMillis };
|
||||
updateEvent(newEventData);
|
||||
return;
|
||||
}
|
||||
};
|
||||
const {
|
||||
eventId,
|
||||
timeStart,
|
||||
timeEnd,
|
||||
duration,
|
||||
timeStrategy,
|
||||
linkStart,
|
||||
delay,
|
||||
isPublic,
|
||||
endAction,
|
||||
timerType,
|
||||
timeWarning,
|
||||
timeDanger,
|
||||
} = props;
|
||||
const { updateEvent } = useEventAction();
|
||||
|
||||
const handleSubmit = (field: HandledActions, value: string | boolean) => {
|
||||
if (field === 'isPublic') {
|
||||
@@ -66,49 +63,28 @@ const EventEditorTimes = (props: EventEditorTimesProps) => {
|
||||
};
|
||||
|
||||
const hasDelay = delay !== 0;
|
||||
const delayTime = hasDelay ? millisToDelayString(delay) : null;
|
||||
const startLabel = delayTime ? `New Start ${millisToString(timeStart + delay)}` : 'Start time';
|
||||
const endLabel = delayTime ? `New End ${millisToString(timeEnd + delay)}` : 'End time';
|
||||
const inputTimeLabels = cx([style.inputLabel, hasDelay ? style.delayLabel : null]);
|
||||
const delayLabel = hasDelay
|
||||
? `Event is ${millisToDelayString(delay)}. New schedule ${millisToString(timeStart + delay)} → ${millisToString(
|
||||
timeEnd + delay,
|
||||
)}`
|
||||
: '';
|
||||
|
||||
return (
|
||||
<div className={style.column}>
|
||||
<div className={style.inline}>
|
||||
<div>
|
||||
<label className={inputTimeLabels} htmlFor='timeStart'>
|
||||
{startLabel}
|
||||
</label>
|
||||
<TimeInputWithButton
|
||||
name='timeStart'
|
||||
submitHandler={handleTimeSubmit}
|
||||
time={timeStart}
|
||||
hasDelay={hasDelay}
|
||||
placeholder='Start'
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={inputTimeLabels} htmlFor='timeEnd'>
|
||||
{endLabel}
|
||||
</label>
|
||||
<TimeInputWithButton
|
||||
name='timeEnd'
|
||||
submitHandler={handleTimeSubmit}
|
||||
time={timeEnd}
|
||||
hasDelay={hasDelay}
|
||||
placeholder='End'
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={style.inputLabel} htmlFor='durationOverride'>
|
||||
Duration
|
||||
</label>
|
||||
<TimeInputWithButton
|
||||
name='durationOverride'
|
||||
submitHandler={handleTimeSubmit}
|
||||
time={duration}
|
||||
placeholder='Duration'
|
||||
<>
|
||||
<div>
|
||||
<div className={style.inputLabel}>Event schedule</div>
|
||||
<div className={style.inline}>
|
||||
<TimeInputFlow
|
||||
eventId={eventId}
|
||||
timeStart={timeStart}
|
||||
timeEnd={timeEnd}
|
||||
duration={duration}
|
||||
timeStrategy={timeStrategy}
|
||||
linkStart={linkStart}
|
||||
delay={delay}
|
||||
/>
|
||||
</div>
|
||||
<div className={style.delayLabel}>{delayLabel}</div>
|
||||
</div>
|
||||
|
||||
<div className={style.splitTwo}>
|
||||
@@ -159,11 +135,11 @@ const EventEditorTimes = (props: EventEditorTimesProps) => {
|
||||
<div>
|
||||
<span className={style.inputLabel}>Event Visibility</span>
|
||||
<label className={style.switchLabel}>
|
||||
<Switch isChecked={isPublic} onChange={() => handleSubmit('isPublic', isPublic)} variant='ontime' />
|
||||
<Switch size='sm' isChecked={isPublic} onChange={() => handleSubmit('isPublic', isPublic)} variant='ontime' />
|
||||
{isPublic ? 'Public' : 'Private'}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
.timeLabel {
|
||||
position: absolute;
|
||||
z-index: 100;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
top: 2px;
|
||||
right: 4px;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.timeAction {
|
||||
opacity: 0.4;
|
||||
cursor: pointer;
|
||||
padding-right: 0.5em;
|
||||
|
||||
&.active {
|
||||
opacity: 1;
|
||||
color: var(--status-color-active-override, $active-indicator);
|
||||
}
|
||||
.fourtyfive {
|
||||
transform: rotate(-45deg);
|
||||
}
|
||||
}
|
||||
|
||||
.timerNote {
|
||||
color: $blue-500;
|
||||
margin-right: 0.5rem;
|
||||
font-size: 1.5em;
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { memo } from 'react';
|
||||
import { InputRightElement, Tooltip } from '@chakra-ui/react';
|
||||
import { IoAlertCircleOutline } from '@react-icons/all-files/io5/IoAlertCircleOutline';
|
||||
import { IoLink } from '@react-icons/all-files/io5/IoLink';
|
||||
import { IoLockClosed } from '@react-icons/all-files/io5/IoLockClosed';
|
||||
import { IoLockOpenOutline } from '@react-icons/all-files/io5/IoLockOpenOutline';
|
||||
import { IoUnlink } from '@react-icons/all-files/io5/IoUnlink';
|
||||
import { MaybeString, OntimeEvent, TimeStrategy } from 'ontime-types';
|
||||
|
||||
import TimeInputWithButton from '../../../common/components/input/time-input/TimeInputWithButton';
|
||||
import { useEventAction } from '../../../common/hooks/useEventAction';
|
||||
import { cx } from '../../../common/utils/styleUtils';
|
||||
import { tooltipDelayFast } from '../../../ontimeConfig';
|
||||
|
||||
import style from './TimeInputFlow.module.scss';
|
||||
|
||||
interface EventBlockTimerProps {
|
||||
eventId: string;
|
||||
timeStart: number;
|
||||
timeEnd: number;
|
||||
duration: number;
|
||||
timeStrategy: TimeStrategy;
|
||||
linkStart: MaybeString;
|
||||
delay: number;
|
||||
}
|
||||
|
||||
type TimeActions = 'timeStart' | 'timeEnd' | 'duration';
|
||||
|
||||
const TimeInputFlow = (props: EventBlockTimerProps) => {
|
||||
const { eventId, timeStart, timeEnd, duration, timeStrategy, linkStart, delay } = props;
|
||||
const { updateEvent, updateTimer, linkTimer } = useEventAction();
|
||||
|
||||
// In sync with EventEditorTimes
|
||||
const handleSubmit = (field: TimeActions, value: string) => {
|
||||
updateTimer(eventId, field, value);
|
||||
};
|
||||
|
||||
const handleChangeStrategy = (timeStrategy: TimeStrategy) => {
|
||||
const newEvent: Partial<OntimeEvent> = { id: eventId, timeStrategy };
|
||||
updateEvent(newEvent);
|
||||
};
|
||||
|
||||
const handleLink = (doLink: boolean) => {
|
||||
// the string doesnt mean much for now, not more than an intent to link
|
||||
// we imagine that we can leverage this to create offsets p+10
|
||||
linkTimer(eventId, doLink ? 'p' : null);
|
||||
};
|
||||
|
||||
const overMidnight = timeStart > timeEnd;
|
||||
const hasDelay = delay !== 0;
|
||||
|
||||
const isLockedEnd = timeStrategy === TimeStrategy.LockEnd;
|
||||
const isLockedDuration = timeStrategy === TimeStrategy.LockDuration;
|
||||
|
||||
const activeStart = cx([style.timeAction, linkStart ? style.active : null]);
|
||||
const activeEnd = cx([style.timeAction, isLockedEnd ? style.active : null]);
|
||||
const activeDuration = cx([style.timeAction, isLockedDuration ? style.active : null]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<TimeInputWithButton<TimeActions>
|
||||
name='timeStart'
|
||||
submitHandler={handleSubmit}
|
||||
time={timeStart}
|
||||
hasDelay={hasDelay}
|
||||
placeholder='Start'
|
||||
disabled={Boolean(linkStart)}
|
||||
>
|
||||
<InputRightElement className={activeStart} onClick={() => handleLink(!linkStart)}>
|
||||
<span className={style.timeLabel}>S</span>
|
||||
<span className={style.fourtyfive}>{linkStart ? <IoLink /> : <IoUnlink />}</span>
|
||||
</InputRightElement>
|
||||
</TimeInputWithButton>
|
||||
|
||||
<TimeInputWithButton<TimeActions>
|
||||
name='timeEnd'
|
||||
submitHandler={handleSubmit}
|
||||
time={timeEnd}
|
||||
hasDelay={hasDelay}
|
||||
disabled={isLockedDuration}
|
||||
placeholder='End'
|
||||
>
|
||||
<InputRightElement
|
||||
className={activeEnd}
|
||||
onClick={() => handleChangeStrategy(TimeStrategy.LockEnd)}
|
||||
data-testid='lock__end'
|
||||
>
|
||||
<span className={style.timeLabel}>E</span>
|
||||
{isLockedEnd ? <IoLockClosed /> : <IoLockOpenOutline />}
|
||||
</InputRightElement>
|
||||
</TimeInputWithButton>
|
||||
|
||||
<TimeInputWithButton<TimeActions>
|
||||
name='duration'
|
||||
submitHandler={handleSubmit}
|
||||
time={duration}
|
||||
disabled={isLockedEnd}
|
||||
placeholder='Duration'
|
||||
>
|
||||
<InputRightElement
|
||||
className={activeDuration}
|
||||
onClick={() => handleChangeStrategy(TimeStrategy.LockDuration)}
|
||||
data-testid='lock__duration'
|
||||
>
|
||||
<span className={style.timeLabel}>D</span>
|
||||
{isLockedDuration ? <IoLockClosed /> : <IoLockOpenOutline />}
|
||||
</InputRightElement>
|
||||
</TimeInputWithButton>
|
||||
|
||||
{overMidnight && (
|
||||
<div className={style.timerNote}>
|
||||
<Tooltip
|
||||
label='Over midnight: end time is before start'
|
||||
openDelay={tooltipDelayFast}
|
||||
variant='ontime-ondark'
|
||||
shouldWrapChildren
|
||||
>
|
||||
<IoAlertCircleOutline />
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(TimeInputFlow);
|
||||
@@ -7,7 +7,7 @@ $thumb-color-hover: $gray-900;
|
||||
* {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
scrollbar-color: $track-color $thumb-color;
|
||||
scrollbar-color: $thumb-color $track-color;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ option {
|
||||
|
||||
/* Track */
|
||||
::-webkit-scrollbar-track {
|
||||
background: $track-color;
|
||||
background: $white-1;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user