display multiple custom fields in operator view (#1001)

---------

Co-authored-by: Carlos Valente <carlosvalente@pm.me>
This commit is contained in:
Alex Christoffer Rasmussen
2024-06-12 14:25:26 +02:00
committed by GitHub
parent 9d7b5568e4
commit b1838a5e9a
9 changed files with 171 additions and 72 deletions
@@ -1,5 +1,18 @@
import { useState } from 'react';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { Input, InputGroup, InputLeftElement, Select, Switch } from '@chakra-ui/react'; import {
Button,
Input,
InputGroup,
InputLeftElement,
Menu,
MenuButton,
MenuItemOption,
MenuList,
MenuOptionGroup,
Select,
Switch,
} from '@chakra-ui/react';
import { isStringBoolean } from '../../../features/viewers/common/viewUtils'; import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
@@ -34,6 +47,10 @@ export default function ParamInput(props: EditFormInputProps) {
); );
} }
if (type === 'multi-option') {
return <MultiOption paramField={paramField} />;
}
if (type === 'boolean') { if (type === 'boolean') {
const defaultCheckedValue = isStringBoolean(searchParams.get(id)) || defaultValue; const defaultCheckedValue = isStringBoolean(searchParams.get(id)) || defaultValue;
@@ -70,3 +87,47 @@ export default function ParamInput(props: EditFormInputProps) {
</InputGroup> </InputGroup>
); );
} }
interface EditFormMultiOptionProps {
paramField: ParamField & { type: 'multi-option' };
}
function MultiOption(props: EditFormMultiOptionProps) {
const [searchParams] = useSearchParams();
const { paramField } = props;
const { id, defaultValue } = paramField;
const optionFromParams = (searchParams.get(id) ?? '').toLocaleLowerCase();
const defaultOptionValue = optionFromParams || defaultValue?.toLocaleLowerCase() || '';
const [paramState, setParamState] = useState<string>(defaultOptionValue);
return (
<>
<input name={id} hidden readOnly value={paramState} />
<Menu isLazy closeOnSelect={false} variant='ontime-on-dark'>
<MenuButton as={Button} variant='ontime-subtle-white' position='relative' width='fit-content' fontWeight={400}>
{paramField.title}
</MenuButton>
<MenuList>
<MenuOptionGroup
type='checkbox'
value={paramState.split('_')}
onChange={(value) => {
setParamState(typeof value === 'object' ? value.filter((v) => v !== '').join('_') : value);
}}
>
{Object.values(paramField.values).map((option) => {
const { value, label } = option;
return (
<MenuItemOption value={value} key={value}>
{label}
</MenuItemOption>
);
})}
</MenuOptionGroup>
</MenuList>
</Menu>
</>
);
}
@@ -1,8 +1,6 @@
import { CustomFields } from 'ontime-types'; import { CustomFields } from 'ontime-types';
import { capitaliseFirstLetter } from '../../../features/viewers/common/viewUtils'; import { type ParamField } from './types';
import { ParamField } from './types';
const makeOptionsFromCustomFields = (customFields: CustomFields, additionalOptions?: Record<string, string>) => { const makeOptionsFromCustomFields = (customFields: CustomFields, additionalOptions?: Record<string, string>) => {
const customFieldOptions = Object.entries(customFields).reduce((acc, [key, value]) => { const customFieldOptions = Object.entries(customFields).reduce((acc, [key, value]) => {
@@ -464,8 +462,8 @@ export const getStudioClockOptions = (timeFormat: string): ParamField[] => [
export const getOperatorOptions = (customFields: CustomFields, timeFormat: string): ParamField[] => { export const getOperatorOptions = (customFields: CustomFields, timeFormat: string): ParamField[] => {
const fieldOptions = makeOptionsFromCustomFields(customFields, { title: 'Title', note: 'Note' }); const fieldOptions = makeOptionsFromCustomFields(customFields, { title: 'Title', note: 'Note' });
const customFieldSelect = Object.keys(customFields).reduce((acc, key) => { const customFieldSelect = Object.entries(customFields).reduce((acc, [key, field]) => {
return { ...acc, [key]: `Custom: ${capitaliseFirstLetter(key)}` }; return { ...acc, [key]: { value: key, label: field.label, colour: field.colour } };
}, {}); }, {});
return [ return [
@@ -497,9 +495,8 @@ export const getOperatorOptions = (customFields: CustomFields, timeFormat: strin
id: 'subscribe', id: 'subscribe',
title: 'Highlight Field', title: 'Highlight Field',
description: 'Choose a custom field to highlight', description: 'Choose a custom field to highlight',
type: 'option', type: 'multi-option',
values: customFieldSelect, values: customFieldSelect,
defaultValue: '',
}, },
{ {
id: 'shouldEdit', id: 'shouldEdit',
@@ -9,8 +9,15 @@ type OptionsField = {
values: Record<string, string>; values: Record<string, string>;
defaultValue?: string; defaultValue?: string;
}; };
type MultiOptionsField = {
type: 'multi-option';
values: Record<string, { value: string; label: string; colour: string }>;
defaultValue?: string;
};
type StringField = { type: 'string'; defaultValue?: string; prefix?: string; placeholder?: string }; type StringField = { type: 'string'; defaultValue?: string; prefix?: string; placeholder?: string };
type NumberField = { type: 'number'; defaultValue?: number; prefix?: string; placeholder?: string }; type NumberField = { type: 'number'; defaultValue?: number; prefix?: string; placeholder?: string };
type BooleanField = { type: 'boolean'; defaultValue: boolean }; type BooleanField = { type: 'boolean'; defaultValue: boolean };
export type ParamField = BaseField & (StringField | BooleanField | NumberField | OptionsField); export type ParamField = BaseField & (StringField | BooleanField | NumberField | OptionsField | MultiOptionsField);
+20 -20
View File
@@ -1,6 +1,6 @@
import { useCallback, 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 { CustomField, CustomFields, isOntimeEvent, OntimeEvent, SupportedEvent } from 'ontime-types'; import { isOntimeEvent, OntimeEvent, SupportedEvent } from 'ontime-types';
import { getFirstEventNormal, getLastEventNormal } from 'ontime-utils'; import { getFirstEventNormal, getLastEventNormal } from 'ontime-utils';
import Empty from '../../common/components/state/Empty'; import Empty from '../../common/components/state/Empty';
@@ -27,11 +27,9 @@ import style from './Operator.module.scss';
const selectedOffset = 50; const selectedOffset = 50;
export type Subscribed = { id: string; label: string; colour: string; value: string }[];
type TitleFields = Pick<OntimeEvent, 'title'>; type TitleFields = Pick<OntimeEvent, 'title'>;
export type EditEvent = Pick<OntimeEvent, 'id' | 'cue'> & { fieldLabel?: string; fieldValue: string }; export type EditEvent = Pick<OntimeEvent, 'id' | 'cue'> & { subscriptions: Subscribed };
export type PartialEdit = EditEvent & {
field: keyof CustomFields;
};
export default function Operator() { export default function Operator() {
const { data, status } = useRundown(); const { data, status } = useRundown();
@@ -45,7 +43,7 @@ export default function Operator() {
const { data: settings } = useSettings(); const { data: settings } = useSettings();
const [showEditPrompt, setShowEditPrompt] = useState(false); const [showEditPrompt, setShowEditPrompt] = useState(false);
const [editEvent, setEditEvent] = useState<PartialEdit | null>(null); const [editEvent, setEditEvent] = useState<EditEvent | null>(null);
const [lockAutoScroll, setLockAutoScroll] = useState(false); const [lockAutoScroll, setLockAutoScroll] = useState(false);
const selectedRef = useRef<HTMLDivElement | null>(null); const selectedRef = useRef<HTMLDivElement | null>(null);
@@ -102,16 +100,9 @@ export default function Operator() {
debouncedHandleScroll(); debouncedHandleScroll();
}; };
const handleEdit = useCallback( const handleEdit = useCallback((event: EditEvent) => {
(event: EditEvent) => { setEditEvent({ ...event });
const field = searchParams.get('subscribe') as keyof CustomField | null; }, []);
if (field) {
setEditEvent({ ...event, field });
}
},
[searchParams],
);
const missingData = !data || !customFields || !projectData; const missingData = !data || !customFields || !projectData;
const isLoading = status === 'pending' || customFieldStatus === 'pending' || projectDataStatus === 'pending'; const isLoading = status === 'pending' || customFieldStatus === 'pending' || projectDataStatus === 'pending';
@@ -122,8 +113,13 @@ export default function Operator() {
// get fields which the user subscribed to // get fields which the user subscribed to
const shouldEdit = searchParams.get('shouldEdit'); const shouldEdit = searchParams.get('shouldEdit');
const subscribe = searchParams.get('subscribe') as keyof CustomFields;
const canEdit = shouldEdit && subscribe; const subscriptions = (searchParams.get('subscribe') ?? '')
.toLocaleLowerCase()
.split('_')
.filter((value) => Object.hasOwn(customFields, value));
const canEdit = shouldEdit && subscriptions;
const main = searchParams.get('main') as keyof TitleFields | null; const main = searchParams.get('main') as keyof TitleFields | null;
const secondary = searchParams.get('secondary'); const secondary = searchParams.get('secondary');
@@ -173,7 +169,12 @@ export default function Operator() {
const mainField = main ? getPropertyValue(entry, main) ?? '' : entry.title; const mainField = main ? getPropertyValue(entry, main) ?? '' : entry.title;
const secondaryField = getPropertyValue(entry, secondary) ?? ''; const secondaryField = getPropertyValue(entry, secondary) ?? '';
const subscribedData = entry.custom[subscribe]; const subscribedData = subscriptions
? subscriptions.map((id) => {
const { label, colour } = customFields[id];
return { id, label, colour, value: entry.custom[id] };
})
: null;
return ( return (
<OperatorEvent <OperatorEvent
@@ -189,7 +190,6 @@ export default function Operator() {
delay={entry.delay} delay={entry.delay}
isSelected={isSelected} isSelected={isSelected}
subscribed={subscribedData} subscribed={subscribedData}
subscribeLabel={subscribe}
isPast={isPast} isPast={isPast}
selectedRef={isSelected ? selectedRef : undefined} selectedRef={isSelected ? selectedRef : undefined}
onLongPress={canEdit ? handleEdit : () => undefined} onLongPress={canEdit ? handleEdit : () => undefined}
@@ -1,48 +1,69 @@
import { useRef, useState } from 'react'; import { useRef, useState } from 'react';
import { Button, Textarea } from '@chakra-ui/react'; import { Button, Textarea } from '@chakra-ui/react';
import { OntimeEvent } from 'ontime-types';
import { useEventAction } from '../../../common/hooks/useEventAction'; import { useEventAction } from '../../../common/hooks/useEventAction';
import type { PartialEdit } from '../Operator'; import type { EditEvent } from '../Operator';
import style from './EditModal.module.scss'; import style from './EditModal.module.scss';
interface EditModalProps { interface EditModalProps {
event: PartialEdit; event: EditEvent;
onClose: () => void; onClose: () => void;
} }
export default function EditModal(props: EditModalProps) { export default function EditModal(props: EditModalProps) {
const { event, onClose } = props; const { event, onClose } = props;
const { updateCustomField } = useEventAction(); const { updateEvent } = useEventAction();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const inputRef = useRef<HTMLTextAreaElement | null>(null); const inputRef = useRef<HTMLTextAreaElement[]>(new Array<HTMLTextAreaElement>());
const handleSave = async () => { const handleSave = async () => {
if (!inputRef.current) return;
setLoading(true); setLoading(true);
const newValue = inputRef.current?.value;
if (newValue === undefined) { const patchObject: Partial<OntimeEvent> = { id: event.id };
return;
inputRef.current.forEach((element) => {
if (element.dataset.field && element.defaultValue != element.value) {
if (patchObject.custom) {
patchObject.custom[element.dataset.field] = element.value;
} else {
Object.assign(patchObject, { custom: { [element.dataset.field]: element.value } });
}
}
});
if (patchObject.custom) {
await updateEvent(patchObject);
} }
await updateCustomField(event.id, event.field, newValue);
setLoading(false); setLoading(false);
onClose(); onClose();
}; };
const fieldLabel = event?.fieldLabel ?? event.field;
return ( return (
<div className={style.editModal}> <div className={style.editModal}>
<div>{`Editing field ${fieldLabel} in cue ${event.cue}`}</div> <div>{`Editing fields in cue ${event.cue}`}</div>
<Textarea {event.subscriptions.map((field) => {
ref={inputRef} return (
variant='ontime-filled' <div key={field.label}>
placeholder={`Add value for ${fieldLabel} field`} <label>{field.label}</label>
defaultValue={event.fieldValue} <Textarea
isDisabled={loading} ref={(element) => {
resize='none' if (element) inputRef.current.push(element);
/> }}
variant='ontime-filled'
placeholder={`Add value for ${field.label} field`}
defaultValue={field.value}
data-field={field.id}
isDisabled={loading}
resize='none'
/>
</div>
);
})}
<div className={style.buttonRow}> <div className={style.buttonRow}>
<Button variant='ontime-subtle' onClick={onClose} isDisabled={loading}> <Button variant='ontime-subtle' onClick={onClose} isDisabled={loading}>
Cancel Cancel
@@ -20,9 +20,9 @@
grid-template-rows: auto auto auto; grid-template-rows: auto auto auto;
column-gap: 0.5rem; column-gap: 0.5rem;
grid-template-areas: grid-template-areas:
"binder main schedule" 'binder main schedule'
"binder secondary running" 'binder secondary running'
"binder fields fields"; 'binder fields fields';
&.subscribed { &.subscribed {
background-color: $gray-1250; background-color: $gray-1250;
@@ -93,16 +93,25 @@
font-weight: 400; font-weight: 400;
color: $ui-black; color: $ui-black;
margin: 0.25rem 0; margin: 0.25rem 0;
display: flex;
flex-wrap: wrap;
.field { .field {
font-weight: 600; font-weight: 600;
padding: 0 0.25rem; padding: 0 0.25rem;
background-color: var(--operator-highlight-override, $orange-600); margin-right: 0.25rem;
margin-right: 0.5rem; }
.noColour {
outline: 0.15rem solid var(--operator-highlight-override, $ui-white);
outline-offset: -0.15rem;
padding-right: 0.3rem;
color: $ui-white;
} }
.value { .value {
color: $orange-500; color: var(--operator-highlight-override, $ui-white);
margin-right: 1rem;
} }
} }
@@ -6,7 +6,7 @@ import { useTimer } from '../../../common/hooks/useSocket';
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils'; import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
import ClockTime from '../../viewers/common/clock-time/ClockTime'; import ClockTime from '../../viewers/common/clock-time/ClockTime';
import RunningTime from '../../viewers/common/running-time/RunningTime'; import RunningTime from '../../viewers/common/running-time/RunningTime';
import type { EditEvent } from '../Operator'; import type { EditEvent, Subscribed } from '../Operator';
import style from './OperatorEvent.module.scss'; import style from './OperatorEvent.module.scss';
@@ -21,8 +21,7 @@ interface OperatorEventProps {
duration: number; duration: number;
delay?: number; delay?: number;
isSelected: boolean; isSelected: boolean;
subscribed?: string; subscribed: Subscribed | null;
subscribeLabel: string;
isPast: boolean; isPast: boolean;
selectedRef?: RefObject<HTMLDivElement>; selectedRef?: RefObject<HTMLDivElement>;
onLongPress: (event: EditEvent) => void; onLongPress: (event: EditEvent) => void;
@@ -47,7 +46,6 @@ function OperatorEvent(props: OperatorEventProps) {
delay, delay,
isSelected, isSelected,
subscribed, subscribed,
subscribeLabel: subscribedAlias,
isPast, isPast,
selectedRef, selectedRef,
onLongPress, onLongPress,
@@ -56,7 +54,9 @@ function OperatorEvent(props: OperatorEventProps) {
const handleLongPress = (event?: SyntheticEvent) => { const handleLongPress = (event?: SyntheticEvent) => {
// we dont have an event out of useLongPress // we dont have an event out of useLongPress
event?.preventDefault(); event?.preventDefault();
onLongPress({ id, cue, fieldLabel: subscribedAlias, fieldValue: subscribed ?? '' }); if (subscribed) {
onLongPress({ id, cue, subscriptions: subscribed });
}
}; };
const mouseHandlers = useLongPress(handleLongPress, { threshold: 800 }); const mouseHandlers = useLongPress(handleLongPress, { threshold: 800 });
@@ -89,12 +89,22 @@ function OperatorEvent(props: OperatorEventProps) {
</span> </span>
<div className={style.fields}> <div className={style.fields}>
{subscribed && ( {subscribed &&
<> subscribed
<span className={style.field}>{subscribedAlias}</span> .filter((field) => field.value)
<span className={style.value}>{subscribed}</span> .map((field) => {
</> const fieldClasses = cx([style.field, !field.colour ? style.noColour : null]);
)} return (
<div key={field.id}>
<span className={fieldClasses} style={{ backgroundColor: field.colour }}>
{field.label}
</span>
<span className={style.value} style={{ color: field.colour }}>
{field.value}
</span>
</div>
);
})}
</div> </div>
</div> </div>
); );
@@ -53,10 +53,6 @@ export function getPropertyValue(event: OntimeEvent | null, property: MaybeStrin
return event[property as keyof OntimeEvent] as string; return event[property as keyof OntimeEvent] as string;
} }
export function capitaliseFirstLetter(string: string) {
return string.charAt(0).toUpperCase() + string.slice(1);
}
type FormattingOptions = { type FormattingOptions = {
removeSeconds: boolean; removeSeconds: boolean;
removeLeadingZero: boolean; removeLeadingZero: boolean;
+1 -3
View File
@@ -679,7 +679,6 @@ packages:
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
dependencies: dependencies:
regenerator-runtime: 0.14.1 regenerator-runtime: 0.14.1
dev: true
/@babel/template@7.22.15: /@babel/template@7.22.15:
resolution: {integrity: sha512-QPErUVm4uyJa60rkI73qneDacvdvzxshT3kksGqlGWYdOTIUOwJ7RDUL8sGqslY1uXWSL6xMFKEXDS3ox2uF0w==} resolution: {integrity: sha512-QPErUVm4uyJa60rkI73qneDacvdvzxshT3kksGqlGWYdOTIUOwJ7RDUL8sGqslY1uXWSL6xMFKEXDS3ox2uF0w==}
@@ -8031,7 +8030,7 @@ packages:
peerDependencies: peerDependencies:
react: ^15.3.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 react: ^15.3.0 || ^16.0.0 || ^17.0.0 || ^18.0.0
dependencies: dependencies:
'@babel/runtime': 7.22.5 '@babel/runtime': 7.24.5
react: 18.2.0 react: 18.2.0
dev: false dev: false
@@ -8278,7 +8277,6 @@ packages:
/regenerator-runtime@0.14.1: /regenerator-runtime@0.14.1:
resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==} resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==}
dev: true
/regexp.prototype.flags@1.4.3: /regexp.prototype.flags@1.4.3:
resolution: {integrity: sha512-fjggEOO3slI6Wvgjwflkc4NFRCTZAu5CnNfBd5qOMYhWdn67nJBBu34/TkD++eeFmd8C9r9jfXJ27+nSiRkSUA==} resolution: {integrity: sha512-fjggEOO3slI6Wvgjwflkc4NFRCTZAu5CnNfBd5qOMYhWdn67nJBBu34/TkD++eeFmd8C9r9jfXJ27+nSiRkSUA==}