From a1fab10bfd3241baa91b18d3e98027de0b2622f0 Mon Sep 17 00:00:00 2001 From: Carlos Valente Date: Sun, 20 Jul 2025 07:37:54 +0200 Subject: [PATCH] feat: improve preset building --- apps/client/src/common/api/urlPresets.ts | 22 +- apps/client/src/common/api/utils.ts | 14 ++ .../client-modal/RedirectClientModal.tsx | 4 +- .../components/input/input/Input.module.scss | 3 +- .../src/common/hooks-query/useUrlPresets.ts | 41 +++- .../common/utils/__tests__/urlPresets.test.ts | 80 +++++- apps/client/src/common/utils/urlPresets.ts | 58 ++++- .../panel-utils/PanelUtils.module.scss | 1 + .../panel/feature-panel/FeaturePanel.tsx | 4 +- .../feature-panel/GenerateLinkFormExport.tsx | 2 +- .../panel/feature-panel/URLPresets.tsx | 113 +++++++++ .../panel/feature-panel/UrlPresetsForm.tsx | 230 ------------------ .../composite/URLPresetForm.module.scss | 9 + .../feature-panel/composite/URLPresetForm.tsx | 154 ++++++++++++ .../__tests__/urlPresets.parser.test.ts | 8 +- .../api-data/url-presets/urlPresets.parser.ts | 10 +- .../api-data/url-presets/urlPresets.router.ts | 65 ++++- .../url-presets/urlPresets.validation.ts | 29 ++- .../__tests__/DataProvider.utils.test.ts | 4 +- apps/server/src/models/demoProject.ts | 10 +- apps/server/test-db/db.json | 6 +- e2e/tests/features/206-url-preset.spec.ts | 17 +- e2e/tests/fixtures/e2e-test-db.json | 6 +- .../src/definitions/core/UrlPreset.type.ts | 19 +- packages/types/src/index.ts | 2 +- packages/utils/src/regex-utils/checkRegex.ts | 2 - 26 files changed, 614 insertions(+), 299 deletions(-) create mode 100644 apps/client/src/features/app-settings/panel/feature-panel/URLPresets.tsx delete mode 100644 apps/client/src/features/app-settings/panel/feature-panel/UrlPresetsForm.tsx create mode 100644 apps/client/src/features/app-settings/panel/feature-panel/composite/URLPresetForm.module.scss create mode 100644 apps/client/src/features/app-settings/panel/feature-panel/composite/URLPresetForm.tsx diff --git a/apps/client/src/common/api/urlPresets.ts b/apps/client/src/common/api/urlPresets.ts index 68820a155..454a80c67 100644 --- a/apps/client/src/common/api/urlPresets.ts +++ b/apps/client/src/common/api/urlPresets.ts @@ -6,7 +6,7 @@ import { apiEntryUrl } from './constants'; const urlPresetsPath = `${apiEntryUrl}/url-presets`; /** - * HTTP request to retrieve aliases + * HTTP request to retrieve all presets */ export async function getUrlPresets(): Promise { const res = await axios.get(urlPresetsPath); @@ -14,8 +14,22 @@ export async function getUrlPresets(): Promise { } /** - * HTTP request to mutate aliases + * HTTP request to add a preset */ -export async function postUrlPresets(data: URLPreset[]): Promise { - return axios.post(urlPresetsPath, data); +export async function postUrlPreset(data: URLPreset): Promise { + return (await axios.post(urlPresetsPath, data)).data; +} + +/** + * HTTP request to edit a preset + */ +export async function putUrlPreset(alias: string, data: URLPreset): Promise { + return (await axios.put(`${urlPresetsPath}/${alias}`, data)).data; +} + +/** + * HTTP request to delete a preset + */ +export async function deleteUrlPreset(alias: string): Promise { + return (await axios.delete(`${urlPresetsPath}/${alias}`)).data; } diff --git a/apps/client/src/common/api/utils.ts b/apps/client/src/common/api/utils.ts index b49fc335c..1f8701f24 100644 --- a/apps/client/src/common/api/utils.ts +++ b/apps/client/src/common/api/utils.ts @@ -33,6 +33,20 @@ export function maybeAxiosError(error: unknown) { } } +/** + * Utility unwrap a an instance of Error + */ +export function unwrapError(error: unknown) { + if (error instanceof Error) { + return error.message; + } else { + if (typeof error !== 'string') { + return JSON.stringify(error); + } + return error; + } +} + /** * Utility unwraps a potential axios error and sends to logger * @param prepend diff --git a/apps/client/src/common/components/client-modal/RedirectClientModal.tsx b/apps/client/src/common/components/client-modal/RedirectClientModal.tsx index 2fd86b1cd..6da104950 100644 --- a/apps/client/src/common/components/client-modal/RedirectClientModal.tsx +++ b/apps/client/src/common/components/client-modal/RedirectClientModal.tsx @@ -45,8 +45,8 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC label: view.label, })), ...enabledPresets.map((preset) => ({ - value: preset.pathAndParams, - label: `Preset: ${preset.alias}`, + value: preset.search, + label: `URL Preset: ${preset.alias}`, })), ]; diff --git a/apps/client/src/common/components/input/input/Input.module.scss b/apps/client/src/common/components/input/input/Input.module.scss index bd1fa2563..44c428bf7 100644 --- a/apps/client/src/common/components/input/input/Input.module.scss +++ b/apps/client/src/common/components/input/input/Input.module.scss @@ -16,7 +16,8 @@ &:focus:not(:read-only) { background-color: $gray-1000; - border: 1px solid $blue-500; + outline: 2px solid $blue-500; + outline-offset: 2px; } &:disabled { diff --git a/apps/client/src/common/hooks-query/useUrlPresets.ts b/apps/client/src/common/hooks-query/useUrlPresets.ts index c55b41ef3..4d4667bb4 100644 --- a/apps/client/src/common/hooks-query/useUrlPresets.ts +++ b/apps/client/src/common/hooks-query/useUrlPresets.ts @@ -1,8 +1,9 @@ -import { useQuery } from '@tanstack/react-query'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { URLPreset } from 'ontime-types'; import { queryRefetchIntervalSlow } from '../../ontimeConfig'; import { URL_PRESETS } from '../api/constants'; -import { getUrlPresets } from '../api/urlPresets'; +import { deleteUrlPreset, getUrlPresets, postUrlPreset, putUrlPreset } from '../api/urlPresets'; interface FetchProps { skip?: boolean; @@ -13,12 +14,42 @@ export default function useUrlPresets({ skip = false }: FetchProps = {}) { queryKey: URL_PRESETS, queryFn: getUrlPresets, placeholderData: (previousData, _previousQuery) => previousData, - retry: 5, - retryDelay: (attempt) => attempt * 2500, refetchInterval: queryRefetchIntervalSlow, - networkMode: 'always', enabled: !skip, }); return { data: data ?? [], status, isError, refetch }; } + +export function useUpdateUrlPreset() { + const queryClient = useQueryClient(); + + const addFn = useMutation({ + mutationFn: postUrlPreset, + onSuccess: (newPresets) => { + queryClient.setQueryData(URL_PRESETS, newPresets); + }, + }); + + const updateFn = useMutation({ + mutationFn: ({ alias, data }: { alias: string; data: URLPreset }) => putUrlPreset(alias, data), + onSuccess: (newPresets) => { + queryClient.setQueryData(URL_PRESETS, newPresets); + }, + }); + + const deleteFn = useMutation({ + mutationFn: deleteUrlPreset, + onSuccess: (newPresets) => { + queryClient.setQueryData(URL_PRESETS, newPresets); + }, + }); + + return { + addPreset: addFn.mutateAsync, + updatePreset: (alias: string, data: URLPreset) => updateFn.mutateAsync({ alias, data }), + deletePreset: deleteFn.mutateAsync, + isMutating: addFn.isPending || updateFn.isPending || deleteFn.isPending, + isMutationError: addFn.isError || updateFn.isError || deleteFn.isError, + }; +} diff --git a/apps/client/src/common/utils/__tests__/urlPresets.test.ts b/apps/client/src/common/utils/__tests__/urlPresets.test.ts index dbdcc8253..35a600ec5 100644 --- a/apps/client/src/common/utils/__tests__/urlPresets.test.ts +++ b/apps/client/src/common/utils/__tests__/urlPresets.test.ts @@ -1,6 +1,12 @@ import { resolvePath } from 'react-router-dom'; -import { arePathsEquivalent, generatePathFromPreset, getRouteFromPreset, validateUrlPresetPath } from '../urlPresets'; +import { + arePathsEquivalent, + generatePathFromPreset, + generateUrlPresetOptions, + getRouteFromPreset, + validateUrlPresetPath, +} from '../urlPresets'; describe('validateUrlPresetPaths()', () => { test.each([ @@ -27,7 +33,8 @@ describe('getRouteFromPreset()', () => { { enabled: true, alias: 'demopage', - pathAndParams: '/timer?user=guest', + target: 'timer', + search: 'user=guest', }, ]; @@ -76,14 +83,14 @@ describe('getRouteFromPreset()', () => { describe('generatePathFromPreset()', () => { test.each([ - ['timer?user=guest', 'demopage', 'timer?user=guest&alias=demopage'], - ['timer?user=admin', 'demopage', 'timer?user=admin&alias=demopage'], - ])('generates a path from a preset: %s', (path, alias, expected) => { - expect(generatePathFromPreset(path, alias, null, null)).toEqual(expected); + ['timer', 'user=guest', 'demopage', 'timer?user=guest&alias=demopage'], + ['timer', 'user=admin', 'demopage', 'timer?user=admin&alias=demopage'], + ])('generates a path from a preset: %s', (target, path, alias, expected) => { + expect(generatePathFromPreset(target, path, alias, null, null)).toEqual(expected); }); test('appends the feature params to the alias', () => { - expect(generatePathFromPreset('timer?user=guest', 'demopage', 'true', '123')).toBe( + expect(generatePathFromPreset('timer', 'user=guest', 'demopage', 'true', '123')).toBe( 'timer?user=guest&alias=demopage&locked=true&token=123', ); }); @@ -106,3 +113,62 @@ describe('arePathsEquivalent()', () => { expect(arePathsEquivalent('timer?test=a&locked=true=token=123', 'timer?test=a')).toBeTruthy(); }); }); + +describe('generateUrlPresetOptions', () => { + it.each([ + [ + 'cloud URL without protocol', + 'test', + 'www.getontime.no/timer?param1=value1¶m2=value2', + { + alias: 'test', + target: 'timer', + search: 'param1=value1¶m2=value2', + enabled: true, + }, + ], + [ + 'cloud URL', + 'test', + 'https://cloud.getontime.no/timer?param1=value1¶m2=value2', + { + alias: 'test', + target: 'timer', + search: 'param1=value1¶m2=value2', + enabled: true, + }, + ], + [ + 'local URL', + 'test', + 'http://localhost:4001/timer?param1=value1¶m2=value2', + { + alias: 'test', + target: 'timer', + search: 'param1=value1¶m2=value2', + enabled: true, + }, + ], + [ + 'IP-based URL', + 'test', + 'http://192.168.0.1:4001/timer?param1=value1¶m2=value2', + { + alias: 'test', + target: 'timer', + search: 'param1=value1¶m2=value2', + enabled: true, + }, + ], + ])('should generate URL preset options for %s', (_description, alias, url, expected) => { + expect(generateUrlPresetOptions(alias, url)).toStrictEqual(expected); + }); + + it('throws on invalid URL', () => { + expect(() => generateUrlPresetOptions('test', 'invalid-url')).toThrow(); + }); + + it('throws on on invalid route', () => { + expect(() => generateUrlPresetOptions('test', 'www.getontime.no/somethingelse/')).toThrow(); + }); +}); diff --git a/apps/client/src/common/utils/urlPresets.ts b/apps/client/src/common/utils/urlPresets.ts index 19795af2d..de4502491 100644 --- a/apps/client/src/common/utils/urlPresets.ts +++ b/apps/client/src/common/utils/urlPresets.ts @@ -1,5 +1,6 @@ import { Path, resolvePath } from 'react-router-dom'; -import { URLPreset } from 'ontime-types'; +import { OntimeView, URLPreset } from 'ontime-types'; +import { checkRegex } from 'ontime-utils'; /** * Validates a preset against defined parameters @@ -49,7 +50,7 @@ export function getRouteFromPreset(location: Path, urlPresets: URLPreset[]): str const foundPreset = urlPresets.find((preset) => preset.alias === removeTrailingSlash(currentURL) && preset.enabled); if (foundPreset) { // if so, we can redirect to the preset path - return generatePathFromPreset(foundPreset.pathAndParams, foundPreset.alias, locked, token); + return generatePathFromPreset(foundPreset.target, foundPreset.search, foundPreset.alias, locked, token); } // if the current url is not an alias, we check if the alias is in the search parameters @@ -63,7 +64,7 @@ export function getRouteFromPreset(location: Path, urlPresets: URLPreset[]): str for (const preset of urlPresets) { // if the page has a known enabled alias, we check if we need to redirect if (preset.alias === presetOnPage && preset.enabled) { - const newPath = generatePathFromPreset(preset.pathAndParams, preset.alias, locked, token); + const newPath = generatePathFromPreset(preset.target, preset.search, preset.alias, locked, token); if (!arePathsEquivalent(currentPath, newPath)) { // if current path is out of date // return new path so we can redirect @@ -77,8 +78,14 @@ export function getRouteFromPreset(location: Path, urlPresets: URLPreset[]): str /** * Handles generating a path and search parameters from a preset */ -export function generatePathFromPreset(pathAndParams: string, alias: string, locked: string | null, token: string | null ): string { - const path = resolvePath(pathAndParams); +export function generatePathFromPreset( + target: Omit, + search: string, + alias: string, + locked: string | null, + token: string | null, +): string { + const path = resolvePath(`${target}?${search}`); const searchParams = new URLSearchParams(path.search); // save the alias so we have a reference to it being a preset and can update if necessary @@ -106,16 +113,16 @@ export function generatePathFromPreset(pathAndParams: string, alias: string, loc export function arePathsEquivalent(currentPath: string, newPath: string): boolean { const currentUrl = new URL(currentPath, document.location.origin); const newUrl = new URL(newPath, document.location.origin); - + // check path if (currentUrl.pathname !== newUrl.pathname) { - return false + return false; } // check search params // if the params match, we dont need further checks if (currentUrl.searchParams.toString() === newUrl.searchParams.toString()) { - return true + return true; } // if there is no match, we check the edge cases for the url sharing feature @@ -124,3 +131,38 @@ export function arePathsEquivalent(currentPath: string, newPath: string): boolea return currentUrl.searchParams.toString() === newUrl.searchParams.toString(); } + +/** + * Generates a URL preset from a user given alias and URL. + */ +export function generateUrlPresetOptions(alias: string, userUrl: string): URLPreset { + let sanitisedUrl = userUrl.toLowerCase(); + // we need to ensure the URL has a protocol, but it doesnt matter which + if (!checkRegex.startsWithHttp(sanitisedUrl)) { + sanitisedUrl = `http://${sanitisedUrl}`; + } + + const url = new URL(sanitisedUrl); + const target = extractLastSegment(url.pathname); + + if (target === 'editor' || !Object.values(OntimeView).includes(target as OntimeView)) { + throw new Error(`Invalid target view: ${target}`); + } + + return { + alias, + target, + search: url.searchParams.toString(), + enabled: true, + }; +} + +/** + * the path can contain the stage hash + * "/team-hash/timer" or "/timer" + * we need to extract ontime view it targets + */ +function extractLastSegment(pathname: string): string { + const segments = pathname.split('/').filter(Boolean); + return segments[segments.length - 1] || ''; +} diff --git a/apps/client/src/features/app-settings/panel-utils/PanelUtils.module.scss b/apps/client/src/features/app-settings/panel-utils/PanelUtils.module.scss index 66156171e..23ac33cce 100644 --- a/apps/client/src/features/app-settings/panel-utils/PanelUtils.module.scss +++ b/apps/client/src/features/app-settings/panel-utils/PanelUtils.module.scss @@ -106,6 +106,7 @@ $inner-padding: 1rem; th, td { padding: 0.5rem; + vertical-align: top; } tr:nth-child(even) { diff --git a/apps/client/src/features/app-settings/panel/feature-panel/FeaturePanel.tsx b/apps/client/src/features/app-settings/panel/feature-panel/FeaturePanel.tsx index e235f6f63..abc3d87fd 100644 --- a/apps/client/src/features/app-settings/panel/feature-panel/FeaturePanel.tsx +++ b/apps/client/src/features/app-settings/panel/feature-panel/FeaturePanel.tsx @@ -6,7 +6,7 @@ import InfoNif from '../network-panel/NetworkInterfaces'; import GenerateLinkFormExport from './GenerateLinkFormExport'; import ReportSettings from './ReportSettings'; -import UrlPresetsForm from './UrlPresetsForm'; +import URLPresets from './URLPresets'; export default function FeaturePanel({ location }: PanelBaseProps) { const presetsRef = useScrollIntoView('presets', location); @@ -17,7 +17,7 @@ export default function FeaturePanel({ location }: PanelBaseProps) { <> Sharing and reporting
- +
diff --git a/apps/client/src/features/app-settings/panel/feature-panel/GenerateLinkFormExport.tsx b/apps/client/src/features/app-settings/panel/feature-panel/GenerateLinkFormExport.tsx index a0ee638e7..0296aa499 100644 --- a/apps/client/src/features/app-settings/panel/feature-panel/GenerateLinkFormExport.tsx +++ b/apps/client/src/features/app-settings/panel/feature-panel/GenerateLinkFormExport.tsx @@ -33,7 +33,7 @@ export default function GenerateLinkFormExport({ lockedPath }: GenerateLinkFormE { value: '', label: 'Companion' }, ...urlPresetData.map((preset) => ({ value: preset.alias, - label: `Preset: ${preset.alias}`, + label: `URL Preset: ${preset.alias}`, })), ]; }, [lockedPath, urlPresetData]); diff --git a/apps/client/src/features/app-settings/panel/feature-panel/URLPresets.tsx b/apps/client/src/features/app-settings/panel/feature-panel/URLPresets.tsx new file mode 100644 index 000000000..7c772e262 --- /dev/null +++ b/apps/client/src/features/app-settings/panel/feature-panel/URLPresets.tsx @@ -0,0 +1,113 @@ +import { useState } from 'react'; +import { IoAdd, IoOpenOutline, IoPencil, IoTrash } from 'react-icons/io5'; +import { URLPreset } from 'ontime-types'; + +import Button from '../../../../common/components/buttons/Button'; +import IconButton from '../../../../common/components/buttons/IconButton'; +import Info from '../../../../common/components/info/Info'; +import ExternalLink from '../../../../common/components/link/external-link/ExternalLink'; +import Switch from '../../../../common/components/switch/Switch'; +import useUrlPresets, { useUpdateUrlPreset } from '../../../../common/hooks-query/useUrlPresets'; +import { handleLinks } from '../../../../common/utils/linkUtils'; +import * as Panel from '../../panel-utils/PanelUtils'; + +import URLPresetForm from './composite/URLPresetForm'; + +type FormState = { + isOpen: boolean; + preset?: URLPreset; +}; + +const urlPresetsDocs = 'https://docs.getontime.no/features/url-presets/'; + +export default function URLPresets() { + const [formState, setFormState] = useState({ isOpen: false, preset: undefined }); + const { data, status } = useUrlPresets(); + const { deletePreset, isMutating } = useUpdateUrlPreset(); + + const openNewForm = () => setFormState({ isOpen: true }); + const openEditForm = (preset: URLPreset) => setFormState({ isOpen: true, preset }); + const closeForm = () => setFormState({ isOpen: false, preset: undefined }); + + return ( + + + + URL presets + + + + + + URL presets are user pre-defined aliases to Ontime URLs. +
+ This URL can contain full configuration including parameters, or simply route to a specific view. +
+
+ The easiest way to get started is to copy an URL from your browser and paste it into the form. + See the docs +
+
+ + + {formState.isOpen && } + + + + Enabled + Alias + Target view + URL parameters + + + + + {data.length === 0 && } + {data.map((preset, index) => { + return ( + + + {}} /> + + {preset.alias} + {preset.target} + {preset.search} + + handleLinks(preset.alias, event)} + disabled={isMutating} + > + + + openEditForm(preset)} + variant='ghosted-white' + aria-label='Edit entry' + data-testid={`field__edit_${index}`} + disabled={isMutating} + > + + + deletePreset(preset.alias)} + variant='ghosted-destructive' + aria-label='Delete entry' + data-testid={`field__delete_${index}`} + disabled={isMutating} + > + + + + + ); + })} + + + +
+
+ ); +} diff --git a/apps/client/src/features/app-settings/panel/feature-panel/UrlPresetsForm.tsx b/apps/client/src/features/app-settings/panel/feature-panel/UrlPresetsForm.tsx deleted file mode 100644 index 5c8cf50bd..000000000 --- a/apps/client/src/features/app-settings/panel/feature-panel/UrlPresetsForm.tsx +++ /dev/null @@ -1,230 +0,0 @@ -import { useEffect } from 'react'; -import { useFieldArray, useForm } from 'react-hook-form'; -import { IoAdd, IoOpenOutline, IoTrash } from 'react-icons/io5'; -import { URLPreset } from 'ontime-types'; - -import { postUrlPresets } from '../../../../common/api/urlPresets'; -import { maybeAxiosError } from '../../../../common/api/utils'; -import Button from '../../../../common/components/buttons/Button'; -import IconButton from '../../../../common/components/buttons/IconButton'; -import Info from '../../../../common/components/info/Info'; -import Input from '../../../../common/components/input/input/Input'; -import ExternalLink from '../../../../common/components/link/external-link/ExternalLink'; -import Switch from '../../../../common/components/switch/Switch'; -import Tooltip from '../../../../common/components/tooltip/Tooltip'; -import useUrlPresets from '../../../../common/hooks-query/useUrlPresets'; -import { preventEscape } from '../../../../common/utils/keyEvent'; -import { handleLinks } from '../../../../common/utils/linkUtils'; -import { validateUrlPresetPath } from '../../../../common/utils/urlPresets'; -import * as Panel from '../../panel-utils/PanelUtils'; - -import style from './FeaturePanel.module.scss'; - -const urlPresetsDocs = 'https://docs.getontime.no/features/url-presets/'; - -type FormData = { - data: URLPreset[]; -}; - -export default function UrlPresetsForm() { - 'use no memo'; // RHF and react-compiler don't seem to get along - const { data, status, refetch } = useUrlPresets(); - const { - control, - handleSubmit, - register, - reset, - setError, - watch, - setValue, - formState: { isSubmitting, isDirty, isValid, errors }, - } = useForm({ - mode: 'onChange', - defaultValues: { data }, - values: { data }, - resetOptions: { - keepDirtyValues: true, - }, - }); - const { fields, prepend, remove } = useFieldArray({ - name: 'data', - control, - }); - - // reset form if we get new data from backend - useEffect(() => { - if (data) { - reset({ data }); - } - }, [data, reset]); - - const onSubmit = async (formData: FormData) => { - for (let i = 0; i < formData.data.length; i++) { - const preset = formData.data[i]; - const { isValid, message } = validateUrlPresetPath(preset.pathAndParams); - if (!isValid) { - setError(`data.${i}.pathAndParams`, { message }); - return; - } - } - - try { - await postUrlPresets(formData.data); - } catch (error) { - const message = maybeAxiosError(error); - setError('root', { message }); - } finally { - await refetch(); - } - }; - - const onReset = () => { - reset({ data }); - }; - - const addNew = () => { - prepend({ - enabled: true, - alias: '', - pathAndParams: '', - }); - }; - - const isLoading = status === 'pending'; - const canSubmit = !isSubmitting && isDirty && isValid; - - return ( - preventEscape(event, onReset)} - data-testid='url-preset-form' - > - - - URL presets - - - - - - - - URL presets are user defined aliases to Ontime URLs -
-
- Preset Name
- The alias for the URL. This will be the URL you will be calling. eg:
- - Preset name cam3 called as{' '} - http://localhost:4001/cam3 - -
- URL Segment
- The corresponding alias path and configuration parameters. eg:
- - URL segment backstage?hidePast=true&stopCycle=true corresponds to - complete URL - http://localhost:4001/backstage?hidePast=true&stopCycle=true - -
- You will need to save the changes before the presets are functional. -
- See the docs -
- - - - Manage presets - - - {errors?.root && {errors.root.message}} - {errors?.data && {errors.data.message}} - - - - - Active - Preset name - URL segment - - - - - {fields.length === 0 && } - {fields.map((preset, index) => { - const maybeAliasError = errors.data?.[index]?.alias?.message; - const maybeUrlError = errors.data?.[index]?.pathAndParams?.message; - // only saved and enabled URLs can be tested - const canTest = - preset.alias && preset.enabled && preset.pathAndParams && !maybeAliasError && !maybeUrlError; - - return ( - - - - setValue(`data.${index}.enabled`, value, { shouldDirty: true }) - } - data-testid={`field__enable_${index}`} - /> - - - - {maybeAliasError} - - - - {maybeUrlError} - - - } - data-testid={`field__test_${index}`} - onClick={(event) => handleLinks(preset.alias, event)} - disabled={!canTest} - > - - - remove(index)} - variant='ghosted-destructive' - aria-label='Delete entry' - data-testid={`field__delete_${index}`} - > - - - - - ); - })} - - - -
-
- ); -} diff --git a/apps/client/src/features/app-settings/panel/feature-panel/composite/URLPresetForm.module.scss b/apps/client/src/features/app-settings/panel/feature-panel/composite/URLPresetForm.module.scss new file mode 100644 index 000000000..db630a5c9 --- /dev/null +++ b/apps/client/src/features/app-settings/panel/feature-panel/composite/URLPresetForm.module.scss @@ -0,0 +1,9 @@ +.expand { + flex: 1; +} + +.column { + display: flex; + flex-direction: column; + gap: 1rem; +} \ No newline at end of file diff --git a/apps/client/src/features/app-settings/panel/feature-panel/composite/URLPresetForm.tsx b/apps/client/src/features/app-settings/panel/feature-panel/composite/URLPresetForm.tsx new file mode 100644 index 000000000..f145798ac --- /dev/null +++ b/apps/client/src/features/app-settings/panel/feature-panel/composite/URLPresetForm.tsx @@ -0,0 +1,154 @@ +import { useEffect, useRef } from 'react'; +import { useForm } from 'react-hook-form'; +import { OntimeView, URLPreset } from 'ontime-types'; + +import { maybeAxiosError, unwrapError } from '../../../../../common/api/utils'; +import Button from '../../../../../common/components/buttons/Button'; +import Input from '../../../../../common/components/input/input/Input'; +import Select, { SelectOption } from '../../../../../common/components/select/Select'; +import { useUpdateUrlPreset } from '../../../../../common/hooks-query/useUrlPresets'; +import { preventEscape } from '../../../../../common/utils/keyEvent'; +import { generateUrlPresetOptions } from '../../../../../common/utils/urlPresets'; +import * as Panel from '../../../panel-utils/PanelUtils'; + +import style from './URLPresetForm.module.scss'; + +const targetOptions: SelectOption[] = [ + { value: OntimeView.Cuesheet, label: 'Cuesheet' }, + { value: OntimeView.Operator, label: 'Operator' }, + { value: OntimeView.Timer, label: 'Timer' }, + { value: OntimeView.Backstage, label: 'Backstage' }, + { value: OntimeView.Timeline, label: 'Timeline' }, + { value: OntimeView.StudioClock, label: 'Studio Clock' }, + { value: OntimeView.Countdown, label: 'Countdown' }, + { value: OntimeView.ProjectInfo, label: 'Project Info' }, +]; + +const defaultValues: URLPreset = { + alias: '', + target: OntimeView.Timer, + search: '', + enabled: true, +}; + +interface URLPresetFormProps { + urlPreset?: URLPreset; + onClose: () => void; +} + +export default function URLPresetForm({ urlPreset, onClose }: URLPresetFormProps) { + const { addPreset, updatePreset, isMutating } = useUpdateUrlPreset(); + + const { + handleSubmit, + register, + setFocus, + setError, + clearErrors, + setValue, + getValues, + watch, + formState: { errors, isSubmitting, isValid, isDirty }, + } = useForm({ + defaultValues: urlPreset ?? defaultValues, + resetOptions: { + keepDirtyValues: true, + }, + }); + const urlRef = useRef(null); + + const setupSubmit = async (data: URLPreset) => { + try { + if (urlPreset) { + await updatePreset(urlPreset.alias, data); + } else { + await addPreset(data); + } + onClose(); + } catch (error) { + setError('root', { message: maybeAxiosError(error) }); + } + }; + + useEffect(() => { + setFocus('alias'); + }, [setFocus]); + + const generateOptions = () => { + clearErrors(); + + try { + const preset = generateUrlPresetOptions(getValues('alias'), urlRef.current?.value.trim() ?? ''); + setValue('target', preset.target, { shouldDirty: true, shouldValidate: true }); + setValue('search', preset.search, { shouldDirty: true, shouldValidate: true }); + } catch (error) { + setError('root', { message: unwrapError(error) }); + return; + } + }; + + const validateParams = (value: string) => { + try { + new URLSearchParams(value); + return true; + } catch (error) { + return unwrapError(error) || 'Invalid URL parameters'; + } + }; + + return ( + preventEscape(event, onClose)} + className={style.column} + > + + +
1. Enter URL and let Ontime generate the preset options
+ +
+ Alias + +
+
+ Generate options (paste URL to generate options) + + + + +
+
+
- or -
+
2. Choose a view and its parameters
+
+ Target + + {errors.search?.message} +
+
+ {errors.root?.message} + + + + +
+
+ ); +} diff --git a/apps/server/src/api-data/url-presets/__tests__/urlPresets.parser.test.ts b/apps/server/src/api-data/url-presets/__tests__/urlPresets.parser.test.ts index 071938140..bc3184ea6 100644 --- a/apps/server/src/api-data/url-presets/__tests__/urlPresets.parser.test.ts +++ b/apps/server/src/api-data/url-presets/__tests__/urlPresets.parser.test.ts @@ -12,13 +12,14 @@ describe('parseUrlPresets()', () => { it('parses data, skipping invalid results', () => { const errorEmitter = vi.fn(); - const urlPresets = [{ enabled: true, alias: 'alias', pathAndParams: 'ss' }] as URLPreset[]; + const urlPresets = [{ enabled: true, alias: 'alias', target: 'timer', search: 'ss' }] as URLPreset[]; const result = parseUrlPresets({ urlPresets }, errorEmitter); expect(result.length).toEqual(1); expect(result.at(0)).toMatchObject({ enabled: true, alias: 'alias', - pathAndParams: 'ss', + target: 'timer', + search: 'ss', }); expect(errorEmitter).not.toHaveBeenCalled(); }); @@ -33,7 +34,8 @@ describe('parseUrlPresets()', () => { { enabled: false, alias: 'testalias', - pathAndParams: 'testpathAndParams', + target: 'timer', + search: 'testpathAndParams', }, ], } as unknown as DatabaseModel; diff --git a/apps/server/src/api-data/url-presets/urlPresets.parser.ts b/apps/server/src/api-data/url-presets/urlPresets.parser.ts index d43ee1bca..781381ffc 100644 --- a/apps/server/src/api-data/url-presets/urlPresets.parser.ts +++ b/apps/server/src/api-data/url-presets/urlPresets.parser.ts @@ -16,10 +16,16 @@ export function parseUrlPresets(data: Partial, emitError?: ErrorE const newPresets: URLPreset[] = []; for (const preset of data.urlPresets) { + if (!preset.alias || !preset.search || !preset.target) { + emitError?.(`Invalid URL preset: ${JSON.stringify(preset)}`); + continue; + } + const newPreset = { enabled: preset.enabled ?? false, - alias: preset.alias ?? '', - pathAndParams: preset.pathAndParams ?? '', + alias: preset.alias, + target: preset.target, + search: preset.search, }; newPresets.push(newPreset); } diff --git a/apps/server/src/api-data/url-presets/urlPresets.router.ts b/apps/server/src/api-data/url-presets/urlPresets.router.ts index 765f2aedd..f61783476 100644 --- a/apps/server/src/api-data/url-presets/urlPresets.router.ts +++ b/apps/server/src/api-data/url-presets/urlPresets.router.ts @@ -2,8 +2,8 @@ import express from 'express'; import type { Request, Response } from 'express'; import type { ErrorResponse, URLPreset } from 'ontime-types'; import { getErrorMessage } from 'ontime-utils'; -import { validateUrlPresets } from './urlPresets.validation.js'; import { getDataProvider } from '../../classes/data-provider/DataProvider.js'; +import { validateNewPreset, validatePresetParam, validateUpdatePreset } from './urlPresets.validation.js'; export const router = express.Router(); @@ -12,13 +12,64 @@ router.get('/', (_req: Request, res: Response) => { res.status(200).send(presets as URLPreset[]); }); -router.post('/', validateUrlPresets, async (req: Request, res: Response) => { +router.post('/', validateNewPreset, async (req: Request, res: Response) => { try { - const newPresets: URLPreset[] = req.body.map((preset: URLPreset) => ({ - enabled: preset.enabled, - alias: preset.alias, - pathAndParams: preset.pathAndParams, - })); + const newPreset: URLPreset = { + enabled: req.body.enabled, + alias: req.body.alias, + target: req.body.target, + search: req.body.search, + }; + + const currentPresets = getDataProvider().getUrlPresets(); + if (currentPresets.some((preset) => preset.alias === newPreset.alias)) { + throw new Error(`Preset with alias "${newPreset.alias}" already exists.`); + } + + const newPresets = [...currentPresets, newPreset]; + + // Update the URL presets in the data provider + await getDataProvider().setUrlPresets(newPresets); + res.status(201).send(newPresets); + } catch (error) { + const message = getErrorMessage(error); + res.status(400).send({ message }); + } +}); + +router.put('/:alias', validateUpdatePreset, async (req: Request, res: Response) => { + try { + const alias = req.params.alias; + const updatedPreset: URLPreset = { + enabled: req.body.enabled, + alias: req.body.alias, + target: req.body.target, + search: req.body.search, + }; + + if (alias !== updatedPreset.alias) { + throw new Error(`Changing alias is not permitted`); + } + + const currentPresets = getDataProvider().getUrlPresets(); + const newPresets = currentPresets.map((preset) => (preset.alias === alias ? updatedPreset : preset)); + + // Update the URL presets in the data provider + await getDataProvider().setUrlPresets(newPresets); + res.status(200).send(newPresets); + } catch (error) { + const message = getErrorMessage(error); + res.status(400).send({ message }); + } +}); + +router.delete('/:alias', validatePresetParam, async (req: Request, res: Response) => { + try { + const alias = req.params.alias; + const currentPresets = getDataProvider().getUrlPresets(); + const newPresets = currentPresets.filter((preset) => preset.alias !== alias); + + // Update the URL presets in the data provider await getDataProvider().setUrlPresets(newPresets); res.status(200).send(newPresets); } catch (error) { diff --git a/apps/server/src/api-data/url-presets/urlPresets.validation.ts b/apps/server/src/api-data/url-presets/urlPresets.validation.ts index fdf8a8ad2..262f9277f 100644 --- a/apps/server/src/api-data/url-presets/urlPresets.validation.ts +++ b/apps/server/src/api-data/url-presets/urlPresets.validation.ts @@ -1,14 +1,31 @@ -import { body } from 'express-validator'; +import { OntimeView } from 'ontime-types'; + +import { body, param } from 'express-validator'; + import { requestValidationFunction } from '../validation-utils/validationFunction.js'; /** * validate array of URL preset objects */ -export const validateUrlPresets = [ - body().isArray().withMessage('No array found in request'), - body('*.enabled').isBoolean(), - body('*.alias').isString().trim().notEmpty(), - body('*.pathAndParams').isString().trim().notEmpty(), +export const validateNewPreset = [ + body().isObject().withMessage('No data found in request'), + body('enabled').isBoolean(), + body('alias').isString().trim().notEmpty(), + body('target').isString().trim().notEmpty().isIn(Object.values(OntimeView)), + body('search').isString().trim(), requestValidationFunction, ]; + +export const validateUpdatePreset = [ + param('alias').isString().trim().notEmpty(), + body().isObject().withMessage('No data found in request'), + body('enabled').isBoolean(), + body('alias').isString().trim().notEmpty(), + body('target').isString().trim().notEmpty().isIn(Object.values(OntimeView)), + body('search').isString().trim(), + + requestValidationFunction, +]; + +export const validatePresetParam = [param('alias').isString().trim().notEmpty(), requestValidationFunction]; diff --git a/apps/server/src/classes/data-provider/__tests__/DataProvider.utils.test.ts b/apps/server/src/classes/data-provider/__tests__/DataProvider.utils.test.ts index 19b3f9e6c..3e7bebd85 100644 --- a/apps/server/src/classes/data-provider/__tests__/DataProvider.utils.test.ts +++ b/apps/server/src/classes/data-provider/__tests__/DataProvider.utils.test.ts @@ -88,8 +88,8 @@ describe('safeMerge', () => { it('should merge the urlPresets key when present', () => { const newData = { urlPresets: [ - { enabled: true, alias: 'alias1', pathAndParams: '' }, - { enabled: true, alias: 'alias2', pathAndParams: '' }, + { enabled: true, alias: 'alias1', search: '' }, + { enabled: true, alias: 'alias2', search: '' }, ] as URLPreset[], }; diff --git a/apps/server/src/models/demoProject.ts b/apps/server/src/models/demoProject.ts index 9798ae9f5..d65077794 100644 --- a/apps/server/src/models/demoProject.ts +++ b/apps/server/src/models/demoProject.ts @@ -553,14 +553,16 @@ export const demoDb: DatabaseModel = { { enabled: true, alias: 'clock', - pathAndParams: - 'timer?showLeadingZeros=true&timerType=clock&hideClock=true&hideCards=true&hideProgress=true&hideMessage=true&hideSecondary=true&hideLogo=true', + target: 'timer', + search: + 'showLeadingZeros=true&timerType=clock&hideClock=true&hideCards=true&hideProgress=true&hideMessage=true&hideSecondary=true&hideLogo=true', }, { enabled: true, alias: 'minimal', - pathAndParams: - 'timer?showLeadingZeros=true&hideClock=true&hideCards=true&hideProgress=true&hideMessage=true&hideSecondary=true&hideLogo=true', + target: 'timer', + search: + 'showLeadingZeros=true&hideClock=true&hideCards=true&hideProgress=true&hideMessage=true&hideSecondary=true&hideLogo=true', }, ], automation: { diff --git a/apps/server/test-db/db.json b/apps/server/test-db/db.json index ba99145cd..dce656d71 100644 --- a/apps/server/test-db/db.json +++ b/apps/server/test-db/db.json @@ -488,12 +488,14 @@ { "enabled": true, "alias": "clock", - "pathAndParams": "timer?showLeadingZeros=true&timerType=clock&hideClock=true&hideCards=true&hideProgress=true&hideMessage=true&hideSecondary=true&hideLogo=true" + "target": "timer", + "search": "timer?showLeadingZeros=true&timerType=clock&hideClock=true&hideCards=true&hideProgress=true&hideMessage=true&hideSecondary=true&hideLogo=true" }, { "enabled": true, "alias": "minimal", - "pathAndParams": "timer?showLeadingZeros=true&hideClock=true&hideCards=true&hideProgress=true&hideMessage=true&hideSecondary=true&hideLogo=true" + "target": "timer", + "search": "timer?showLeadingZeros=true&hideClock=true&hideCards=true&hideProgress=true&hideMessage=true&hideSecondary=true&hideLogo=true" } ], "automation": { diff --git a/e2e/tests/features/206-url-preset.spec.ts b/e2e/tests/features/206-url-preset.spec.ts index 70311f72a..2214915e3 100644 --- a/e2e/tests/features/206-url-preset.spec.ts +++ b/e2e/tests/features/206-url-preset.spec.ts @@ -8,16 +8,19 @@ test('URL preset feature, it should redirect to given URL', async ({ page }) => await page.getByRole('button', { name: 'URL Presets' }).click(); // create preset - await page.getByTestId('url-preset-form').getByRole('button', { name: 'New' }).scrollIntoViewIfNeeded(); - await page.getByTestId('url-preset-form').getByRole('button', { name: 'New' }).click(); + await page.getByRole('heading', { name: 'URL presets New' }).getByRole('button').scrollIntoViewIfNeeded(); + await page.getByRole('heading', { name: 'URL presets New' }).getByRole('button').click(); - await page.getByTestId('field__alias_0').click(); - await page.getByTestId('field__alias_0').fill('testing'); + await page.locator('input[name="alias"]').click(); + await page.locator('input[name="alias"]').fill('testing'); - await page.getByTestId('field__url_0').click(); - await page.getByTestId('field__url_0').fill('countdown'); + await page.getByRole('textbox', { name: 'Paste URL' }).click(); + await page.getByRole('textbox', { name: 'Paste URL' }).fill('www.getontime.no/team/countdown'); + await page.getByRole('button', { name: 'Generate' }).click(); - await page.getByTestId('url-preset-form').getByRole('button', { name: 'Save', exact: true }).click(); + await page.getByRole('combobox').filter({ hasText: 'Countdown' }); + + await page.getByRole('button', { name: 'Save' }).click(); // make sure preset works await page.goto('http://localhost:4001/testing'); diff --git a/e2e/tests/fixtures/e2e-test-db.json b/e2e/tests/fixtures/e2e-test-db.json index 004934c52..dcc71cde9 100644 --- a/e2e/tests/fixtures/e2e-test-db.json +++ b/e2e/tests/fixtures/e2e-test-db.json @@ -505,12 +505,14 @@ { "enabled": true, "alias": "clock", - "pathAndParams": "timer?showLeadingZeros=true&timerType=clock&hideClock=true&hideCards=true&hideProgress=true&hideMessage=true&hideSecondary=true&hideLogo=true" + "target": "timer", + "search": "timer?showLeadingZeros=true&timerType=clock&hideClock=true&hideCards=true&hideProgress=true&hideMessage=true&hideSecondary=true&hideLogo=true" }, { "enabled": true, "alias": "minimal", - "pathAndParams": "timer?showLeadingZeros=true&hideClock=true&hideCards=true&hideProgress=true&hideMessage=true&hideSecondary=true&hideLogo=true" + "target": "timer", + "search": "timer?showLeadingZeros=true&hideClock=true&hideCards=true&hideProgress=true&hideMessage=true&hideSecondary=true&hideLogo=true" } ], "automation": { diff --git a/packages/types/src/definitions/core/UrlPreset.type.ts b/packages/types/src/definitions/core/UrlPreset.type.ts index 86dc79d67..474bdeb29 100644 --- a/packages/types/src/definitions/core/UrlPreset.type.ts +++ b/packages/types/src/definitions/core/UrlPreset.type.ts @@ -1,5 +1,22 @@ +/** + * Describes all views in Ontime + */ +export enum OntimeView { + Editor = 'editor', + Cuesheet = 'cuesheet', + Operator = 'op', + Timer = 'timer', + Backstage = 'backstage', + Timeline = 'timeline', + StudioClock = 'studio', + Countdown = 'countdown', + ProjectInfo = 'info', +} + export type URLPreset = { + // presets cannot target the editor view + target: Omit; enabled: boolean; alias: string; - pathAndParams: string; + search: string; }; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index e55228849..076e0d5c8 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -52,7 +52,7 @@ export type { ViewSettings } from './definitions/core/Views.type.js'; export type { TimeFormat } from './definitions/core/TimeFormat.type.js'; // ---> URL Presets -export type { URLPreset } from './definitions/core/UrlPreset.type.js'; +export { OntimeView, type URLPreset } from './definitions/core/UrlPreset.type.js'; // ---> Custom Fields export type { diff --git a/packages/utils/src/regex-utils/checkRegex.ts b/packages/utils/src/regex-utils/checkRegex.ts index 2fc6cee5f..fa5f02c07 100644 --- a/packages/utils/src/regex-utils/checkRegex.ts +++ b/packages/utils/src/regex-utils/checkRegex.ts @@ -8,7 +8,6 @@ export const regex = { isASCII: /^[ -~]+$/, // https://catonmat.net/my-favorite-regex isASCIIorEmpty: /^$|^[ -~]+$/, // https://catonmat.net/my-favorite-regex isNotEmpty: /\S/, - isUrlSafe: /^[a-zA-Z0-9_-]*$/, // https://stackoverflow.com/questions/24419067/validate-a-string-to-be-url-safe-using-regex }; export const checkRegex = { @@ -21,5 +20,4 @@ export const checkRegex = { isASCII: (text: string): boolean => regex.isASCII.test(text), isASCIIorEmpty: (text: string): boolean => regex.isASCIIorEmpty.test(text), isNotEmpty: (text: string): boolean => regex.isNotEmpty.test(text), - isUrlSafe: (text: string): boolean => regex.isUrlSafe.test(text), };