diff --git a/apps/client/src/common/components/view-params-editor/__tests__/constants.test.ts b/apps/client/src/common/components/view-params-editor/__tests__/constants.test.ts new file mode 100644 index 000000000..12659cb49 --- /dev/null +++ b/apps/client/src/common/components/view-params-editor/__tests__/constants.test.ts @@ -0,0 +1,45 @@ +import { CustomFields } from 'ontime-types'; + +import { makeOptionsFromCustomFields } from '../constants'; + +describe('makeOptionsFromCustomFields', () => { + const testCustomFields: CustomFields = { + field1: { label: 'Field 1', colour: 'red', type: 'string' }, + field2: { label: 'Field 2', colour: 'blue', type: 'string' }, + }; + + it('creates a record of keys for the given custom fields', () => { + const result = makeOptionsFromCustomFields(testCustomFields); + expect(result).toStrictEqual({ + 'custom-field1': 'Custom: Field 1', + 'custom-field2': 'Custom: Field 2', + }); + }); + + it('appends additional data', () => { + const additionalData = { + test1: 'test1', + test2: 'test2', + }; + const result = makeOptionsFromCustomFields(testCustomFields, additionalData); + expect(result).toStrictEqual({ + 'custom-field1': 'Custom: Field 1', + 'custom-field2': 'Custom: Field 2', + test1: 'test1', + test2: 'test2', + }); + }); + + it('filtersImageTypes', () => { + const customFieldsWIthImage: CustomFields = { + ...testCustomFields, + field3: { label: 'Field 3', colour: 'green', type: 'image' }, + }; + + const result = makeOptionsFromCustomFields(customFieldsWIthImage); + expect(result).toStrictEqual({ + 'custom-field1': 'Custom: Field 1', + 'custom-field2': 'Custom: Field 2', + }); + }); +}); diff --git a/apps/client/src/common/components/view-params-editor/constants.ts b/apps/client/src/common/components/view-params-editor/constants.ts index a527180f8..3f5f48ab4 100644 --- a/apps/client/src/common/components/view-params-editor/constants.ts +++ b/apps/client/src/common/components/view-params-editor/constants.ts @@ -5,11 +5,17 @@ import type { ParamField } from './types'; export const makeOptionsFromCustomFields = ( customFields: CustomFields, additionalOptions: Record = {}, + filterImageType = true, ) => { - return Object.entries(customFields).reduce((options, [key, value]) => { + const options = structuredClone(additionalOptions); + for (const [key, value] of Object.entries(customFields)) { + if (filterImageType && value.type === 'image') { + continue; + } + options[`custom-${key}`] = `Custom: ${value.label}`; - return options; - }, additionalOptions); + } + return options; }; export const getTimeOption = (timeFormat: string): ParamField => { diff --git a/apps/client/src/features/app-settings/panel/feature-settings-panel/custom-fields/CustomFieldEntry.tsx b/apps/client/src/features/app-settings/panel/feature-settings-panel/custom-fields/CustomFieldEntry.tsx index 1b3a076d1..b563ceb1e 100644 --- a/apps/client/src/features/app-settings/panel/feature-settings-panel/custom-fields/CustomFieldEntry.tsx +++ b/apps/client/src/features/app-settings/panel/feature-settings-panel/custom-fields/CustomFieldEntry.tsx @@ -6,6 +6,7 @@ import { CustomField, CustomFieldLabel } from 'ontime-types'; import CopyTag from '../../../../../common/components/copy-tag/CopyTag'; import Swatch from '../../../../../common/components/input/colour-input/Swatch'; +import Tag from '../../../../../common/components/tag/Tag'; import * as Panel from '../../../panel-utils/PanelUtils'; import CustomFieldForm from './CustomFieldForm'; @@ -13,19 +14,20 @@ import CustomFieldForm from './CustomFieldForm'; import style from '../FeatureSettings.module.scss'; interface CustomFieldEntryProps { - field: string; colour: string; label: string; + fieldKey: string; + type: 'string' | 'image'; onEdit: (label: CustomFieldLabel, patch: CustomField) => Promise; onDelete: (label: CustomFieldLabel) => Promise; } export default function CustomFieldEntry(props: CustomFieldEntryProps) { - const { colour, label, onEdit, onDelete, field } = props; + const { colour, label, fieldKey, type, onEdit, onDelete } = props; const [isEditing, setIsEditing] = useState(false); const handleEdit = async (patch: CustomField) => { - await onEdit(field, patch); + await onEdit(fieldKey, patch); setIsEditing(false); }; @@ -38,7 +40,7 @@ export default function CustomFieldEntry(props: CustomFieldEntryProps) { onSubmit={handleEdit} initialColour={colour} initialLabel={label} - initialKey={field} + initialKey={fieldKey} /> @@ -50,10 +52,13 @@ export default function CustomFieldEntry(props: CustomFieldEntryProps) { + + {type} + {label} - - {field} + + {fieldKey} @@ -71,7 +76,7 @@ export default function CustomFieldEntry(props: CustomFieldEntryProps) { color='#FA5656' // $red-500 icon={} aria-label='Delete entry' - onClick={() => onDelete(field)} + onClick={() => onDelete(fieldKey)} /> diff --git a/apps/client/src/features/app-settings/panel/feature-settings-panel/custom-fields/CustomFieldForm.tsx b/apps/client/src/features/app-settings/panel/feature-settings-panel/custom-fields/CustomFieldForm.tsx index e08e4e61f..5e3cc9a77 100644 --- a/apps/client/src/features/app-settings/panel/feature-settings-panel/custom-fields/CustomFieldForm.tsx +++ b/apps/client/src/features/app-settings/panel/feature-settings-panel/custom-fields/CustomFieldForm.tsx @@ -1,10 +1,11 @@ import { useEffect, useState } from 'react'; -import { useForm } from 'react-hook-form'; -import { Button, Input } from '@chakra-ui/react'; +import { Controller, useForm } from 'react-hook-form'; +import { Button, Input, Radio, RadioGroup } from '@chakra-ui/react'; import { CustomField } from 'ontime-types'; import { customFieldLabelToKey, isAlphanumericWithSpace } from 'ontime-utils'; import { maybeAxiosError } from '../../../../../common/api/utils'; +import Info from '../../../../../common/components/info/Info'; import SwatchSelect from '../../../../../common/components/input/colour-input/SwatchSelect'; import useCustomFields from '../../../../../common/hooks-query/useCustomFields'; import { preventEscape } from '../../../../../common/utils/keyEvent'; @@ -20,6 +21,8 @@ interface CustomFieldsFormProps { initialKey?: string; } +type CustomFieldFormData = CustomField & { key: string }; + export default function CustomFieldForm(props: CustomFieldsFormProps) { const { onSubmit, onCancel, initialColour, initialLabel, initialKey } = props; const { data } = useCustomFields(); @@ -28,6 +31,7 @@ export default function CustomFieldForm(props: CustomFieldsFormProps) { const [_, setColour] = useState(initialColour || ''); const { + control, handleSubmit, register, setFocus, @@ -35,17 +39,17 @@ export default function CustomFieldForm(props: CustomFieldsFormProps) { setValue, getValues, formState: { errors, isSubmitting, isValid, isDirty }, - } = useForm({ - defaultValues: { label: initialLabel || '', colour: initialColour || '', key: initialKey || '' }, + } = useForm({ + defaultValues: { type: 'string', label: initialLabel || '', colour: initialColour || '' }, resetOptions: { keepDirtyValues: true, }, }); - const setupSubmit = async (values: { label: string; colour: string }) => { - const { label, colour } = values; + const setupSubmit = async (values: CustomFieldFormData) => { + const { type, label, colour } = values; const newField: CustomField = { - type: 'string', // type is not user definable yet + type, colour, label, }; @@ -77,6 +81,26 @@ export default function CustomFieldForm(props: CustomFieldsFormProps) { className={style.fieldForm} onKeyDown={(event) => preventEscape(event, onCancel)} > + + Please note that images can quickly deteriorate your app's performance. +
+ Prefer using small, and compressed images. +
+
+ Type + ( + + + Text + Image + + + )} + /> +
Label (only alphanumeric characters are allowed) @@ -105,12 +129,10 @@ export default function CustomFieldForm(props: CustomFieldsFormProps) {
-
Colour handleSelectColour(value)} />
- {errors.root && {errors.root.message}} )} + {Object.keys(customFields).map((fieldKey) => { const key = `${event.id}-${fieldKey}`; const fieldName = `custom-${fieldKey}`; @@ -94,17 +97,41 @@ export default function EventEditor(props: EventEditorProps) { const { backgroundColor, color } = getAccessibleColour(customFields[fieldKey].colour); const labelText = customFields[fieldKey].label; - return ( - - ); + if (customFields[fieldKey].type === 'string') { + return ( + + ); + } + + if (customFields[fieldKey].type === 'image') { + return ( +
+ + +
+ ); + } + + // we should have exhausted all types by now + return null; })} diff --git a/apps/client/src/features/rundown/event-editor/composite/EventEditorImage.module.scss b/apps/client/src/features/rundown/event-editor/composite/EventEditorImage.module.scss new file mode 100644 index 000000000..6f3bddef7 --- /dev/null +++ b/apps/client/src/features/rundown/event-editor/composite/EventEditorImage.module.scss @@ -0,0 +1,12 @@ +.imageContainer { + width: 100%; + height: 100%; + background-color: $gray-1250; + display: grid; + place-content: center; +} + +.imageOverlay { + width: 100%; + height: 100%; +} diff --git a/apps/client/src/features/rundown/event-editor/composite/EventEditorImage.tsx b/apps/client/src/features/rundown/event-editor/composite/EventEditorImage.tsx new file mode 100644 index 000000000..5a60b23ce --- /dev/null +++ b/apps/client/src/features/rundown/event-editor/composite/EventEditorImage.tsx @@ -0,0 +1,16 @@ +import style from './EventEditorImage.module.scss'; + +interface EventEditorImageProps { + src: string; +} + +export default function EventEditorImage(props: EventEditorImageProps) { + const { src } = props; + + return ( +
+ +
+
+ ); +} diff --git a/apps/client/src/features/rundown/event-editor/composite/EventTextInput.tsx b/apps/client/src/features/rundown/event-editor/composite/EventTextInput.tsx index a4d1fef2d..82bd0db30 100644 --- a/apps/client/src/features/rundown/event-editor/composite/EventTextInput.tsx +++ b/apps/client/src/features/rundown/event-editor/composite/EventTextInput.tsx @@ -9,11 +9,12 @@ interface EventTextInputProps extends InputProps { field: EditorUpdateFields; label: string; initialValue: string; + placeholder?: string; submitHandler: (field: EditorUpdateFields, value: string) => void; } export default function EventTextInput(props: EventTextInputProps) { - const { field, label, initialValue, submitHandler, maxLength } = props; + const { className, field, label, initialValue, style: givenStyles, submitHandler, maxLength, placeholder } = props; const ref = useRef(null); const submitCallback = useCallback((newValue: string) => submitHandler(field, newValue), [field, submitHandler]); @@ -23,7 +24,9 @@ export default function EventTextInput(props: EventTextInputProps) { return (
- {label} + + {label} + void; +} + +export default memo(EditableImage); + +function EditableImage(props: EditableImageProps) { + const { initialValue, updateValue } = props; + + const handleUpdate = (newValue: string) => { + if (newValue === initialValue) { + return; + } + if (newValue !== '' && !newValue.startsWith('http')) { + return; + } + updateValue(newValue); + }; + + if (!initialValue) { + return ( + handleUpdate(event.currentTarget.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') { + handleUpdate(event.currentTarget.value); + } + }} + defaultValue={initialValue} + spellCheck={false} + autoComplete='off' + /> + ); + } + + return ( +
+
+ +
+ +
+ ); +} diff --git a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/cuesheetCols.tsx b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/cuesheetCols.tsx index ab629958a..d3e75b9b6 100644 --- a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/cuesheetCols.tsx +++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/cuesheetCols.tsx @@ -6,6 +6,7 @@ import { millisToString, removeSeconds } from 'ontime-utils'; import DelayIndicator from '../../../../common/components/delay-indicator/DelayIndicator'; import { formatDuration } from '../../../../common/utils/time'; +import EditableImage from './EditableImage'; import MultiLineCell from './MultiLineCell'; import SingleLineCell from './SingleLineCell'; import TimeInput from './TimeInput'; @@ -105,6 +106,24 @@ function MakeMultiLineField({ row, column, table }: CellContext; } +function LazyImage({ row, column, table }: CellContext) { + const update = useCallback( + (newValue: string) => { + table.options.meta?.handleUpdate(row.index, column.id, newValue, true); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps -- we skip table.options.meta since the reference seems unstable + [column.id, row.index], + ); + + const event = row.original; + if (!isOntimeEvent(event)) { + return null; + } + + const initialValue = event.custom[column.id]; + return ; +} + function MakeSingleLineField({ row, column, table }: CellContext) { const update = useCallback( (newValue: string) => { @@ -139,7 +158,6 @@ function MakeCustomField({ row, column, table }: CellContext; } @@ -148,8 +166,8 @@ export function makeCuesheetColumns(customFields: CustomFields): ColumnDef { return isAlphanumericWithSpace(value); }), - body('type').exists().isString().trim(), + body('type').exists().isIn(['string', 'image']), body('colour').exists().isString().trim(), (req: Request, res: Response, next: NextFunction) => { @@ -23,8 +23,14 @@ export const validateCustomField = [ export const validateEditCustomField = [ param('label').exists().isString().trim(), - body('label').exists().isString().trim(), - body('type').exists().isString().trim(), + body('label') + .exists() + .isString() + .trim() + .custom((value) => { + return isAlphanumericWithSpace(value); + }), + body('type').exists().isIn(['string', 'image']), body('colour').exists().isString().trim(), (req: Request, res: Response, next: NextFunction) => { diff --git a/apps/server/src/utils/__tests__/parserFunctions.test.ts b/apps/server/src/utils/__tests__/parserFunctions.test.ts index eface0ea1..0121d1c6d 100644 --- a/apps/server/src/utils/__tests__/parserFunctions.test.ts +++ b/apps/server/src/utils/__tests__/parserFunctions.test.ts @@ -156,7 +156,7 @@ describe('parseCustomFields()', () => { }); describe('sanitiseCustomFields()', () => { - it('returns an empty array if not an array', () => { + it('returns an empty object the type is incorrect', () => { expect(sanitiseCustomFields({})).toEqual({}); }); @@ -170,16 +170,16 @@ describe('sanitiseCustomFields()', () => { expect(sanitationResult).toStrictEqual(customFields); }); - it('type is forced to be string', () => { - const customFields: CustomFields = { - // @ts-expect-error intentional bad data - test: { label: 'test', type: 'another', colour: 'red' }, - }; - const expectedCustomFields: CustomFields = { - test: { label: 'test', type: 'string', colour: 'red' }, - }; - const sanitationResult = sanitiseCustomFields(customFields); - expect(sanitationResult).toStrictEqual(expectedCustomFields); + it('type should be one of (image | string)', () => { + const testTypes = sanitiseCustomFields({ + test1: { label: 'test1', type: 'another', colour: 'red' }, + test2: { label: 'test2', type: 'image', colour: 'red' }, + test3: { label: 'test3', type: 'string', colour: 'red' }, + }); + expect(testTypes).toMatchObject({ + test2: { label: 'test2', type: 'image', colour: 'red' }, + test3: { label: 'test3', type: 'string', colour: 'red' }, + }); }); it('colour must be a string', () => { diff --git a/apps/server/src/utils/parserFunctions.ts b/apps/server/src/utils/parserFunctions.ts index 34253e9b4..7c8cce9d5 100644 --- a/apps/server/src/utils/parserFunctions.ts +++ b/apps/server/src/utils/parserFunctions.ts @@ -217,15 +217,15 @@ export function sanitiseCustomFields(data: object): CustomFields { } const keyFromLabel = customFieldLabelToKey(field.label); - //Test label and key cohesion, but allow old lowercased keys to stay - //TODO: the `toLocaleLowerCase` part here is to conserve keys from old projects and could be removed at some point (okt. 2024) + // Test label and key cohesion, but allow old lowercased keys to stay + // TODO: the `toLocaleLowerCase` part here is to conserve keys from old projects and could be removed at some point (okt. 2024) const key = originalKey.toLocaleLowerCase() === keyFromLabel.toLocaleLowerCase() ? originalKey : keyFromLabel; if (key in newCustomFields) { continue; } newCustomFields[key] = { - type: 'string', + type: field.type, colour: field.colour, label: field.label, }; @@ -238,7 +238,9 @@ export function sanitiseCustomFields(data: object): CustomFields { 'label' in data && data.label !== '' && 'colour' in data && - typeof data.colour === 'string' + typeof data.colour === 'string' && + 'type' in data && + (data.type === 'string' || data.type === 'image') ); } diff --git a/packages/types/src/definitions/core/CustomFields.type.ts b/packages/types/src/definitions/core/CustomFields.type.ts index cb5807919..4ee92ef42 100644 --- a/packages/types/src/definitions/core/CustomFields.type.ts +++ b/packages/types/src/definitions/core/CustomFields.type.ts @@ -1,7 +1,7 @@ export type CustomFieldLabel = string; export type CustomField = { - type: 'string'; + type: 'string' | 'image'; colour: string; label: CustomFieldLabel; };