feat: allow editing field from operator (#609)

* feat: allow editing field from operator

Co-authored-by: arc-alex <ac@omnivox.dk>

* style: functional and presentation tweaks
---------

Co-authored-by: arc-alex <ac@omnivox.dk>
This commit is contained in:
Carlos Valente
2023-11-24 12:04:22 +01:00
committed by GitHub
parent bdd216d881
commit bf8ebe942e
6 changed files with 234 additions and 9 deletions
@@ -0,0 +1,64 @@
import { MouseEvent, SyntheticEvent, TouchEvent, useMemo, useRef } from 'react';
type LongPressOptions = {
threshold?: number;
onStart?: (e: SyntheticEvent) => void;
onFinish?: (e: SyntheticEvent) => void;
onCancel?: (e: SyntheticEvent) => void;
};
type LongPressFns = {
onMouseDown: (e: MouseEvent) => void;
onMouseUp: (e: MouseEvent) => void;
onMouseLeave: (e: MouseEvent) => void;
onTouchStart: (e: TouchEvent) => void;
onTouchEnd: (e: TouchEvent) => void;
};
export default function useLongPress(callback: () => void, options: LongPressOptions = {}): LongPressFns {
const { threshold = 400, onStart, onFinish, onCancel } = options;
const isLongPressActive = useRef(false);
const isPressed = useRef(false);
const timerId = useRef<NodeJS.Timer>();
return useMemo(() => {
const start = (event: SyntheticEvent) => {
if (onStart) {
onStart(event);
}
isPressed.current = true;
timerId.current = setTimeout(() => {
callback();
isLongPressActive.current = true;
}, threshold);
};
const cancel = (event: SyntheticEvent) => {
if (isLongPressActive.current) {
if (onFinish) {
onFinish(event);
}
} else if (isPressed.current) {
if (onCancel) {
onCancel(event);
}
}
isLongPressActive.current = false;
isPressed.current = false;
if (timerId.current) {
clearTimeout(timerId.current);
}
};
return {
onMouseDown: start,
onMouseUp: cancel,
onMouseLeave: cancel,
onTouchStart: start,
onTouchEnd: cancel,
};
}, [callback, threshold, onCancel, onFinish, onStart]);
}
@@ -22,3 +22,24 @@
.spacer { .spacer {
min-height: 95vh; min-height: 95vh;
} }
.editPrompt {
position: fixed;
z-index: 1;
left: 50%;
transform: translate(-50%, 0);
text-align: center;
background: rgba(black, 0.6);
border-radius: 2px;
padding: 0.5em 2em;
color: gold;
opacity: 0;
transition-property: opacity;
transition-duration: 0.3s;
&.show {
opacity: 1;
}
}
+46 -7
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { isOntimeEvent, OntimeEvent, SupportedEvent, UserFields } from 'ontime-types'; import { isOntimeEvent, OntimeEvent, SupportedEvent, UserFields } from 'ontime-types';
import { getFirstEvent, getLastEvent } from 'ontime-utils'; import { getFirstEvent, getLastEvent } from 'ontime-utils';
@@ -15,6 +15,7 @@ import useUserFields from '../../common/hooks-query/useUserFields';
import { debounce } from '../../common/utils/debounce'; import { debounce } from '../../common/utils/debounce';
import { isStringBoolean } from '../../common/utils/viewUtils'; import { isStringBoolean } from '../../common/utils/viewUtils';
import EditModal from './edit-modal/EditModal';
import FollowButton from './follow-button/FollowButton'; import FollowButton from './follow-button/FollowButton';
import OperatorBlock from './operator-block/OperatorBlock'; import OperatorBlock from './operator-block/OperatorBlock';
import OperatorEvent from './operator-event/OperatorEvent'; import OperatorEvent from './operator-event/OperatorEvent';
@@ -25,14 +26,24 @@ import style from './Operator.module.scss';
const selectedOffset = 50; const selectedOffset = 50;
type TitleFields = Pick<OntimeEvent, 'title' | 'subtitle' | 'presenter'>; type TitleFields = Pick<OntimeEvent, 'title' | 'subtitle' | 'presenter'>;
export type EditEvent = Pick<OntimeEvent, 'id' | 'cue'> & { fieldLabel?: string; fieldValue: string };
export type PartialEdit = EditEvent & {
field: keyof UserFields;
};
export default function Operator() { export default function Operator() {
const { data, status } = useRundown(); const { data, status } = useRundown();
const { data: userFields, status: userFieldsStatus } = useUserFields(); const { data: userFields, status: userFieldsStatus } = useUserFields();
const { data: projectData, status: projectDataStatus } = useProjectData(); const { data: projectData, status: projectDataStatus } = useProjectData();
const timeoutId = useRef<NodeJS.Timeout | null>(null);
const featureData = useOperator(); const featureData = useOperator();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const [showEditPrompt, setShowEditPrompt] = useState(false);
const [editEvent, setEditEvent] = useState<PartialEdit | null>(null);
const [lockAutoScroll, setLockAutoScroll] = useState(false); const [lockAutoScroll, setLockAutoScroll] = useState(false);
const selectedRef = useRef<HTMLDivElement | null>(null); const selectedRef = useRef<HTMLDivElement | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null); const scrollRef = useRef<HTMLDivElement | null>(null);
@@ -78,6 +89,30 @@ export default function Operator() {
}; };
const debouncedHandleScroll = debounce(handleUserScroll, 1000); const debouncedHandleScroll = debounce(handleUserScroll, 1000);
const handleScroll = () => {
if (timeoutId.current) {
clearTimeout(timeoutId.current);
}
timeoutId.current = setTimeout(() => {
setShowEditPrompt(false);
}, 700);
setShowEditPrompt(true);
debouncedHandleScroll();
};
const handleEdit = useCallback(
(event: EditEvent) => {
const field = searchParams.get('subscribe') as keyof UserFields | null;
if (field) {
setEditEvent({ ...event, field });
}
},
[searchParams],
);
const missingData = !data || !userFields || !projectData; const missingData = !data || !userFields || !projectData;
const isLoading = status === 'pending' || userFieldsStatus === 'pending' || projectDataStatus === 'pending'; const isLoading = status === 'pending' || userFieldsStatus === 'pending' || projectDataStatus === 'pending';
@@ -103,6 +138,7 @@ export default function Operator() {
<div className={style.operatorContainer}> <div className={style.operatorContainer}>
<NavigationMenu /> <NavigationMenu />
<ViewParamsEditor paramFields={operatorOptions} /> <ViewParamsEditor paramFields={operatorOptions} />
{editEvent && <EditModal event={editEvent} onClose={() => setEditEvent(null)} />}
<StatusBar <StatusBar
projectTitle={projectData.title} projectTitle={projectData.title}
@@ -114,12 +150,13 @@ export default function Operator() {
lastId={lastEvent?.id} lastId={lastEvent?.id}
/> />
<div {subscribe && (
className={style.operatorEvents} <div className={`${style.editPrompt} ${showEditPrompt ? style.show : undefined}`}>
onWheel={debouncedHandleScroll} Press and hold to edit user field
onTouchMove={debouncedHandleScroll} </div>
ref={scrollRef} )}
>
<div className={style.operatorEvents} onWheel={handleScroll} onTouchMove={handleScroll} ref={scrollRef}>
{data.map((entry) => { {data.map((entry) => {
if (isOntimeEvent(entry)) { if (isOntimeEvent(entry)) {
const isSelected = featureData.selectedEventId === entry.id; const isSelected = featureData.selectedEventId === entry.id;
@@ -139,6 +176,7 @@ export default function Operator() {
return ( return (
<OperatorEvent <OperatorEvent
key={entry.id} key={entry.id}
id={entry.id}
colour={entry.colour} colour={entry.colour}
cue={entry.cue} cue={entry.cue}
main={mainField} main={mainField}
@@ -153,6 +191,7 @@ export default function Operator() {
showSeconds={showSeconds} showSeconds={showSeconds}
isPast={isPast} isPast={isPast}
selectedRef={isSelected ? selectedRef : undefined} selectedRef={isSelected ? selectedRef : undefined}
onLongPress={subscribe ? handleEdit : () => undefined}
/> />
); );
} }
@@ -0,0 +1,30 @@
@use '../../../theme/v2Styles' as *;
@use '../../../theme/ontimeColours' as *;
.editModal {
position: absolute;
z-index: 2;
margin: 0 auto;
top: 20%;
left: 50%;
transform: translateX(-50%);
padding: 1rem;
background-color: $gray-1250;
display: flex;
flex-direction: column;
gap: 1rem;
min-width: min(400px, 90vw);
box-shadow: $box-shadow-l1;
.buttonRow {
margin-top: auto;
display: flex;
justify-content: space-between;
gap: 1rem;
button {
width: 100%;
}
}
}
@@ -0,0 +1,57 @@
import { useRef, useState } from 'react';
import { Button, Textarea } from '@chakra-ui/react';
import { OntimeEvent } from 'ontime-types';
import { useEventAction } from '../../../common/hooks/useEventAction';
import type { PartialEdit } from '../Operator';
import style from './EditModal.module.scss';
interface EditModalProps {
event: PartialEdit;
onClose: () => void;
}
export default function EditModal(props: EditModalProps) {
const { event, onClose } = props;
const { updateEvent } = useEventAction();
const [loading, setLoading] = useState(false);
const inputRef = useRef<HTMLTextAreaElement | null>(null);
const handleSave = async () => {
setLoading(true);
const newValue = inputRef.current?.value;
const partialEvent: Partial<OntimeEvent> = {
id: event.id,
[event.field]: newValue,
};
await updateEvent(partialEvent);
setLoading(false);
onClose();
};
const fieldLabel = event?.fieldLabel ?? event.field;
return (
<div className={style.editModal}>
<div>{`Editing field ${fieldLabel} in cue ${event.cue}`}</div>
<Textarea
ref={inputRef}
variant='ontime-filled'
placeholder={`Add value for ${fieldLabel} field`}
defaultValue={event.fieldValue}
isDisabled={loading}
/>
<div className={style.buttonRow}>
<Button variant='ontime-subtle' onClick={onClose} isDisabled={loading}>
Cancel
</Button>
<Button variant='ontime-filled' onClick={handleSave} isDisabled={loading}>
Save
</Button>
</div>
</div>
);
}
@@ -1,13 +1,16 @@
import { memo, RefObject } from 'react'; import { memo, RefObject, SyntheticEvent } from 'react';
import DelayIndicator from '../../../common/components/delay-indicator/DelayIndicator'; import DelayIndicator from '../../../common/components/delay-indicator/DelayIndicator';
import useLongPress from '../../../common/hooks/useLongPress';
import { useTimer } from '../../../common/hooks/useSocket'; import { useTimer } from '../../../common/hooks/useSocket';
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils'; import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
import { formatTime } from '../../../common/utils/time'; import { formatTime } from '../../../common/utils/time';
import type { EditEvent } from '../Operator';
import style from './OperatorEvent.module.scss'; import style from './OperatorEvent.module.scss';
interface OperatorEventProps { interface OperatorEventProps {
id: string;
colour: string; colour: string;
cue: string; cue: string;
main: string; main: string;
@@ -22,6 +25,7 @@ interface OperatorEventProps {
showSeconds: boolean; showSeconds: boolean;
isPast: boolean; isPast: boolean;
selectedRef?: RefObject<HTMLDivElement>; selectedRef?: RefObject<HTMLDivElement>;
onLongPress: (event: EditEvent) => void;
} }
// extract this to contain re-renders // extract this to contain re-renders
@@ -32,6 +36,7 @@ function RollingTime() {
function OperatorEvent(props: OperatorEventProps) { function OperatorEvent(props: OperatorEventProps) {
const { const {
id,
colour, colour,
cue, cue,
main, main,
@@ -46,8 +51,17 @@ function OperatorEvent(props: OperatorEventProps) {
showSeconds, showSeconds,
isPast, isPast,
selectedRef, selectedRef,
onLongPress,
} = props; } = props;
const handleLongPress = (event?: SyntheticEvent) => {
// we dont have an event out of useLongPress
event?.preventDefault();
onLongPress({ id, cue, fieldLabel: subscribedAlias, fieldValue: subscribed ?? '' });
};
const mouseHandlers = useLongPress(handleLongPress, { threshold: 800 });
const start = formatTime(timeStart, { showSeconds }); const start = formatTime(timeStart, { showSeconds });
const end = formatTime(timeEnd, { showSeconds }); const end = formatTime(timeEnd, { showSeconds });
@@ -61,7 +75,7 @@ function OperatorEvent(props: OperatorEventProps) {
]); ]);
return ( return (
<div className={operatorClasses} ref={selectedRef}> <div className={operatorClasses} ref={selectedRef} onContextMenu={handleLongPress} {...mouseHandlers}>
<div className={style.binder} style={{ ...cueColours }}> <div className={style.binder} style={{ ...cueColours }}>
<span className={style.cue}>{cue}</span> <span className={style.cue}>{cue}</span>
</div> </div>