feat(url-presets): allow presets to appear as nav menu items (#2110)

This commit is contained in:
Carlos Valente
2026-06-28 18:07:02 +02:00
committed by GitHub
parent 41a09bf5c3
commit 1553573b94
16 changed files with 86 additions and 7 deletions
@@ -8,6 +8,7 @@ import { useLocation } from 'react-router';
import { isLocalhost, supportsFullscreen } from '../../../externals'; import { isLocalhost, supportsFullscreen } from '../../../externals';
import { canUseWakeLock, useKeepAwakeOptions } from '../../../features/keep-awake/useWakeLock'; import { canUseWakeLock, useKeepAwakeOptions } from '../../../features/keep-awake/useWakeLock';
import { navigatorConstants } from '../../../viewerConfig'; import { navigatorConstants } from '../../../viewerConfig';
import useUrlPresets from '../../hooks-query/useUrlPresets';
import { useIsSmallScreen } from '../../hooks/useIsSmallScreen'; import { useIsSmallScreen } from '../../hooks/useIsSmallScreen';
import { useClientStore } from '../../stores/clientStore'; import { useClientStore } from '../../stores/clientStore';
import { useViewOptionsStore } from '../../stores/viewOptions'; import { useViewOptionsStore } from '../../stores/viewOptions';
@@ -105,6 +106,8 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
{route.label} {route.label}
</ClientLink> </ClientLink>
))} ))}
<PresetNavigation isSmallScreen={isSmallScreen} onClose={onClose} />
</div> </div>
{isLocalhost && ( {isLocalhost && (
@@ -117,3 +120,27 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
</Dialog.Root> </Dialog.Root>
); );
} }
function PresetNavigation({ isSmallScreen, onClose }: { isSmallScreen: boolean; onClose: () => void }) {
const location = useLocation();
const { data: urlPresets } = useUrlPresets();
const navPresets = urlPresets.filter((preset) => preset.enabled && preset.displayInNav);
if (navPresets.length === 0) return null;
return (
<>
<hr className={style.separator} />
{navPresets.map((preset) => (
<ClientLink
key={preset.alias}
to={`preset/${preset.alias}`}
current={location.pathname === `/preset/${preset.alias}`}
postAction={isSmallScreen ? onClose : undefined}
>
{preset.alias}
</ClientLink>
))}
</>
);
}
@@ -37,6 +37,7 @@ describe('getRouteFromPreset()', () => {
alias: 'demopage', alias: 'demopage',
target: OntimeView.Timer, target: OntimeView.Timer,
search: 'user=guest', search: 'user=guest',
displayInNav: false,
options: {}, options: {},
}, },
]; ];
@@ -119,6 +120,7 @@ describe('getRouteFromPreset()', () => {
alias: 'cuesheet-4685d6', alias: 'cuesheet-4685d6',
target: OntimeView.Cuesheet, target: OntimeView.Cuesheet,
search: '', search: '',
displayInNav: false,
options: { options: {
read: 'full', read: 'full',
write: '-', write: '-',
@@ -131,6 +133,7 @@ describe('getRouteFromPreset()', () => {
alias: 'cuesheet-basic', alias: 'cuesheet-basic',
target: OntimeView.Cuesheet, target: OntimeView.Cuesheet,
search: '', search: '',
displayInNav: false,
}, },
]; ];
const cuesheetPresetWithNavLock: URLPreset[] = [ const cuesheetPresetWithNavLock: URLPreset[] = [
@@ -139,6 +142,7 @@ describe('getRouteFromPreset()', () => {
alias: 'cuesheet-locked', alias: 'cuesheet-locked',
target: OntimeView.Cuesheet, target: OntimeView.Cuesheet,
search: 'n=1', search: 'n=1',
displayInNav: false,
}, },
]; ];
@@ -220,6 +224,7 @@ describe('generateUrlPresetOptions', () => {
target: 'timer', target: 'timer',
search: 'param1=value1&param2=value2', search: 'param1=value1&param2=value2',
enabled: true, enabled: true,
displayInNav: false,
}, },
], ],
[ [
@@ -231,6 +236,7 @@ describe('generateUrlPresetOptions', () => {
target: 'timer', target: 'timer',
search: 'param1=value1&param2=value2', search: 'param1=value1&param2=value2',
enabled: true, enabled: true,
displayInNav: false,
}, },
], ],
[ [
@@ -242,6 +248,7 @@ describe('generateUrlPresetOptions', () => {
target: 'timer', target: 'timer',
search: 'param1=value1&param2=value2', search: 'param1=value1&param2=value2',
enabled: true, enabled: true,
displayInNav: false,
}, },
], ],
[ [
@@ -253,6 +260,7 @@ describe('generateUrlPresetOptions', () => {
target: 'timer', target: 'timer',
search: 'param1=value1&param2=value2', search: 'param1=value1&param2=value2',
enabled: true, enabled: true,
displayInNav: false,
}, },
], ],
])('should generate URL preset options for %s', (_description, alias, url, expected) => { ])('should generate URL preset options for %s', (_description, alias, url, expected) => {
@@ -194,6 +194,7 @@ export function generateUrlPresetOptions(alias: string, userUrl: string): URLPre
target: path, target: path,
search: url.searchParams.toString(), search: url.searchParams.toString(),
enabled: true, enabled: true,
displayInNav: false,
}; };
} }
@@ -2,6 +2,7 @@ import { URLPreset } from 'ontime-types';
import { useState } from 'react'; import { useState } from 'react';
import { IoAdd, IoOpenOutline, IoPencil, IoTrash } from 'react-icons/io5'; import { IoAdd, IoOpenOutline, IoPencil, IoTrash } from 'react-icons/io5';
import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button'; import Button from '../../../../common/components/buttons/Button';
import IconButton from '../../../../common/components/buttons/IconButton'; import IconButton from '../../../../common/components/buttons/IconButton';
import Info from '../../../../common/components/info/Info'; import Info from '../../../../common/components/info/Info';
@@ -22,13 +23,23 @@ const urlPresetsDocs = 'https://docs.getontime.no/features/url-presets/';
export default function URLPresets() { export default function URLPresets() {
const [formState, setFormState] = useState<FormState>({ isOpen: false, preset: undefined }); const [formState, setFormState] = useState<FormState>({ isOpen: false, preset: undefined });
const [actionError, setActionError] = useState<string | null>(null);
const { data, status } = useUrlPresets(); const { data, status } = useUrlPresets();
const { deletePreset, isMutating } = useUpdateUrlPreset(); const { updatePreset, deletePreset, isMutating } = useUpdateUrlPreset();
const openNewForm = () => setFormState({ isOpen: true }); const openNewForm = () => setFormState({ isOpen: true });
const openEditForm = (preset: URLPreset) => setFormState({ isOpen: true, preset }); const openEditForm = (preset: URLPreset) => setFormState({ isOpen: true, preset });
const closeForm = () => setFormState({ isOpen: false, preset: undefined }); const closeForm = () => setFormState({ isOpen: false, preset: undefined });
const persistPreset = async (preset: URLPreset) => {
setActionError(null);
try {
await updatePreset(preset.alias, preset);
} catch (error) {
setActionError(maybeAxiosError(error));
}
};
return ( return (
<Panel.Section> <Panel.Section>
<Panel.Card> <Panel.Card>
@@ -53,10 +64,12 @@ export default function URLPresets() {
<Panel.Section> <Panel.Section>
<Panel.Loader isLoading={status === 'pending'} /> <Panel.Loader isLoading={status === 'pending'} />
{formState.isOpen && <URLPresetForm urlPreset={formState.preset} onClose={closeForm} />} {formState.isOpen && <URLPresetForm urlPreset={formState.preset} onClose={closeForm} />}
{actionError && <Panel.Error>{actionError}</Panel.Error>}
<Panel.Table> <Panel.Table>
<thead> <thead>
<tr> <tr>
<th>Enabled</th> <th>Enabled</th>
<th>Show in nav</th>
<th>Target view</th> <th>Target view</th>
<th>Alias</th> <th>Alias</th>
<th /> <th />
@@ -68,7 +81,18 @@ export default function URLPresets() {
return ( return (
<tr key={preset.alias}> <tr key={preset.alias}>
<td> <td>
<Switch defaultChecked={preset.enabled} onCheckedChange={() => {}} /> <Switch
checked={preset.enabled}
onCheckedChange={(checked) => persistPreset({ ...preset, enabled: checked })}
disabled={isMutating}
/>
</td>
<td>
<Switch
checked={preset.displayInNav}
onCheckedChange={(checked) => persistPreset({ ...preset, displayInNav: checked })}
disabled={isMutating}
/>
</td> </td>
<td> <td>
<Tag>{preset.target}</Tag> <Tag>{preset.target}</Tag>
@@ -32,6 +32,7 @@ const defaultValues: URLPreset = {
target: OntimeView.Timer, target: OntimeView.Timer,
search: '', search: '',
enabled: true, enabled: true,
displayInNav: false,
}; };
interface URLPresetFormProps { interface URLPresetFormProps {
@@ -95,6 +95,7 @@ export default function GenerateLinkForm({ hostOptions, pathOptions, presets, is
enabled: true, enabled: true,
alias, alias,
search: '', search: '',
displayInNav: false,
options: { options: {
read: options.read, read: options.read,
write: options.write, write: options.write,
@@ -20,6 +20,7 @@ describe('getCuesheetPermissionsPolicy()', () => {
alias: 'cuesheet-read-only', alias: 'cuesheet-read-only',
target: OntimeView.Cuesheet, target: OntimeView.Cuesheet,
search: '', search: '',
displayInNav: false,
options: { options: {
read: 'full', read: 'full',
write: '-', write: '-',
@@ -41,6 +42,7 @@ describe('getCuesheetPermissionsPolicy()', () => {
alias: 'cuesheet-flag', alias: 'cuesheet-flag',
target: OntimeView.Cuesheet, target: OntimeView.Cuesheet,
search: '', search: '',
displayInNav: false,
options: { options: {
read: 'full', read: 'full',
write: 'flag', write: 'flag',
@@ -62,6 +64,7 @@ describe('getCuesheetPermissionsPolicy()', () => {
alias: 'cuesheet-default', alias: 'cuesheet-default',
target: OntimeView.Cuesheet, target: OntimeView.Cuesheet,
search: '', search: '',
displayInNav: false,
}; };
const policy = getCuesheetColumnAccessPolicy(preset, AppMode.Edit); const policy = getCuesheetColumnAccessPolicy(preset, AppMode.Edit);
@@ -76,6 +79,7 @@ describe('getCuesheetPermissionsPolicy()', () => {
alias: 'cuesheet-granular', alias: 'cuesheet-granular',
target: OntimeView.Cuesheet, target: OntimeView.Cuesheet,
search: '', search: '',
displayInNav: false,
options: { options: {
read: 'cue,title', read: 'cue,title',
write: 'title', write: 'title',
@@ -110,14 +110,13 @@ type old_URLPreset = {
/** /**
* migrates a url presets from v3 to v4 * migrates a url presets from v3 to v4
* - pathAndParams split into a target and search * - pathAndParams split into a target and search
*
*/ */
export function migrateURLPresets(jsonData: object): URLPreset[] | undefined { export function migrateURLPresets(jsonData: object): URLPreset[] | undefined {
if (is.objectWithKeys(jsonData, ['urlPresets']) && is.array(jsonData.urlPresets)) { if (is.objectWithKeys(jsonData, ['urlPresets']) && is.array(jsonData.urlPresets)) {
const oldURLPresets = structuredClone(jsonData.urlPresets) as old_URLPreset; const oldURLPresets = structuredClone(jsonData.urlPresets) as old_URLPreset;
const newURLPreset: URLPreset[] = oldURLPresets.map(({ enabled, alias, pathAndParams }) => { const newURLPreset: URLPreset[] = oldURLPresets.map(({ enabled, alias, pathAndParams }) => {
const [target, search] = pathAndParams.split('?'); const [target, search] = pathAndParams.split('?');
return { enabled, alias, target, search, options: {} } as URLPreset; return { enabled, alias, target, search, displayInNav: false, options: {} } as URLPreset;
}); });
return newURLPreset; return newURLPreset;
} }
@@ -208,6 +208,7 @@ describe('v3 to v4', () => {
target: OntimeView.Timer, target: OntimeView.Timer,
search: search:
'showLeadingZeros=true&timerType=clock&hideClock=true&hideCards=true&hideProgress=true&hideMessage=true&hideSecondary=true&hideLogo=true', 'showLeadingZeros=true&timerType=clock&hideClock=true&hideCards=true&hideProgress=true&hideMessage=true&hideSecondary=true&hideLogo=true',
displayInNav: false,
options: {}, options: {},
}, },
{ {
@@ -216,6 +217,7 @@ describe('v3 to v4', () => {
target: OntimeView.Timer, target: OntimeView.Timer,
search: search:
'showLeadingZeros=true&hideClock=true&hideCards=true&hideProgress=true&hideMessage=true&hideSecondary=true&hideLogo=true', 'showLeadingZeros=true&hideClock=true&hideCards=true&hideProgress=true&hideMessage=true&hideSecondary=true&hideLogo=true',
displayInNav: false,
options: {}, options: {},
}, },
]; ];
@@ -12,7 +12,9 @@ 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', target: 'timer', search: 'ss' }] as URLPreset[]; const urlPresets = [
{ enabled: true, alias: 'alias', target: 'timer', search: 'ss', displayInNav: false },
] 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({
@@ -36,6 +38,7 @@ describe('parseUrlPresets()', () => {
alias: 'testalias', alias: 'testalias',
target: 'timer', target: 'timer',
search: 'testpathAndParams', search: 'testpathAndParams',
displayInNav: false,
}, },
], ],
} as unknown as DatabaseModel; } as unknown as DatabaseModel;
@@ -26,6 +26,7 @@ export function parseUrlPresets(data: Partial<DatabaseModel>, emitError?: ErrorE
alias: preset.alias, alias: preset.alias,
target: preset.target, target: preset.target,
search: preset.search ?? '', search: preset.search ?? '',
displayInNav: preset.displayInNav ?? false,
options: preset?.options, options: preset?.options,
}; };
newPresets.push(newPreset); newPresets.push(newPreset);
@@ -21,6 +21,7 @@ router.post('/', validateNewPreset, async (req: Request, res: Response<URLPreset
alias: req.body.alias, alias: req.body.alias,
target: req.body.target, target: req.body.target,
search: req.body.search, search: req.body.search,
displayInNav: req.body.displayInNav,
options: req.body.options, options: req.body.options,
}; };
@@ -55,6 +56,7 @@ router.put('/:alias', validateUpdatePreset, async (req: Request, res: Response<U
alias: req.body.alias, alias: req.body.alias,
target: req.body.target, target: req.body.target,
search: req.body.search, search: req.body.search,
displayInNav: req.body.displayInNav,
options: req.body.options ?? existingPreset.options, options: req.body.options ?? existingPreset.options,
}; };
@@ -12,6 +12,7 @@ export const validateNewPreset = [
body('alias').isString().trim().notEmpty(), body('alias').isString().trim().notEmpty(),
body('target').isString().trim().notEmpty().isIn(Object.values(OntimeView)), body('target').isString().trim().notEmpty().isIn(Object.values(OntimeView)),
body('search').isString().trim(), body('search').isString().trim(),
body('displayInNav').isBoolean(),
// options are currently only provided for cuesheet presets // options are currently only provided for cuesheet presets
body('options').optional().isObject(), body('options').optional().isObject(),
@@ -27,6 +28,7 @@ export const validateUpdatePreset = [
body('alias').isString().trim().notEmpty(), body('alias').isString().trim().notEmpty(),
body('target').isString().trim().notEmpty().isIn(Object.values(OntimeView)), body('target').isString().trim().notEmpty().isIn(Object.values(OntimeView)),
body('search').isString().trim(), body('search').isString().trim(),
body('displayInNav').isBoolean(),
// options are currently only provided for cuesheet presets // options are currently only provided for cuesheet presets
body('options').optional().isObject(), body('options').optional().isObject(),
@@ -87,8 +87,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', search: '' }, { enabled: true, alias: 'alias1', target: 'timer', search: '', displayInNav: false },
{ enabled: true, alias: 'alias2', search: '' }, { enabled: true, alias: 'alias2', target: 'timer', search: '', displayInNav: false },
] as URLPreset[], ] as URLPreset[],
}; };
+2
View File
@@ -43,6 +43,7 @@ export const demoDb: DatabaseModel = {
target: OntimeView.Timer, target: OntimeView.Timer,
search: search:
'showLeadingZeros=true&timerType=clock&hideClock=true&hideCards=true&hideProgress=true&hideMessage=true&hideSecondary=true&hideLogo=true', 'showLeadingZeros=true&timerType=clock&hideClock=true&hideCards=true&hideProgress=true&hideMessage=true&hideSecondary=true&hideLogo=true',
displayInNav: false,
}, },
{ {
enabled: true, enabled: true,
@@ -50,6 +51,7 @@ export const demoDb: DatabaseModel = {
target: OntimeView.Timer, target: OntimeView.Timer,
search: search:
'hideclock=true&hidecards=true&hideprogress=true&hidemessage=true&hidesecondary=true&hidelogo=true&font=arial+black&keycolour=00ff00&timerColour=ffffff', 'hideclock=true&hidecards=true&hideprogress=true&hidemessage=true&hidesecondary=true&hidelogo=true&font=arial+black&keycolour=00ff00&timerColour=ffffff',
displayInNav: false,
}, },
], ],
customFields: { customFields: {
@@ -20,6 +20,7 @@ type BaseURLPreset = {
enabled: boolean; enabled: boolean;
alias: string; alias: string;
search: string; search: string;
displayInNav: boolean;
options?: Record<string, string>; options?: Record<string, string>;
}; };
@@ -28,6 +29,7 @@ type CuesheetUrlPreset = {
enabled: boolean; enabled: boolean;
alias: string; alias: string;
search: string; search: string;
displayInNav: boolean;
options?: { options?: {
read?: string; read?: string;
write?: string; write?: string;