feat: expose presets in view options

This commit is contained in:
Carlos Valente
2025-07-21 16:19:09 +02:00
committed by Carlos Valente
parent 5f8a5c4995
commit 307f0ffd34
16 changed files with 111 additions and 27 deletions
@@ -0,0 +1,45 @@
import { useSearchParams } from 'react-router-dom';
import { OntimeView, URLPreset } from 'ontime-types';
import { useViewUrlPresets } from '../../hooks-query/useUrlPresets';
import { cx } from '../../utils/styleUtils';
import Button from '../buttons/Button';
import style from './ViewParamsShare.module.scss';
/**
* Shows a list of presets for the current view
*/
export function ViewParamsShare({ target }: { target: OntimeView }) {
const { viewPresets } = useViewUrlPresets(target);
const [_, setSearchParams] = useSearchParams();
const handleRecall = (preset: URLPreset) => {
setSearchParams(`${preset.search}&alias=${preset.alias}`);
};
if (viewPresets.length === 0) {
return null;
}
return (
<div className={style.presetSection}>
{viewPresets.map((preset) => {
const active = window.location.search.includes(`alias=${preset.alias}`);
return (
<div key={preset.alias} className={cx([style.preset, active && style.active])}>
<div>{preset.alias}</div>
<Button
variant='subtle-white'
onClick={() => handleRecall(preset)}
disabled={active}
className={style.presetActions}
>
{active ? 'Applied' : 'Apply'}
</Button>
</div>
);
})}
</div>
);
}
@@ -1,7 +1,8 @@
import { FormEvent, memo } from 'react';
import { FormEvent, memo, useReducer } from 'react';
import { IoClose } from 'react-icons/io5';
import { useSearchParams } from 'react-router-dom';
import { Dialog } from '@base-ui-components/react/dialog';
import { OntimeView } from 'ontime-types';
import useViewSettings from '../../hooks-query/useViewSettings';
import Button from '../buttons/Button';
@@ -11,20 +12,23 @@ import Info from '../info/Info';
import { ViewOption } from './viewParams.types';
import { getURLSearchParamsFromObj } from './viewParams.utils';
import { useViewParamsEditorStore } from './viewParamsEditor.store';
import { ViewParamsShare } from './ViewParamShare';
import ViewParamsSection from './ViewParamsSection';
import style from './ViewParamsEditor.module.scss';
interface EditFormDrawerProps {
target: OntimeView;
viewOptions: ViewOption[];
}
export default memo(ViewParamsEditor);
function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) {
function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
const [_, setSearchParams] = useSearchParams();
const { data: viewSettings } = useViewSettings();
const { isOpen, close } = useViewParamsEditorStore();
// TODO: we dont want this as a permanent option
const forceRender = useReducer((x) => x + 1, 0)[1];
const handleClose = () => {
close();
@@ -32,6 +36,7 @@ function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) {
const resetParams = () => {
setSearchParams();
forceRender();
};
const onParamsFormSubmit = (formEvent: FormEvent<HTMLFormElement>) => {
@@ -40,6 +45,7 @@ function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) {
const newParamsObject = Object.fromEntries(new FormData(formEvent.currentTarget));
const newSearchParams = getURLSearchParamsFromObj(newParamsObject, viewOptions);
setSearchParams(newSearchParams);
forceRender();
};
return (
@@ -64,6 +70,7 @@ function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) {
{viewSettings.overrideStyles && (
<Info className={style.info}>This view style is being modified by a custom CSS file.</Info>
)}
<ViewParamsShare target={target} />
<form id='edit-params-form' onSubmit={onParamsFormSubmit} className={style.sectionList}>
{viewOptions.map((section) => (
<ViewParamsSection
@@ -80,7 +87,7 @@ function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) {
Reset to default
</Button>
<Button variant='primary' size='large' form='edit-params-form' type='submit'>
Save
Apply
</Button>
</div>
</Dialog.Popup>
@@ -36,11 +36,11 @@
}
.closed {
transition: rotate $transition-time-feedback;
transition: rotate 300ms cubic-bezier(0.45, 1.005, 0, 1.005);
rotate: 0deg;
}
.open {
transition: rotate $transition-time-feedback;
transition: rotate 300ms cubic-bezier(0.45, 1.005, 0, 1.005);
rotate: 180deg;
}
@@ -15,8 +15,7 @@ interface ViewParamsSectionProps {
options: ParamField[];
}
export default memo(ViewParamsSection);
function ViewParamsSection({ title, collapsible, options }: ViewParamsSectionProps) {
export default function ViewParamsSection({ title, collapsible, options }: ViewParamsSectionProps) {
const [collapsed, setCollapsed] = useLocalStorage({ key: `params-${title}`, defaultValue: false });
const handleCollapse = () => {
@@ -0,0 +1,22 @@
.presetSection {
background-color: $gray-1350;
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 1rem 0.5rem;
margin-bottom: 1rem;
}
.preset {
display: flex;
align-items: center;
gap: 0.5rem;
&.active {
color: $blue-500;
}
}
.presetActions {
margin-left: auto;
}
@@ -1,5 +1,6 @@
import { useMemo } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { URLPreset } from 'ontime-types';
import { OntimeView, URLPreset } from 'ontime-types';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { URL_PRESETS } from '../api/constants';
@@ -21,6 +22,14 @@ export default function useUrlPresets({ skip = false }: FetchProps = {}) {
return { data: data ?? [], status, isError, refetch };
}
export function useViewUrlPresets(view: OntimeView) {
const { data } = useUrlPresets();
const viewPresets = useMemo(() => data.filter((preset) => preset.target === view), [data, view]);
return { viewPresets };
}
export function useUpdateUrlPreset() {
const queryClient = useQueryClient();
+2 -2
View File
@@ -136,9 +136,9 @@ export function arePathsEquivalent(currentPath: string, newPath: string): boolea
* Generates a URL preset from a user given alias and URL.
*/
export function generateUrlPresetOptions(alias: string, userUrl: string): URLPreset {
let sanitisedUrl = userUrl.toLowerCase();
let sanitisedUrl = userUrl;
// we need to ensure the URL has a protocol, but it doesnt matter which
if (!checkRegex.startsWithHttp(sanitisedUrl)) {
if (!checkRegex.startsWithHttp(userUrl.toLowerCase())) {
sanitisedUrl = `http://${sanitisedUrl}`;
}
@@ -115,7 +115,7 @@ export default function URLPresetForm({ urlPreset, onClose }: URLPresetFormProps
<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 />
<Input placeholder='Paste URL' fluid ref={urlRef} />
<Button onClick={generateOptions}>Generate</Button>
</Panel.InlineElements>
</div>
@@ -1,5 +1,5 @@
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { isOntimeBlock, isOntimeEvent } from 'ontime-types';
import { isOntimeBlock, isOntimeEvent, OntimeView } from 'ontime-types';
import EmptyPage from '../../common/components/state/EmptyPage';
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
@@ -116,7 +116,7 @@ export default function Operator() {
return (
<div className={style.operatorContainer} data-testid='operator-view'>
<ViewParamsEditor viewOptions={operatorOptions} />
<ViewParamsEditor target={OntimeView.Operator} viewOptions={operatorOptions} />
{editEvent && <EditModal event={editEvent} onClose={() => setEditEvent(null)} />}
<StatusBar />
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from 'react';
import QRCode from 'react-qr-code';
import { useViewportSize } from '@mantine/hooks';
import { CustomFields, OntimeEvent, ProjectData, Runtime, Settings } from 'ontime-types';
import { CustomFields, OntimeEvent, OntimeView, ProjectData, Runtime, Settings } from 'ontime-types';
import { millisToString, removeLeadingZero } from 'ontime-utils';
import ProgressBar from '../../common/components/progress-bar/ProgressBar';
@@ -110,7 +110,7 @@ export default function Backstage({
return (
<div className={`backstage ${isMirrored ? 'mirror' : ''}`} data-testid='backstage-view'>
<ViewParamsEditor viewOptions={backstageOptions} />
<ViewParamsEditor target={OntimeView.Backstage} viewOptions={backstageOptions} />
<div className='project-header'>
{general?.logo && <ViewLogo name={general.logo} className='logo' />}
<div className='title'>{general.title}</div>
@@ -6,6 +6,7 @@ import {
isOntimeEvent,
isPlayableEvent,
OntimeEvent,
OntimeView,
ProjectData,
Settings,
} from 'ontime-types';
@@ -70,7 +71,7 @@ export default function Countdown({
return (
<div className={`countdown ${isMirrored ? 'mirror' : ''}`} data-testid='countdown-view'>
<ViewParamsEditor viewOptions={countdownOptions} />
<ViewParamsEditor target={OntimeView.Countdown} viewOptions={countdownOptions} />
<div className='project-header'>
{general?.logo && <ViewLogo name={general.logo} className='logo' />}
<div className='title'>{general.title}</div>
@@ -10,6 +10,7 @@ import { useViewOptionsStore } from '../../common/stores/viewOptions';
import { useTranslation } from '../../translation/TranslationProvider';
import './ProjectInfo.scss';
import { OntimeView } from 'ontime-types';
export default function ProjectInfo() {
// persisted app state
@@ -23,7 +24,7 @@ export default function ProjectInfo() {
return (
<>
<ViewNavigationMenu isLockable suppressSettings />
<ViewParamsEditor viewOptions={[]} />
<ViewParamsEditor target={OntimeView.ProjectInfo} viewOptions={[]} />
<EmptyPage text={getLocalizedString('common.no_data')} />;
</>
);
@@ -38,7 +39,7 @@ export default function ProjectInfo() {
return (
<>
<ViewNavigationMenu isLockable suppressSettings />
<ViewParamsEditor viewOptions={[]} />
<ViewParamsEditor target={OntimeView.ProjectInfo} viewOptions={[]} />
<EmptyPage text={getLocalizedString('common.no_data')} />;
</>
);
@@ -47,7 +48,7 @@ export default function ProjectInfo() {
return (
<div className={`project ${isMirrored ? 'mirror' : ''}`} data-testid='project-view'>
<ViewNavigationMenu isLockable suppressSettings />
<ViewParamsEditor viewOptions={[]} />
<ViewParamsEditor target={OntimeView.ProjectInfo} viewOptions={[]} />
{data.logo && <ViewLogo name={data.logo} className='logo' />}
<div className='info'>
{data.title && (
+2 -2
View File
@@ -1,5 +1,5 @@
import { useMemo } from 'react';
import { MessageState, OntimeEvent, ProjectData, Runtime, Settings, ViewSettings } from 'ontime-types';
import { MessageState, OntimeEvent, OntimeView, ProjectData, Runtime, Settings, ViewSettings } from 'ontime-types';
import ViewLogo from '../../common/components/view-logo/ViewLogo';
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
@@ -48,7 +48,7 @@ export default function Studio({
return (
<div className={cx(['studio', isMirrored && 'mirror'])} data-testid='studio-view'>
<ViewParamsEditor viewOptions={studioOptions} />
<ViewParamsEditor target={OntimeView.StudioClock} viewOptions={studioOptions} />
<div className='project-header'>
{general?.logo && <ViewLogo name={general.logo} className='logo' />}
@@ -1,5 +1,5 @@
import { useMemo } from 'react';
import { MaybeString, OntimeEvent, ProjectData, Runtime, Settings } from 'ontime-types';
import { MaybeString, OntimeEvent, OntimeView, ProjectData, Runtime, Settings } from 'ontime-types';
import ViewLogo from '../../common/components/view-logo/ViewLogo';
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
@@ -72,7 +72,7 @@ export default function TimelinePage({ events, general, runtime, selectedId, set
}
return (
<div className='timeline' data-testid='timeline-view'>
<ViewParamsEditor viewOptions={progressOptions} />
<ViewParamsEditor target={OntimeView.Timeline} viewOptions={progressOptions} />
<div className='project-header'>
{general?.logo && <ViewLogo name={general.logo} className='logo' />}
<div className='title'>{general.title}</div>
+2 -2
View File
@@ -1,5 +1,5 @@
import { useMemo } from 'react';
import { CustomFields, MessageState, OntimeEvent, ProjectData, Settings, ViewSettings } from 'ontime-types';
import { CustomFields, MessageState, OntimeEvent, OntimeView, ProjectData, Settings, ViewSettings } from 'ontime-types';
import { FitText } from '../../common/components/fit-text/FitText';
import MultiPartProgressBar from '../../common/components/multi-part-progress-bar/MultiPartProgressBar';
@@ -157,7 +157,7 @@ export default function Timer({
>
{!hideLogo && general?.logo && <ViewLogo name={general.logo} className='logo' />}
<ViewParamsEditor viewOptions={timerOptions} />
<ViewParamsEditor target={OntimeView.Timer} viewOptions={timerOptions} />
<div className={cx(['blackout', message.timer.blackout && 'blackout--active'])} />
+1 -1
View File
@@ -8,7 +8,7 @@ test('View params configures timer view', async ({ page }) => {
await page.mouse.move(Math.random() * 100, Math.random() * 100);
await page.getByTestId('navigation__toggle-settings').click();
await page.locator('label').filter({ hasText: 'Hide Time NowHides the Time' }).locator('span').nth(2).click();
await page.getByRole('button', { name: 'Save' }).click();
await page.getByRole('button', { name: 'Apply' }).click();
await expect(page.getByText('TIME NOW', { exact: true })).not.toBeInViewport();
await expect(page).toHaveURL(/.*hideClock=true/);