mirror of
https://github.com/cpvalente/ontime.git
synced 2026-09-03 13:29:06 +00:00
feat(navigation): persist view params customisation
This commit is contained in:
@@ -1,6 +1,8 @@
|
|||||||
import { useDisclosure, useHotkeys } from '@mantine/hooks';
|
import { useDisclosure, useHotkeys } from '@mantine/hooks';
|
||||||
import { memo } from 'react';
|
import { memo } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router';
|
||||||
|
|
||||||
|
import { hasCustomParams, useSavedViewParams } from '../../stores/savedViewParams';
|
||||||
import { useViewParamsEditorStore } from '../view-params-editor/viewParamsEditor.store';
|
import { useViewParamsEditorStore } from '../view-params-editor/viewParamsEditor.store';
|
||||||
import FloatingNavigation from './floating-navigation/FloatingNavigation';
|
import FloatingNavigation from './floating-navigation/FloatingNavigation';
|
||||||
import NavigationMenu from './NavigationMenu';
|
import NavigationMenu from './NavigationMenu';
|
||||||
@@ -17,6 +19,9 @@ export default memo(ViewNavigationMenu);
|
|||||||
function ViewNavigationMenu({ isNavigationLocked, suppressSettings }: ViewNavigationMenuProps) {
|
function ViewNavigationMenu({ isNavigationLocked, suppressSettings }: ViewNavigationMenuProps) {
|
||||||
const [isMenuOpen, menuHandler] = useDisclosure();
|
const [isMenuOpen, menuHandler] = useDisclosure();
|
||||||
const { open: showEditFormDrawer } = useViewParamsEditorStore();
|
const { open: showEditFormDrawer } = useViewParamsEditorStore();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const savedParams = useSavedViewParams((store) => store.params);
|
||||||
|
const hasSavedChanges = hasCustomParams(searchParams) || Object.keys(savedParams).length > 0;
|
||||||
|
|
||||||
useHotkeys([
|
useHotkeys([
|
||||||
[
|
[
|
||||||
@@ -46,6 +51,7 @@ function ViewNavigationMenu({ isNavigationLocked, suppressSettings }: ViewNaviga
|
|||||||
<FloatingNavigation
|
<FloatingNavigation
|
||||||
toggleMenu={isNavigationLocked ? undefined : menuHandler.toggle}
|
toggleMenu={isNavigationLocked ? undefined : menuHandler.toggle}
|
||||||
toggleSettings={suppressSettings ? undefined : showEditFormDrawer}
|
toggleSettings={suppressSettings ? undefined : showEditFormDrawer}
|
||||||
|
hasSavedChanges={hasSavedChanges}
|
||||||
/>
|
/>
|
||||||
{!isNavigationLocked && <NavigationMenu isOpen={isMenuOpen} onClose={menuHandler.close} />}
|
{!isNavigationLocked && <NavigationMenu isOpen={isMenuOpen} onClose={menuHandler.close} />}
|
||||||
</>
|
</>
|
||||||
|
|||||||
+26
-1
@@ -1,4 +1,29 @@
|
|||||||
.linkIcon {
|
.trailing {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.linkIcon {
|
||||||
@include rotate-fourty-five;
|
@include rotate-fourty-five;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.indicator {
|
||||||
|
width: 0.5rem;
|
||||||
|
height: 0.5rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: $active-indicator;
|
||||||
|
pointer-events: none;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear {
|
||||||
|
opacity: 0.6;
|
||||||
|
transition: opacity $transition-time-action;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:focus-visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
import { PropsWithChildren } from 'react';
|
import { MouseEvent, PropsWithChildren } from 'react';
|
||||||
import { IoArrowUp } from 'react-icons/io5';
|
import { IoArrowUp, IoCloseOutline } from 'react-icons/io5';
|
||||||
import { useNavigate } from 'react-router';
|
import { useLocation, useNavigate, useSearchParams } from 'react-router';
|
||||||
|
|
||||||
import { useElectronEvent } from '../../../hooks/useElectronEvent';
|
import { useElectronEvent } from '../../../hooks/useElectronEvent';
|
||||||
|
import {
|
||||||
|
hasCustomParams,
|
||||||
|
reservedParams,
|
||||||
|
stripReservedParams,
|
||||||
|
useSavedViewParams,
|
||||||
|
} from '../../../stores/savedViewParams';
|
||||||
import { handleLinks } from '../../../utils/linkUtils';
|
import { handleLinks } from '../../../utils/linkUtils';
|
||||||
|
import IconButton from '../../buttons/IconButton';
|
||||||
import NavigationMenuItem from '../navigation-menu-item/NavigationMenuItem';
|
import NavigationMenuItem from '../navigation-menu-item/NavigationMenuItem';
|
||||||
|
|
||||||
import style from './ClientLink.module.scss';
|
import style from './ClientLink.module.scss';
|
||||||
@@ -16,32 +23,100 @@ interface ClientLinkProps {
|
|||||||
|
|
||||||
export default function ClientLink({ current, to, postAction, children }: PropsWithChildren<ClientLinkProps>) {
|
export default function ClientLink({ current, to, postAction, children }: PropsWithChildren<ClientLinkProps>) {
|
||||||
const { isElectron } = useElectronEvent();
|
const { isElectron } = useElectronEvent();
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
if (isElectron) {
|
if (isElectron) {
|
||||||
return (
|
return (
|
||||||
<NavigationMenuItem
|
<ElectronNavigationItem current={current} to={to} postAction={postAction}>
|
||||||
active={current}
|
|
||||||
onClick={() => {
|
|
||||||
handleLinks(to);
|
|
||||||
postAction?.();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
<IoArrowUp className={style.linkIcon} />
|
</ElectronNavigationItem>
|
||||||
</NavigationMenuItem>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NavigationMenuItem
|
<BrowserNavigationItem current={current} to={to} postAction={postAction}>
|
||||||
active={current}
|
|
||||||
onClick={() => {
|
|
||||||
navigate(`/${to}`);
|
|
||||||
postAction?.();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
|
</BrowserNavigationItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ElectronNavigationItem({ current, to, postAction, children }: PropsWithChildren<ClientLinkProps>) {
|
||||||
|
const navigateToLink = () => {
|
||||||
|
handleLinks(to);
|
||||||
|
postAction?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NavigationMenuItem active={current} onClick={navigateToLink}>
|
||||||
|
{children}
|
||||||
|
<span className={style.trailing}>
|
||||||
|
<IoArrowUp className={style.linkIcon} />
|
||||||
|
</span>
|
||||||
|
</NavigationMenuItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BrowserNavigationItem({ current, to, postAction, children }: PropsWithChildren<ClientLinkProps>) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const { params: savedParams, save, clear } = useSavedViewParams();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save the params of the view we are leaving and resolve the destination,
|
||||||
|
* restoring any params previously saved for the target view.
|
||||||
|
*/
|
||||||
|
const resolveDestination = () => {
|
||||||
|
const currentView = location.pathname.replace(/^\//, '');
|
||||||
|
save(currentView, stripReservedParams(location.search));
|
||||||
|
const restored = savedParams[to];
|
||||||
|
return `${to}${restored ? `?${restored}` : ''}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isCustomised = current ? hasCustomParams(searchParams) : Boolean(savedParams[to]);
|
||||||
|
|
||||||
|
const navigateToLink = () => {
|
||||||
|
const destination = resolveDestination();
|
||||||
|
navigate(`/${destination}`);
|
||||||
|
postAction?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the saved settings for this route without navigating to it.
|
||||||
|
* When it is the current view, also reset the live URL to its defaults.
|
||||||
|
*/
|
||||||
|
const clearViewSettings = (event: MouseEvent) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
clear(to);
|
||||||
|
|
||||||
|
// if mounted, clear the URL params
|
||||||
|
if (current) {
|
||||||
|
const preserved = new URLSearchParams();
|
||||||
|
reservedParams.forEach((key) => {
|
||||||
|
const value = searchParams.get(key);
|
||||||
|
if (value !== null) preserved.set(key, value);
|
||||||
|
});
|
||||||
|
setSearchParams(preserved);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NavigationMenuItem active={current} onClick={navigateToLink}>
|
||||||
|
{children}
|
||||||
|
{isCustomised && (
|
||||||
|
<span className={style.trailing}>
|
||||||
|
<span className={style.indicator} aria-hidden data-testid='client-link__saved-indicator' />
|
||||||
|
<IconButton
|
||||||
|
variant='ghosted-white'
|
||||||
|
size='small'
|
||||||
|
className={style.clear}
|
||||||
|
aria-label='Clear saved view settings'
|
||||||
|
title='Clear saved view settings'
|
||||||
|
onClick={clearViewSettings}
|
||||||
|
>
|
||||||
|
<IoCloseOutline />
|
||||||
|
</IconButton>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</NavigationMenuItem>
|
</NavigationMenuItem>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+16
@@ -22,3 +22,19 @@
|
|||||||
top: 0;
|
top: 0;
|
||||||
z-index: $zindex-nav;
|
z-index: $zindex-nav;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.buttonWithIndicator {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.indicator {
|
||||||
|
position: absolute;
|
||||||
|
top: -0.15rem;
|
||||||
|
right: -0.15rem;
|
||||||
|
width: 0.6rem;
|
||||||
|
height: 0.6rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: $active-indicator;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|||||||
+14
-10
@@ -9,9 +9,10 @@ import style from './FloatingNavigation.module.scss';
|
|||||||
interface FloatingNavigationProps {
|
interface FloatingNavigationProps {
|
||||||
toggleMenu?: () => void;
|
toggleMenu?: () => void;
|
||||||
toggleSettings?: () => void;
|
toggleSettings?: () => void;
|
||||||
|
hasSavedChanges?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function FloatingNavigation({ toggleMenu, toggleSettings }: FloatingNavigationProps) {
|
export default function FloatingNavigation({ toggleMenu, toggleSettings, hasSavedChanges }: FloatingNavigationProps) {
|
||||||
const isButtonShown = useFadeOutOnInactivity(true);
|
const isButtonShown = useFadeOutOnInactivity(true);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -20,15 +21,18 @@ export default function FloatingNavigation({ toggleMenu, toggleSettings }: Float
|
|||||||
className={cx([style.fadeable, style.buttonContainer, !isButtonShown && style.hidden])}
|
className={cx([style.fadeable, style.buttonContainer, !isButtonShown && style.hidden])}
|
||||||
>
|
>
|
||||||
{toggleMenu && (
|
{toggleMenu && (
|
||||||
<IconButton
|
<div className={style.buttonWithIndicator}>
|
||||||
variant='subtle-white'
|
<IconButton
|
||||||
size='xlarge'
|
variant='subtle-white'
|
||||||
onClick={toggleMenu}
|
size='xlarge'
|
||||||
aria-label='toggle menu'
|
onClick={toggleMenu}
|
||||||
data-testid='navigation__toggle-menu'
|
aria-label='toggle menu'
|
||||||
>
|
data-testid='navigation__toggle-menu'
|
||||||
<IoApps />
|
>
|
||||||
</IconButton>
|
<IoApps />
|
||||||
|
</IconButton>
|
||||||
|
{hasSavedChanges && <span className={style.indicator} data-testid='navigation__saved-indicator' />}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{toggleSettings && (
|
{toggleSettings && (
|
||||||
<IconButton
|
<IconButton
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { hasCustomParams, stripReservedParams, useSavedViewParams } from '../savedViewParams';
|
||||||
|
|
||||||
|
describe('savedViewParams store', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useSavedViewParams.getState().clearAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('saves and restores params per view', () => {
|
||||||
|
useSavedViewParams.getState().save('timer', 'hideSeconds=true');
|
||||||
|
useSavedViewParams.getState().save('backstage', 'showProgress=false');
|
||||||
|
|
||||||
|
expect(useSavedViewParams.getState().params.timer).toBe('hideSeconds=true');
|
||||||
|
expect(useSavedViewParams.getState().params.backstage).toBe('showProgress=false');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('overwrites the saved params for a view on subsequent saves', () => {
|
||||||
|
useSavedViewParams.getState().save('timer', 'hideSeconds=true');
|
||||||
|
useSavedViewParams.getState().save('timer', 'hideSeconds=false');
|
||||||
|
|
||||||
|
expect(useSavedViewParams.getState().params.timer).toBe('hideSeconds=false');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not store an entry for an empty search string', () => {
|
||||||
|
useSavedViewParams.getState().save('timer', '');
|
||||||
|
|
||||||
|
expect(useSavedViewParams.getState().params).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes a previously saved entry when saved with an empty search string', () => {
|
||||||
|
useSavedViewParams.getState().save('timer', 'hideSeconds=true');
|
||||||
|
useSavedViewParams.getState().save('timer', '');
|
||||||
|
|
||||||
|
expect(useSavedViewParams.getState().params).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores empty view keys', () => {
|
||||||
|
useSavedViewParams.getState().save('', 'hideSeconds=true');
|
||||||
|
|
||||||
|
expect(useSavedViewParams.getState().params).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears the saved params for a single view without touching others', () => {
|
||||||
|
useSavedViewParams.getState().save('timer', 'hideSeconds=true');
|
||||||
|
useSavedViewParams.getState().save('backstage', 'showProgress=false');
|
||||||
|
useSavedViewParams.getState().clear('timer');
|
||||||
|
|
||||||
|
expect(useSavedViewParams.getState().params).toEqual({ backstage: 'showProgress=false' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is a no-op when clearing a view without saved params', () => {
|
||||||
|
useSavedViewParams.getState().save('backstage', 'showProgress=false');
|
||||||
|
useSavedViewParams.getState().clear('timer');
|
||||||
|
|
||||||
|
expect(useSavedViewParams.getState().params).toEqual({ backstage: 'showProgress=false' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears all saved params', () => {
|
||||||
|
useSavedViewParams.getState().save('timer', 'hideSeconds=true');
|
||||||
|
useSavedViewParams.getState().clearAll();
|
||||||
|
|
||||||
|
expect(useSavedViewParams.getState().params).toEqual({});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('stripReservedParams', () => {
|
||||||
|
it('removes reserved auth/preset params while keeping view customisation', () => {
|
||||||
|
expect(stripReservedParams('hideSeconds=true&token=abc&n=1&alias=my')).toBe('hideSeconds=true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an empty string when only reserved params are present', () => {
|
||||||
|
expect(stripReservedParams('token=abc&n=1&alias=my')).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('hasCustomParams', () => {
|
||||||
|
it('is true when a non-reserved param is present', () => {
|
||||||
|
expect(hasCustomParams(new URLSearchParams('hideSeconds=true&token=abc'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is false when only reserved params are present', () => {
|
||||||
|
expect(hasCustomParams(new URLSearchParams('token=abc&n=1&alias=my'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is false when there are no params', () => {
|
||||||
|
expect(hasCustomParams(new URLSearchParams(''))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
// params that are auth/preset markers, not user view customisation (see common/utils/urlPresets.ts)
|
||||||
|
export const reservedParams = new Set(['token', 'n', 'alias']);
|
||||||
|
|
||||||
|
interface SavedViewParamsStore {
|
||||||
|
params: Record<string, string>; // view key (e.g. "timer") -> search string without leading "?"
|
||||||
|
save: (view: string, search: string) => void;
|
||||||
|
clear: (view: string) => void;
|
||||||
|
clearAll: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remembers the last view parameters used for each view so they can be
|
||||||
|
* restored when the user navigates back to that view through the menu.
|
||||||
|
* In-memory only: persists across SPA navigation, resets on a full page reload.
|
||||||
|
*/
|
||||||
|
export const useSavedViewParams = create<SavedViewParamsStore>((set) => ({
|
||||||
|
params: {},
|
||||||
|
save: (view, search) =>
|
||||||
|
set((state) => {
|
||||||
|
// ignore empty view keys and do not store empty entries, so the
|
||||||
|
// "saved changes" indicator only reflects genuine customisation
|
||||||
|
if (!view) return state;
|
||||||
|
const params = { ...state.params };
|
||||||
|
if (search) {
|
||||||
|
params[view] = search;
|
||||||
|
} else {
|
||||||
|
delete params[view];
|
||||||
|
}
|
||||||
|
return { params };
|
||||||
|
}),
|
||||||
|
clear: (view) =>
|
||||||
|
set((state) => {
|
||||||
|
if (!state.params[view]) return state;
|
||||||
|
const params = { ...state.params };
|
||||||
|
delete params[view];
|
||||||
|
return { params };
|
||||||
|
}),
|
||||||
|
clearAll: () => set({ params: {} }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes reserved (auth/preset) params from a search string, keeping only
|
||||||
|
* genuine view customisation.
|
||||||
|
*/
|
||||||
|
export function stripReservedParams(search: string): string {
|
||||||
|
const sp = new URLSearchParams(search);
|
||||||
|
reservedParams.forEach((key) => sp.delete(key));
|
||||||
|
return sp.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the search params contain any genuine view customisation,
|
||||||
|
* ignoring reserved params.
|
||||||
|
*/
|
||||||
|
export function hasCustomParams(searchParams: URLSearchParams): boolean {
|
||||||
|
for (const key of searchParams.keys()) {
|
||||||
|
if (!reservedParams.has(key)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
@@ -37,7 +37,12 @@ export function handleLinks(
|
|||||||
event?.preventDefault();
|
event?.preventDefault();
|
||||||
|
|
||||||
const destination = new URL(externalServerUrl);
|
const destination = new URL(externalServerUrl);
|
||||||
destination.pathname = externalBaseURI ? `${externalBaseURI}/${location}` : location;
|
// split off any query string so it is not encoded into the pathname
|
||||||
|
const [pathname, search] = location.split('?');
|
||||||
|
destination.pathname = externalBaseURI ? `${externalBaseURI}/${pathname}` : pathname;
|
||||||
|
if (search) {
|
||||||
|
destination.search = search;
|
||||||
|
}
|
||||||
openLink(destination.toString());
|
openLink(destination.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user