mirror of
https://github.com/cpvalente/ontime.git
synced 2026-09-02 04:49:05 +00:00
feat: improve preset building
This commit is contained in:
committed by
Carlos Valente
parent
5bc286145d
commit
a1fab10bfd
@@ -6,7 +6,7 @@ import { apiEntryUrl } from './constants';
|
|||||||
const urlPresetsPath = `${apiEntryUrl}/url-presets`;
|
const urlPresetsPath = `${apiEntryUrl}/url-presets`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTTP request to retrieve aliases
|
* HTTP request to retrieve all presets
|
||||||
*/
|
*/
|
||||||
export async function getUrlPresets(): Promise<URLPreset[]> {
|
export async function getUrlPresets(): Promise<URLPreset[]> {
|
||||||
const res = await axios.get(urlPresetsPath);
|
const res = await axios.get(urlPresetsPath);
|
||||||
@@ -14,8 +14,22 @@ export async function getUrlPresets(): Promise<URLPreset[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTTP request to mutate aliases
|
* HTTP request to add a preset
|
||||||
*/
|
*/
|
||||||
export async function postUrlPresets(data: URLPreset[]): Promise<URLPreset[]> {
|
export async function postUrlPreset(data: URLPreset): Promise<URLPreset[]> {
|
||||||
return axios.post(urlPresetsPath, data);
|
return (await axios.post(urlPresetsPath, data)).data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP request to edit a preset
|
||||||
|
*/
|
||||||
|
export async function putUrlPreset(alias: string, data: URLPreset): Promise<URLPreset[]> {
|
||||||
|
return (await axios.put(`${urlPresetsPath}/${alias}`, data)).data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP request to delete a preset
|
||||||
|
*/
|
||||||
|
export async function deleteUrlPreset(alias: string): Promise<URLPreset[]> {
|
||||||
|
return (await axios.delete(`${urlPresetsPath}/${alias}`)).data;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
* Utility unwraps a potential axios error and sends to logger
|
||||||
* @param prepend
|
* @param prepend
|
||||||
|
|||||||
@@ -45,8 +45,8 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
|
|||||||
label: view.label,
|
label: view.label,
|
||||||
})),
|
})),
|
||||||
...enabledPresets.map((preset) => ({
|
...enabledPresets.map((preset) => ({
|
||||||
value: preset.pathAndParams,
|
value: preset.search,
|
||||||
label: `Preset: ${preset.alias}`,
|
label: `URL Preset: ${preset.alias}`,
|
||||||
})),
|
})),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,8 @@
|
|||||||
|
|
||||||
&:focus:not(:read-only) {
|
&:focus:not(:read-only) {
|
||||||
background-color: $gray-1000;
|
background-color: $gray-1000;
|
||||||
border: 1px solid $blue-500;
|
outline: 2px solid $blue-500;
|
||||||
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:disabled {
|
&:disabled {
|
||||||
|
|||||||
@@ -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 { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
||||||
import { URL_PRESETS } from '../api/constants';
|
import { URL_PRESETS } from '../api/constants';
|
||||||
import { getUrlPresets } from '../api/urlPresets';
|
import { deleteUrlPreset, getUrlPresets, postUrlPreset, putUrlPreset } from '../api/urlPresets';
|
||||||
|
|
||||||
interface FetchProps {
|
interface FetchProps {
|
||||||
skip?: boolean;
|
skip?: boolean;
|
||||||
@@ -13,12 +14,42 @@ export default function useUrlPresets({ skip = false }: FetchProps = {}) {
|
|||||||
queryKey: URL_PRESETS,
|
queryKey: URL_PRESETS,
|
||||||
queryFn: getUrlPresets,
|
queryFn: getUrlPresets,
|
||||||
placeholderData: (previousData, _previousQuery) => previousData,
|
placeholderData: (previousData, _previousQuery) => previousData,
|
||||||
retry: 5,
|
|
||||||
retryDelay: (attempt) => attempt * 2500,
|
|
||||||
refetchInterval: queryRefetchIntervalSlow,
|
refetchInterval: queryRefetchIntervalSlow,
|
||||||
networkMode: 'always',
|
|
||||||
enabled: !skip,
|
enabled: !skip,
|
||||||
});
|
});
|
||||||
|
|
||||||
return { data: data ?? [], status, isError, refetch };
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { resolvePath } from 'react-router-dom';
|
import { resolvePath } from 'react-router-dom';
|
||||||
|
|
||||||
import { arePathsEquivalent, generatePathFromPreset, getRouteFromPreset, validateUrlPresetPath } from '../urlPresets';
|
import {
|
||||||
|
arePathsEquivalent,
|
||||||
|
generatePathFromPreset,
|
||||||
|
generateUrlPresetOptions,
|
||||||
|
getRouteFromPreset,
|
||||||
|
validateUrlPresetPath,
|
||||||
|
} from '../urlPresets';
|
||||||
|
|
||||||
describe('validateUrlPresetPaths()', () => {
|
describe('validateUrlPresetPaths()', () => {
|
||||||
test.each([
|
test.each([
|
||||||
@@ -27,7 +33,8 @@ describe('getRouteFromPreset()', () => {
|
|||||||
{
|
{
|
||||||
enabled: true,
|
enabled: true,
|
||||||
alias: 'demopage',
|
alias: 'demopage',
|
||||||
pathAndParams: '/timer?user=guest',
|
target: 'timer',
|
||||||
|
search: 'user=guest',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -76,14 +83,14 @@ describe('getRouteFromPreset()', () => {
|
|||||||
|
|
||||||
describe('generatePathFromPreset()', () => {
|
describe('generatePathFromPreset()', () => {
|
||||||
test.each([
|
test.each([
|
||||||
['timer?user=guest', 'demopage', 'timer?user=guest&alias=demopage'],
|
['timer', 'user=guest', 'demopage', 'timer?user=guest&alias=demopage'],
|
||||||
['timer?user=admin', 'demopage', 'timer?user=admin&alias=demopage'],
|
['timer', 'user=admin', 'demopage', 'timer?user=admin&alias=demopage'],
|
||||||
])('generates a path from a preset: %s', (path, alias, expected) => {
|
])('generates a path from a preset: %s', (target, path, alias, expected) => {
|
||||||
expect(generatePathFromPreset(path, alias, null, null)).toEqual(expected);
|
expect(generatePathFromPreset(target, path, alias, null, null)).toEqual(expected);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('appends the feature params to the alias', () => {
|
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',
|
'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();
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Path, resolvePath } from 'react-router-dom';
|
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
|
* 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);
|
const foundPreset = urlPresets.find((preset) => preset.alias === removeTrailingSlash(currentURL) && preset.enabled);
|
||||||
if (foundPreset) {
|
if (foundPreset) {
|
||||||
// if so, we can redirect to the preset path
|
// 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
|
// 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) {
|
for (const preset of urlPresets) {
|
||||||
// if the page has a known enabled alias, we check if we need to redirect
|
// if the page has a known enabled alias, we check if we need to redirect
|
||||||
if (preset.alias === presetOnPage && preset.enabled) {
|
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 (!arePathsEquivalent(currentPath, newPath)) {
|
||||||
// if current path is out of date
|
// if current path is out of date
|
||||||
// return new path so we can redirect
|
// 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
|
* Handles generating a path and search parameters from a preset
|
||||||
*/
|
*/
|
||||||
export function generatePathFromPreset(pathAndParams: string, alias: string, locked: string | null, token: string | null ): string {
|
export function generatePathFromPreset(
|
||||||
const path = resolvePath(pathAndParams);
|
target: Omit<OntimeView, 'editor'>,
|
||||||
|
search: string,
|
||||||
|
alias: string,
|
||||||
|
locked: string | null,
|
||||||
|
token: string | null,
|
||||||
|
): string {
|
||||||
|
const path = resolvePath(`${target}?${search}`);
|
||||||
const searchParams = new URLSearchParams(path.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
|
// 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 {
|
export function arePathsEquivalent(currentPath: string, newPath: string): boolean {
|
||||||
const currentUrl = new URL(currentPath, document.location.origin);
|
const currentUrl = new URL(currentPath, document.location.origin);
|
||||||
const newUrl = new URL(newPath, document.location.origin);
|
const newUrl = new URL(newPath, document.location.origin);
|
||||||
|
|
||||||
// check path
|
// check path
|
||||||
if (currentUrl.pathname !== newUrl.pathname) {
|
if (currentUrl.pathname !== newUrl.pathname) {
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// check search params
|
// check search params
|
||||||
// if the params match, we dont need further checks
|
// if the params match, we dont need further checks
|
||||||
if (currentUrl.searchParams.toString() === newUrl.searchParams.toString()) {
|
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
|
// 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();
|
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] || '';
|
||||||
|
}
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ $inner-padding: 1rem;
|
|||||||
th,
|
th,
|
||||||
td {
|
td {
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
|
vertical-align: top;
|
||||||
}
|
}
|
||||||
|
|
||||||
tr:nth-child(even) {
|
tr:nth-child(even) {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import InfoNif from '../network-panel/NetworkInterfaces';
|
|||||||
|
|
||||||
import GenerateLinkFormExport from './GenerateLinkFormExport';
|
import GenerateLinkFormExport from './GenerateLinkFormExport';
|
||||||
import ReportSettings from './ReportSettings';
|
import ReportSettings from './ReportSettings';
|
||||||
import UrlPresetsForm from './UrlPresetsForm';
|
import URLPresets from './URLPresets';
|
||||||
|
|
||||||
export default function FeaturePanel({ location }: PanelBaseProps) {
|
export default function FeaturePanel({ location }: PanelBaseProps) {
|
||||||
const presetsRef = useScrollIntoView<HTMLDivElement>('presets', location);
|
const presetsRef = useScrollIntoView<HTMLDivElement>('presets', location);
|
||||||
@@ -17,7 +17,7 @@ export default function FeaturePanel({ location }: PanelBaseProps) {
|
|||||||
<>
|
<>
|
||||||
<Panel.Header>Sharing and reporting</Panel.Header>
|
<Panel.Header>Sharing and reporting</Panel.Header>
|
||||||
<div ref={presetsRef}>
|
<div ref={presetsRef}>
|
||||||
<UrlPresetsForm />
|
<URLPresets />
|
||||||
</div>
|
</div>
|
||||||
<div ref={linkRef}>
|
<div ref={linkRef}>
|
||||||
<Panel.Section>
|
<Panel.Section>
|
||||||
|
|||||||
+1
-1
@@ -33,7 +33,7 @@ export default function GenerateLinkFormExport({ lockedPath }: GenerateLinkFormE
|
|||||||
{ value: '', label: 'Companion' },
|
{ value: '', label: 'Companion' },
|
||||||
...urlPresetData.map((preset) => ({
|
...urlPresetData.map((preset) => ({
|
||||||
value: preset.alias,
|
value: preset.alias,
|
||||||
label: `Preset: ${preset.alias}`,
|
label: `URL Preset: ${preset.alias}`,
|
||||||
})),
|
})),
|
||||||
];
|
];
|
||||||
}, [lockedPath, urlPresetData]);
|
}, [lockedPath, urlPresetData]);
|
||||||
|
|||||||
@@ -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<FormState>({ 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 (
|
||||||
|
<Panel.Section>
|
||||||
|
<Panel.Card>
|
||||||
|
<Panel.SubHeader>
|
||||||
|
URL presets
|
||||||
|
<Button onClick={openNewForm}>
|
||||||
|
New <IoAdd />
|
||||||
|
</Button>
|
||||||
|
</Panel.SubHeader>
|
||||||
|
<Panel.Divider />
|
||||||
|
<Panel.Section>
|
||||||
|
<Info>
|
||||||
|
URL presets are user pre-defined aliases to Ontime URLs.
|
||||||
|
<br />
|
||||||
|
This URL can contain full configuration including parameters, or simply route to a specific view.
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
The easiest way to get started is to copy an URL from your browser and paste it into the form.
|
||||||
|
<ExternalLink href={urlPresetsDocs}>See the docs</ExternalLink>
|
||||||
|
</Info>
|
||||||
|
</Panel.Section>
|
||||||
|
<Panel.Section>
|
||||||
|
<Panel.Loader isLoading={status === 'pending'} />
|
||||||
|
{formState.isOpen && <URLPresetForm urlPreset={formState.preset} onClose={closeForm} />}
|
||||||
|
<Panel.Table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Enabled</th>
|
||||||
|
<th>Alias</th>
|
||||||
|
<th>Target view</th>
|
||||||
|
<th>URL parameters</th>
|
||||||
|
<th />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.length === 0 && <Panel.TableEmpty handleClick={openNewForm} />}
|
||||||
|
{data.map((preset, index) => {
|
||||||
|
return (
|
||||||
|
<tr key={preset.alias}>
|
||||||
|
<td>
|
||||||
|
<Switch defaultChecked={preset.enabled} onCheckedChange={() => {}} />
|
||||||
|
</td>
|
||||||
|
<td>{preset.alias}</td>
|
||||||
|
<td>{preset.target}</td>
|
||||||
|
<td>{preset.search}</td>
|
||||||
|
<Panel.InlineElements relation='inner' as='td'>
|
||||||
|
<IconButton
|
||||||
|
variant='ghosted-white'
|
||||||
|
onClick={(event) => handleLinks(preset.alias, event)}
|
||||||
|
disabled={isMutating}
|
||||||
|
>
|
||||||
|
<IoOpenOutline />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
onClick={() => openEditForm(preset)}
|
||||||
|
variant='ghosted-white'
|
||||||
|
aria-label='Edit entry'
|
||||||
|
data-testid={`field__edit_${index}`}
|
||||||
|
disabled={isMutating}
|
||||||
|
>
|
||||||
|
<IoPencil />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
onClick={() => deletePreset(preset.alias)}
|
||||||
|
variant='ghosted-destructive'
|
||||||
|
aria-label='Delete entry'
|
||||||
|
data-testid={`field__delete_${index}`}
|
||||||
|
disabled={isMutating}
|
||||||
|
>
|
||||||
|
<IoTrash />
|
||||||
|
</IconButton>
|
||||||
|
</Panel.InlineElements>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</Panel.Table>
|
||||||
|
</Panel.Section>
|
||||||
|
</Panel.Card>
|
||||||
|
</Panel.Section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<FormData>({
|
|
||||||
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 (
|
|
||||||
<Panel.Section
|
|
||||||
as='form'
|
|
||||||
onSubmit={handleSubmit(onSubmit)}
|
|
||||||
onKeyDown={(event) => preventEscape(event, onReset)}
|
|
||||||
data-testid='url-preset-form'
|
|
||||||
>
|
|
||||||
<Panel.Card>
|
|
||||||
<Panel.SubHeader>
|
|
||||||
URL presets
|
|
||||||
<Panel.InlineElements>
|
|
||||||
<Button variant='ghosted' onClick={onReset} disabled={!canSubmit}>
|
|
||||||
Revert to saved
|
|
||||||
</Button>
|
|
||||||
<Button variant='primary' type='submit' disabled={!canSubmit} loading={isSubmitting}>
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
</Panel.InlineElements>
|
|
||||||
</Panel.SubHeader>
|
|
||||||
<Panel.Divider />
|
|
||||||
<Info>
|
|
||||||
URL presets are user defined aliases to Ontime URLs
|
|
||||||
<br />
|
|
||||||
<br />
|
|
||||||
<b>Preset Name</b> <br />
|
|
||||||
The alias for the URL. This will be the URL you will be calling. eg: <br />
|
|
||||||
<Panel.BlockQuote>
|
|
||||||
Preset name <Panel.Highlight>cam3</Panel.Highlight> called as{' '}
|
|
||||||
<Panel.Highlight>http://localhost:4001/cam3</Panel.Highlight>
|
|
||||||
</Panel.BlockQuote>
|
|
||||||
<br />
|
|
||||||
<b>URL Segment</b> <br />
|
|
||||||
The corresponding alias path and configuration parameters. eg: <br />
|
|
||||||
<Panel.BlockQuote>
|
|
||||||
URL segment <Panel.Highlight>backstage?hidePast=true&stopCycle=true</Panel.Highlight> corresponds to
|
|
||||||
complete URL
|
|
||||||
<Panel.Highlight>http://localhost:4001/backstage?hidePast=true&stopCycle=true</Panel.Highlight>
|
|
||||||
</Panel.BlockQuote>
|
|
||||||
<br />
|
|
||||||
You will need to save the changes before the presets are functional.
|
|
||||||
<br />
|
|
||||||
<ExternalLink href={urlPresetsDocs}>See the docs</ExternalLink>
|
|
||||||
</Info>
|
|
||||||
<Panel.Section>
|
|
||||||
<Panel.Loader isLoading={isLoading} />
|
|
||||||
<Panel.Title>
|
|
||||||
Manage presets
|
|
||||||
<Button onClick={addNew}>
|
|
||||||
New <IoAdd />
|
|
||||||
</Button>
|
|
||||||
</Panel.Title>
|
|
||||||
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
|
|
||||||
{errors?.data && <Panel.Error>{errors.data.message}</Panel.Error>}
|
|
||||||
|
|
||||||
<Panel.Table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th className={style.fit}>Active</th>
|
|
||||||
<th className={style.aliasConstrain}>Preset name</th>
|
|
||||||
<th className={style.fullWidth}>URL segment</th>
|
|
||||||
<th />
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{fields.length === 0 && <Panel.TableEmpty handleClick={addNew} />}
|
|
||||||
{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 (
|
|
||||||
<tr key={preset.id}>
|
|
||||||
<td className={style.fit}>
|
|
||||||
<Switch
|
|
||||||
size='large'
|
|
||||||
checked={watch(`data.${index}.enabled`)}
|
|
||||||
onCheckedChange={(value: boolean) =>
|
|
||||||
setValue(`data.${index}.enabled`, value, { shouldDirty: true })
|
|
||||||
}
|
|
||||||
data-testid={`field__enable_${index}`}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td className={style.aliasConstrain}>
|
|
||||||
<Input
|
|
||||||
{...register(`data.${index}.alias`, {
|
|
||||||
required: { value: true, message: 'Required field' },
|
|
||||||
})}
|
|
||||||
fluid
|
|
||||||
placeholder='URL Preset'
|
|
||||||
data-testid={`field__alias_${index}`}
|
|
||||||
/>
|
|
||||||
<Panel.Error>{maybeAliasError}</Panel.Error>
|
|
||||||
</td>
|
|
||||||
<td className={style.fullWidth}>
|
|
||||||
<Input
|
|
||||||
{...register(`data.${index}.pathAndParams`, {
|
|
||||||
required: { value: true, message: 'Required field' },
|
|
||||||
})}
|
|
||||||
fluid
|
|
||||||
placeholder='URL (portion after ontime Port)'
|
|
||||||
data-testid={`field__url_${index}`}
|
|
||||||
/>
|
|
||||||
<Panel.Error>{maybeUrlError}</Panel.Error>
|
|
||||||
</td>
|
|
||||||
<Panel.InlineElements relation='inner' as='td'>
|
|
||||||
<Tooltip
|
|
||||||
text='Test preset'
|
|
||||||
render={<IconButton variant='ghosted-white' />}
|
|
||||||
data-testid={`field__test_${index}`}
|
|
||||||
onClick={(event) => handleLinks(preset.alias, event)}
|
|
||||||
disabled={!canTest}
|
|
||||||
>
|
|
||||||
<IoOpenOutline />
|
|
||||||
</Tooltip>
|
|
||||||
<IconButton
|
|
||||||
onClick={() => remove(index)}
|
|
||||||
variant='ghosted-destructive'
|
|
||||||
aria-label='Delete entry'
|
|
||||||
data-testid={`field__delete_${index}`}
|
|
||||||
>
|
|
||||||
<IoTrash />
|
|
||||||
</IconButton>
|
|
||||||
</Panel.InlineElements>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</Panel.Table>
|
|
||||||
</Panel.Section>
|
|
||||||
</Panel.Card>
|
|
||||||
</Panel.Section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
+9
@@ -0,0 +1,9 @@
|
|||||||
|
.expand {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
+154
@@ -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<URLPreset>({
|
||||||
|
defaultValues: urlPreset ?? defaultValues,
|
||||||
|
resetOptions: {
|
||||||
|
keepDirtyValues: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const urlRef = useRef<HTMLInputElement>(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 (
|
||||||
|
<Panel.Indent
|
||||||
|
as='form'
|
||||||
|
onSubmit={handleSubmit(setupSubmit)}
|
||||||
|
onKeyDown={(event) => preventEscape(event, onClose)}
|
||||||
|
className={style.column}
|
||||||
|
>
|
||||||
|
<input hidden name='enabled' value='true' />
|
||||||
|
|
||||||
|
<div>1. Enter URL and let Ontime generate the preset options</div>
|
||||||
|
<Panel.InlineElements>
|
||||||
|
<div>
|
||||||
|
<Panel.Description>Alias</Panel.Description>
|
||||||
|
<Input {...register('alias', { required: 'Alias is required' })} />
|
||||||
|
</div>
|
||||||
|
<div className={style.expand}>
|
||||||
|
<Panel.Description>Generate options (paste URL to generate options)</Panel.Description>
|
||||||
|
<Panel.InlineElements>
|
||||||
|
<Input placeholder='Paste URL' fluid ref={urlRef} required />
|
||||||
|
<Button onClick={generateOptions}>Generate</Button>
|
||||||
|
</Panel.InlineElements>
|
||||||
|
</div>
|
||||||
|
</Panel.InlineElements>
|
||||||
|
<div> - or -</div>
|
||||||
|
<div>2. Choose a view and its parameters</div>
|
||||||
|
<div>
|
||||||
|
<Panel.Description>Target</Panel.Description>
|
||||||
|
<Select
|
||||||
|
options={targetOptions}
|
||||||
|
{...register('target', { required: 'Target is required' })}
|
||||||
|
value={watch('target') as OntimeView}
|
||||||
|
onValueChange={(value) => setValue('target', value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Panel.Description>Parameters</Panel.Description>
|
||||||
|
<Input
|
||||||
|
fluid
|
||||||
|
{...register('search', {
|
||||||
|
validate: validateParams,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<Panel.Error>{errors.search?.message}</Panel.Error>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Panel.Error>{errors.root?.message}</Panel.Error>
|
||||||
|
<Panel.InlineElements align='end'>
|
||||||
|
<Button onClick={onClose}>Cancel</Button>
|
||||||
|
<Button variant='primary' type='submit' disabled={!isValid || !isDirty} loading={isSubmitting || isMutating}>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</Panel.InlineElements>
|
||||||
|
</div>
|
||||||
|
</Panel.Indent>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,13 +12,14 @@ describe('parseUrlPresets()', () => {
|
|||||||
|
|
||||||
it('parses data, skipping invalid results', () => {
|
it('parses data, skipping invalid results', () => {
|
||||||
const errorEmitter = vi.fn();
|
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);
|
const result = parseUrlPresets({ urlPresets }, errorEmitter);
|
||||||
expect(result.length).toEqual(1);
|
expect(result.length).toEqual(1);
|
||||||
expect(result.at(0)).toMatchObject({
|
expect(result.at(0)).toMatchObject({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
alias: 'alias',
|
alias: 'alias',
|
||||||
pathAndParams: 'ss',
|
target: 'timer',
|
||||||
|
search: 'ss',
|
||||||
});
|
});
|
||||||
expect(errorEmitter).not.toHaveBeenCalled();
|
expect(errorEmitter).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -33,7 +34,8 @@ describe('parseUrlPresets()', () => {
|
|||||||
{
|
{
|
||||||
enabled: false,
|
enabled: false,
|
||||||
alias: 'testalias',
|
alias: 'testalias',
|
||||||
pathAndParams: 'testpathAndParams',
|
target: 'timer',
|
||||||
|
search: 'testpathAndParams',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
} as unknown as DatabaseModel;
|
} as unknown as DatabaseModel;
|
||||||
|
|||||||
@@ -16,10 +16,16 @@ export function parseUrlPresets(data: Partial<DatabaseModel>, emitError?: ErrorE
|
|||||||
const newPresets: URLPreset[] = [];
|
const newPresets: URLPreset[] = [];
|
||||||
|
|
||||||
for (const preset of data.urlPresets) {
|
for (const preset of data.urlPresets) {
|
||||||
|
if (!preset.alias || !preset.search || !preset.target) {
|
||||||
|
emitError?.(`Invalid URL preset: ${JSON.stringify(preset)}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const newPreset = {
|
const newPreset = {
|
||||||
enabled: preset.enabled ?? false,
|
enabled: preset.enabled ?? false,
|
||||||
alias: preset.alias ?? '',
|
alias: preset.alias,
|
||||||
pathAndParams: preset.pathAndParams ?? '',
|
target: preset.target,
|
||||||
|
search: preset.search,
|
||||||
};
|
};
|
||||||
newPresets.push(newPreset);
|
newPresets.push(newPreset);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import express from 'express';
|
|||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import type { ErrorResponse, URLPreset } from 'ontime-types';
|
import type { ErrorResponse, URLPreset } from 'ontime-types';
|
||||||
import { getErrorMessage } from 'ontime-utils';
|
import { getErrorMessage } from 'ontime-utils';
|
||||||
import { validateUrlPresets } from './urlPresets.validation.js';
|
|
||||||
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
|
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
|
||||||
|
import { validateNewPreset, validatePresetParam, validateUpdatePreset } from './urlPresets.validation.js';
|
||||||
|
|
||||||
export const router = express.Router();
|
export const router = express.Router();
|
||||||
|
|
||||||
@@ -12,13 +12,64 @@ router.get('/', (_req: Request, res: Response<URLPreset[]>) => {
|
|||||||
res.status(200).send(presets as URLPreset[]);
|
res.status(200).send(presets as URLPreset[]);
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/', validateUrlPresets, async (req: Request, res: Response<URLPreset[] | ErrorResponse>) => {
|
router.post('/', validateNewPreset, async (req: Request, res: Response<URLPreset[] | ErrorResponse>) => {
|
||||||
try {
|
try {
|
||||||
const newPresets: URLPreset[] = req.body.map((preset: URLPreset) => ({
|
const newPreset: URLPreset = {
|
||||||
enabled: preset.enabled,
|
enabled: req.body.enabled,
|
||||||
alias: preset.alias,
|
alias: req.body.alias,
|
||||||
pathAndParams: preset.pathAndParams,
|
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<URLPreset[] | ErrorResponse>) => {
|
||||||
|
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<URLPreset[] | ErrorResponse>) => {
|
||||||
|
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);
|
await getDataProvider().setUrlPresets(newPresets);
|
||||||
res.status(200).send(newPresets);
|
res.status(200).send(newPresets);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -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';
|
import { requestValidationFunction } from '../validation-utils/validationFunction.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* validate array of URL preset objects
|
* validate array of URL preset objects
|
||||||
*/
|
*/
|
||||||
export const validateUrlPresets = [
|
export const validateNewPreset = [
|
||||||
body().isArray().withMessage('No array found in request'),
|
body().isObject().withMessage('No data found in request'),
|
||||||
body('*.enabled').isBoolean(),
|
body('enabled').isBoolean(),
|
||||||
body('*.alias').isString().trim().notEmpty(),
|
body('alias').isString().trim().notEmpty(),
|
||||||
body('*.pathAndParams').isString().trim().notEmpty(),
|
body('target').isString().trim().notEmpty().isIn(Object.values(OntimeView)),
|
||||||
|
body('search').isString().trim(),
|
||||||
|
|
||||||
requestValidationFunction,
|
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];
|
||||||
|
|||||||
@@ -88,8 +88,8 @@ describe('safeMerge', () => {
|
|||||||
it('should merge the urlPresets key when present', () => {
|
it('should merge the urlPresets key when present', () => {
|
||||||
const newData = {
|
const newData = {
|
||||||
urlPresets: [
|
urlPresets: [
|
||||||
{ enabled: true, alias: 'alias1', pathAndParams: '' },
|
{ enabled: true, alias: 'alias1', search: '' },
|
||||||
{ enabled: true, alias: 'alias2', pathAndParams: '' },
|
{ enabled: true, alias: 'alias2', search: '' },
|
||||||
] as URLPreset[],
|
] as URLPreset[],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -553,14 +553,16 @@ export const demoDb: DatabaseModel = {
|
|||||||
{
|
{
|
||||||
enabled: true,
|
enabled: true,
|
||||||
alias: 'clock',
|
alias: 'clock',
|
||||||
pathAndParams:
|
target: 'timer',
|
||||||
'timer?showLeadingZeros=true&timerType=clock&hideClock=true&hideCards=true&hideProgress=true&hideMessage=true&hideSecondary=true&hideLogo=true',
|
search:
|
||||||
|
'showLeadingZeros=true&timerType=clock&hideClock=true&hideCards=true&hideProgress=true&hideMessage=true&hideSecondary=true&hideLogo=true',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
enabled: true,
|
enabled: true,
|
||||||
alias: 'minimal',
|
alias: 'minimal',
|
||||||
pathAndParams:
|
target: 'timer',
|
||||||
'timer?showLeadingZeros=true&hideClock=true&hideCards=true&hideProgress=true&hideMessage=true&hideSecondary=true&hideLogo=true',
|
search:
|
||||||
|
'showLeadingZeros=true&hideClock=true&hideCards=true&hideProgress=true&hideMessage=true&hideSecondary=true&hideLogo=true',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
automation: {
|
automation: {
|
||||||
|
|||||||
@@ -488,12 +488,14 @@
|
|||||||
{
|
{
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"alias": "clock",
|
"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,
|
"enabled": true,
|
||||||
"alias": "minimal",
|
"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": {
|
"automation": {
|
||||||
|
|||||||
@@ -8,16 +8,19 @@ test('URL preset feature, it should redirect to given URL', async ({ page }) =>
|
|||||||
await page.getByRole('button', { name: 'URL Presets' }).click();
|
await page.getByRole('button', { name: 'URL Presets' }).click();
|
||||||
|
|
||||||
// create preset
|
// create preset
|
||||||
await page.getByTestId('url-preset-form').getByRole('button', { name: 'New' }).scrollIntoViewIfNeeded();
|
await page.getByRole('heading', { name: 'URL presets New' }).getByRole('button').scrollIntoViewIfNeeded();
|
||||||
await page.getByTestId('url-preset-form').getByRole('button', { name: 'New' }).click();
|
await page.getByRole('heading', { name: 'URL presets New' }).getByRole('button').click();
|
||||||
|
|
||||||
await page.getByTestId('field__alias_0').click();
|
await page.locator('input[name="alias"]').click();
|
||||||
await page.getByTestId('field__alias_0').fill('testing');
|
await page.locator('input[name="alias"]').fill('testing');
|
||||||
|
|
||||||
await page.getByTestId('field__url_0').click();
|
await page.getByRole('textbox', { name: 'Paste URL' }).click();
|
||||||
await page.getByTestId('field__url_0').fill('countdown');
|
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
|
// make sure preset works
|
||||||
await page.goto('http://localhost:4001/testing');
|
await page.goto('http://localhost:4001/testing');
|
||||||
|
|||||||
Vendored
+4
-2
@@ -505,12 +505,14 @@
|
|||||||
{
|
{
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"alias": "clock",
|
"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,
|
"enabled": true,
|
||||||
"alias": "minimal",
|
"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": {
|
"automation": {
|
||||||
|
|||||||
@@ -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 = {
|
export type URLPreset = {
|
||||||
|
// presets cannot target the editor view
|
||||||
|
target: Omit<OntimeView, 'editor'>;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
alias: string;
|
alias: string;
|
||||||
pathAndParams: string;
|
search: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export type { ViewSettings } from './definitions/core/Views.type.js';
|
|||||||
export type { TimeFormat } from './definitions/core/TimeFormat.type.js';
|
export type { TimeFormat } from './definitions/core/TimeFormat.type.js';
|
||||||
|
|
||||||
// ---> URL Presets
|
// ---> URL Presets
|
||||||
export type { URLPreset } from './definitions/core/UrlPreset.type.js';
|
export { OntimeView, type URLPreset } from './definitions/core/UrlPreset.type.js';
|
||||||
|
|
||||||
// ---> Custom Fields
|
// ---> Custom Fields
|
||||||
export type {
|
export type {
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ export const regex = {
|
|||||||
isASCII: /^[ -~]+$/, // https://catonmat.net/my-favorite-regex
|
isASCII: /^[ -~]+$/, // https://catonmat.net/my-favorite-regex
|
||||||
isASCIIorEmpty: /^$|^[ -~]+$/, // https://catonmat.net/my-favorite-regex
|
isASCIIorEmpty: /^$|^[ -~]+$/, // https://catonmat.net/my-favorite-regex
|
||||||
isNotEmpty: /\S/,
|
isNotEmpty: /\S/,
|
||||||
isUrlSafe: /^[a-zA-Z0-9_-]*$/, // https://stackoverflow.com/questions/24419067/validate-a-string-to-be-url-safe-using-regex
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const checkRegex = {
|
export const checkRegex = {
|
||||||
@@ -21,5 +20,4 @@ export const checkRegex = {
|
|||||||
isASCII: (text: string): boolean => regex.isASCII.test(text),
|
isASCII: (text: string): boolean => regex.isASCII.test(text),
|
||||||
isASCIIorEmpty: (text: string): boolean => regex.isASCIIorEmpty.test(text),
|
isASCIIorEmpty: (text: string): boolean => regex.isASCIIorEmpty.test(text),
|
||||||
isNotEmpty: (text: string): boolean => regex.isNotEmpty.test(text),
|
isNotEmpty: (text: string): boolean => regex.isNotEmpty.test(text),
|
||||||
isUrlSafe: (text: string): boolean => regex.isUrlSafe.test(text),
|
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user