From bf8ebe942ec9e5f7e2803afe16668d381f872658 Mon Sep 17 00:00:00 2001 From: Carlos Valente <34649812+cpvalente@users.noreply.github.com> Date: Fri, 24 Nov 2023 12:04:22 +0100 Subject: [PATCH] feat: allow editing field from operator (#609) * feat: allow editing field from operator Co-authored-by: arc-alex * style: functional and presentation tweaks --------- Co-authored-by: arc-alex --- apps/client/src/common/hooks/useLongPress.tsx | 64 +++++++++++++++++++ .../features/operator/Operator.module.scss | 21 ++++++ .../client/src/features/operator/Operator.tsx | 53 +++++++++++++-- .../operator/edit-modal/EditModal.module.scss | 30 +++++++++ .../operator/edit-modal/EditModal.tsx | 57 +++++++++++++++++ .../operator/operator-event/OperatorEvent.tsx | 18 +++++- 6 files changed, 234 insertions(+), 9 deletions(-) create mode 100644 apps/client/src/common/hooks/useLongPress.tsx create mode 100644 apps/client/src/features/operator/edit-modal/EditModal.module.scss create mode 100644 apps/client/src/features/operator/edit-modal/EditModal.tsx diff --git a/apps/client/src/common/hooks/useLongPress.tsx b/apps/client/src/common/hooks/useLongPress.tsx new file mode 100644 index 000000000..b191a509c --- /dev/null +++ b/apps/client/src/common/hooks/useLongPress.tsx @@ -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(); + + 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]); +} diff --git a/apps/client/src/features/operator/Operator.module.scss b/apps/client/src/features/operator/Operator.module.scss index 0a72d0d8b..8d2cc826f 100644 --- a/apps/client/src/features/operator/Operator.module.scss +++ b/apps/client/src/features/operator/Operator.module.scss @@ -22,3 +22,24 @@ .spacer { 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; + } +} diff --git a/apps/client/src/features/operator/Operator.tsx b/apps/client/src/features/operator/Operator.tsx index b43b9ed9a..f52034c04 100644 --- a/apps/client/src/features/operator/Operator.tsx +++ b/apps/client/src/features/operator/Operator.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { useSearchParams } from 'react-router-dom'; import { isOntimeEvent, OntimeEvent, SupportedEvent, UserFields } from 'ontime-types'; import { getFirstEvent, getLastEvent } from 'ontime-utils'; @@ -15,6 +15,7 @@ import useUserFields from '../../common/hooks-query/useUserFields'; import { debounce } from '../../common/utils/debounce'; import { isStringBoolean } from '../../common/utils/viewUtils'; +import EditModal from './edit-modal/EditModal'; import FollowButton from './follow-button/FollowButton'; import OperatorBlock from './operator-block/OperatorBlock'; import OperatorEvent from './operator-event/OperatorEvent'; @@ -25,14 +26,24 @@ import style from './Operator.module.scss'; const selectedOffset = 50; type TitleFields = Pick; +export type EditEvent = Pick & { fieldLabel?: string; fieldValue: string }; +export type PartialEdit = EditEvent & { + field: keyof UserFields; +}; + export default function Operator() { const { data, status } = useRundown(); const { data: userFields, status: userFieldsStatus } = useUserFields(); const { data: projectData, status: projectDataStatus } = useProjectData(); + const timeoutId = useRef(null); + const featureData = useOperator(); const [searchParams] = useSearchParams(); + const [showEditPrompt, setShowEditPrompt] = useState(false); + const [editEvent, setEditEvent] = useState(null); + const [lockAutoScroll, setLockAutoScroll] = useState(false); const selectedRef = useRef(null); const scrollRef = useRef(null); @@ -78,6 +89,30 @@ export default function Operator() { }; 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 isLoading = status === 'pending' || userFieldsStatus === 'pending' || projectDataStatus === 'pending'; @@ -103,6 +138,7 @@ export default function Operator() {
+ {editEvent && setEditEvent(null)} />} -
+ {subscribe && ( +
+ Press and hold to edit user field +
+ )} + +
{data.map((entry) => { if (isOntimeEvent(entry)) { const isSelected = featureData.selectedEventId === entry.id; @@ -139,6 +176,7 @@ export default function Operator() { return ( undefined} /> ); } diff --git a/apps/client/src/features/operator/edit-modal/EditModal.module.scss b/apps/client/src/features/operator/edit-modal/EditModal.module.scss new file mode 100644 index 000000000..0adf73b01 --- /dev/null +++ b/apps/client/src/features/operator/edit-modal/EditModal.module.scss @@ -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%; + } + } +} diff --git a/apps/client/src/features/operator/edit-modal/EditModal.tsx b/apps/client/src/features/operator/edit-modal/EditModal.tsx new file mode 100644 index 000000000..e82d99f59 --- /dev/null +++ b/apps/client/src/features/operator/edit-modal/EditModal.tsx @@ -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(null); + + const handleSave = async () => { + setLoading(true); + const newValue = inputRef.current?.value; + + const partialEvent: Partial = { + id: event.id, + [event.field]: newValue, + }; + await updateEvent(partialEvent); + setLoading(false); + onClose(); + }; + + const fieldLabel = event?.fieldLabel ?? event.field; + + return ( +
+
{`Editing field ${fieldLabel} in cue ${event.cue}`}
+