wrolpi/app/src/components/Nav.test.js
lrnselfreliance 86d4bbab98
Some checks are pending
CodeQL / Analyze (push) Waiting to run
Controller Tests / controller-tests (push) Waiting to run
Jest Tests / jest-tests (push) Waiting to run
Bookmarks: a nav menu of user links, stored in bookmarks.yaml (#729)
* bookmarks: user bookmarks in the navigation bar, stored in bookmarks.yaml

A "Bookmarks" tab between Calculators and Flasher opens on hover or click and
lists the user's bookmarks, with directories as flyout submenus (Menu.Sub).
The mobile hamburger shows directories as labelled groups instead.  The tab
always shows, with an "Edit bookmarks" item, so the feature can be found
before any bookmark exists.

Bookmarks live in a new config-only bookmarks.yaml: a nested tree where a
node with `children` is a directory and a node with `url` is a bookmark.
Every node carries an integer id so the API can rename, move and delete
without relying on names.  A URL may be a path on this WROLPi, a port on this
host (`:8096/` or `http://:8096/`, resolved against whatever hostname the
browser used, so it works over LAN IP, mDNS and the hotspot alike), or an
absolute URL.  Each bookmark chooses whether to open in a new tab.

The editor at /more/bookmarks follows the playlist items table: numbered rows,
up/down/edit/delete, and inline "Add a bookmark" / "Add a directory" forms
with a Location picker.  Deleting a directory asks first, since its contents
go with it.  The menu and the editor are in the component gallery.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* bookmarks: mark More current when the editor is folded inside it; rem indents in the mobile menu

Found on the QA Pi at a width where Bookmarks overflows into More: the More
button carried no mark on /more/bookmarks, because the bookmarks link has no
`to` for isLinkActive to match.  isLinkActive now knows the bookmarks link owns
its editor page, and the Bookmarks tab uses the same rule.

The mobile menu's directory labels sat a few pixels left of the labels around
them: the indent was in em, and a Menu.Label is set smaller than a Menu.Item.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* bookmarks: parse URLs instead of matching a prefix; validate the whole tree on every write

The URL contract was checked by prefix on both sides, and a prefix is not a
form: `//evil.com` starts like a path and `:8096@evil.com` starts like a port,
and a browser takes both off this host.  A path is now a single leading slash
that resolves to this origin, a port form is an optional scheme, `:`, digits
and then only a path, query or fragment (no `@`, no backslash, no control
characters), and anything else must parse as http(s).  The same rules live in
wrolpi/bookmarks.py and BookmarksContext.js, with the same bypass strings in
both test suites.

The config's dataclass validator only saw that `bookmarks` was a list, so a
hand-edited file or the generic config API could store a `javascript:` URL
that the bookmarks API would refuse, and the nav rendered it.  The config now
validates the tree itself on every write and import: URL forms, exactly one of
url/children, unique integer ids, boolean new_tab, and no cycles (a YAML
anchor can build one).  An invalid file is not imported and, since it was never
successfully imported, is never overwritten.

On the page, a stored URL that does not resolve is rendered as text, never as
an href.  A refused add keeps what was typed instead of clearing the form, and
an edit whose move is refused still refreshes the nav (the rename was
committed) and keeps the modal open.  An IPv6 host is bracketed before a port.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* help: document Bookmarks

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-26 13:11:36 -06:00

321 lines
13 KiB
JavaScript

import fs from 'fs';
import path from 'path';
import React from 'react';
import {screen} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import {render} from '../test-utils';
import {FileWorkerStatusContext} from '../contexts/FileWorkerStatusContext';
import {NavBar} from './Nav';
/*
* Which navbar tab is marked as the current page.
*
* `NavLink` marks itself with `aria-current="page"` and an `active` class, and both need to
* be right: the class is what the bar styles, and the attribute is what tells a screen
* reader which section the user is in.
*
* The rule these tests encode is "a tab stays current for everything underneath it" --
* /videos/1234 is still Videos. That is `NavLink`'s default, so the tests exist to catch
* an `end` prop added to a link that has pages beneath it, which silently drops the mark on
* every one of those pages.
*/
/**
* Report a viewport to fresnel, which renders only the breakpoints whose query matches: a
* mock matching nothing renders no navbar at all, and one matching everything renders the
* mobile and desktop bars at once.
*/
const useViewport = (kind) => {
// Fresnel's breakpoints are mobile 0 / tablet 700 / wideTablet 880 / computer 1024.
const matches = kind === 'desktop'
? (query) => query.includes('1024')
: (query) => !/\d{3,}px/.test(query) || /\(max-width/.test(query);
window.matchMedia = jest.fn().mockImplementation(query => ({
matches: matches(query),
media: query,
onchange: null,
addListener: jest.fn(),
removeListener: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
}));
};
/**
* Give jsdom a bar of `barWidth` holding tabs of 60px each.
*
* Every width is 0 otherwise, so useOverflowNav concludes that nothing fits and sweeps all
* eleven tabs into the closed "More" menu -- the bar renders no tabs at all and every
* assertion about them passes vacuously. A narrow bar is how the overflow path is reached
* on purpose.
*/
const useBarWidth = (barWidth) => {
rectSpy = rectSpy || null;
Object.defineProperty(HTMLElement.prototype, 'clientWidth', {configurable: true, value: barWidth});
rectSpy = jest.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(() => ({
width: 60, height: 40, top: 0, left: 0, right: 60, bottom: 40, x: 0, y: 0,
toJSON: () => ({}),
}));
};
/**
* Hand back the real `getBoundingClientRect`.
*
* The stub above is for the overflow measurement, which the mobile bar never runs -- and
* floating-ui, which positions the menu's portal, walks up to the document element and
* crashes the worker on a rect that is not a real DOMRect.
*/
const useRealRects = () => {
if (rectSpy) {
rectSpy.mockRestore();
rectSpy = null;
}
};
let rectSpy = null;
beforeEach(() => {
rectSpy = null;
useViewport('desktop');
useBarWidth(2000);
});
// NavBar reads the file-worker status for its indicator icons. That context lives outside
// contexts/contexts.js, so the spec supplies it -- see the note at the top of test-utils.
const fileWorkerStatus = {status: null, error: null, refresh: jest.fn(), setFastPolling: jest.fn()};
const renderNav = (route) => render(<NavBar/>, {
route,
withMedia: true,
contexts: [[FileWorkerStatusContext, fileWorkerStatus]],
});
/** The tab marked as the current page, by its text. */
const currentTab = () => {
const current = document.querySelectorAll('[aria-current="page"]');
return [...current].map(el => el.textContent.trim());
};
describe('NavBar current tab', () => {
it.each([
['/videos', 'Videos'],
['/archives', 'Archive'],
['/docs', 'Docs'],
['/map', 'Map'],
['/files', 'Files'],
['/playlists', 'Playlists'],
['/zim', 'Zim'],
['/inventory', 'Inventory'],
['/flasher', 'Flasher'],
['/more/calculators', 'Calculators'],
['/more/statistics', 'Statistics'],
])('marks %s as %s', (route, text) => {
renderNav(route);
// Exactly, not `toContain`: a regression leaving Home permanently marked alongside
// the real tab satisfies "contains" on every row here.
expect(currentTab()).toEqual([text]);
});
it.each([
['/videos/1234', 'Videos'],
['/videos/channel/5/video', 'Videos'],
['/archives/domain/example.com', 'Archive'],
['/files/some/deep/directory', 'Files'],
['/inventory/1/items', 'Inventory'],
['/more/calculators/electrical', 'Calculators'],
])('keeps the tab marked on a page underneath it: %s stays %s', (route, text) => {
/*
* Not where the reported bug was -- Videos never carried `end`, so opening a video
* always marked it in the DOM; what was missing was the CSS that draws the mark.
* These cases guard the other half: that nobody adds `end` to a link with pages
* beneath it, which is what Calculators and Statistics used to have.
*/
renderNav(route);
expect(currentTab()).toEqual([text]);
});
it('marks the home link only at the root', () => {
// `to='/'` matches every path unless it is told not to, which would leave the home
// link permanently marked and two tabs claiming to be the current page at once.
renderNav('/videos');
const home = screen.getByRole('link', {name: /WROLPi Home Icon|WROLPi/i});
expect(home).not.toHaveAttribute('aria-current');
});
it('marks exactly one tab at a time', () => {
renderNav('/videos/1234');
expect(currentTab()).toHaveLength(1);
});
it('marks the home link at the root', () => {
renderNav('/');
expect(currentTab()).toHaveLength(1);
});
it('gives the current tab the class the bar styles', () => {
renderNav('/videos');
expect(document.querySelector('[aria-current="page"]')).toHaveClass('active');
});
it('marks the current item inside the mobile menu', async () => {
// On a phone the bar is a hamburger, so these menu items are the only place the
// mark can appear -- and `.mantine-Menu-item.active` is the only rule that draws it.
useViewport('mobile');
useRealRects();
renderNav('/videos');
await userEvent.click(screen.getByRole('button', {name: 'Menu'}));
const videos = await screen.findByRole('menuitem', {name: 'Videos'});
expect(videos).toHaveAttribute('aria-current', 'page');
expect(videos).toHaveClass('active');
});
it('marks the More button when the current section is folded inside it', () => {
/*
* A narrow bar pushes tabs into the overflow menu. Nothing in the bar was marked
* then: the trigger is a plain button, not a NavLink, so the bar went blank exactly
* when the window got too small to show the tab -- and the user had to open the menu
* to find out where they were.
*/
useBarWidth(200);
renderNav('/videos');
const more = screen.getByRole('button', {name: /More/});
expect(more).toHaveClass('active');
});
it('leaves the More button unmarked when the current section is not inside it', () => {
useBarWidth(200);
// Nothing in the bar's link list covers /admin/settings; Admin sits outside it.
renderNav('/admin/settings');
expect(screen.getByRole('button', {name: /More/})).not.toHaveClass('active');
});
it('styles that class, so the current tab actually looks current', () => {
/*
* The reported bug in full: react-router was already marking the tab, and the mark
* was already reaching the DOM -- nothing styled it, so every tab looked identical
* and clicking Videos appeared to do nothing to the bar.
*
* jsdom applies no stylesheet, so the rule itself is the thing to guard, and it is
* guarded by its declarations rather than by its selector: a check for the selector
* alone still passes with the body emptied, or with the underline deleted -- and the
* underline is the half that survives on a dark bar, where the wash barely reads.
*/
const css = fs.readFileSync(path.join(__dirname, '..', 'App.css'), 'utf8');
const ruleBody = (selector) => {
const match = css.match(new RegExp(`${selector}\\s*{([^}]*)}`));
return match ? match[1] : '';
};
// The wash.
expect(ruleBody('\\.wrolpi-navbar-link\\.active')).toMatch(/background:/);
// The underline: drawn, in the bar's own foreground, with a height to draw.
const underline = ruleBody('\\.wrolpi-navbar-link\\.active::after');
expect(underline).toMatch(/content:/);
expect(underline).toMatch(/background:\s*currentColor/);
// Parsed, not pattern-matched: `(?!0)` would have called 0.1875rem zero.
const height = underline.match(/height:\s*([\d.]+)\s*(?:rem|em|px)/);
expect(height).not.toBeNull();
expect(parseFloat(height[1])).toBeGreaterThan(0);
// The menu items, which are the whole of the mark on a phone.
expect(ruleBody('\\.mantine-Menu-item\\.active')).toMatch(/background:|font-weight:/);
});
});
describe('NavBar bookmarks', () => {
const {BookmarksContext} = require('../contexts/BookmarksContext');
const bookmarks = [
{id: 1, name: 'Channels', url: '/videos/channel', new_tab: false},
{id: 2, name: 'Services', children: [{id: 3, name: 'Jellyfin', url: ':8096/', new_tab: true}]},
];
const renderWithBookmarks = (route, nodes) => render(<NavBar/>, {
route,
withMedia: true,
contexts: [
[FileWorkerStatusContext, fileWorkerStatus],
[BookmarksContext, {bookmarks: nodes, error: null, refresh: jest.fn()}],
],
});
it('sits between Calculators and Flasher', () => {
renderNav('/');
const texts = [...document.querySelectorAll('#global_navbar .wrolpi-navbar-link')]
.map(el => el.textContent.trim());
const at = texts.indexOf('Bookmarks');
expect(at).toBeGreaterThan(0);
expect(texts[at - 1]).toBe('Calculators');
expect(texts[at + 1]).toBe('Flasher');
});
it('opens on click with the bookmarks, a directory flyout, and the edit link', async () => {
useRealRects();
renderWithBookmarks('/', bookmarks);
await userEvent.click(screen.getByRole('button', {name: /Bookmarks/}));
const channels = await screen.findByRole('menuitem', {name: 'Channels'});
expect(channels).toHaveAttribute('href', '/videos/channel');
expect(channels).not.toHaveAttribute('target');
// A directory is a submenu; its bookmarks appear once it is hovered.
expect(screen.queryByRole('menuitem', {name: 'Jellyfin'})).not.toBeInTheDocument();
await userEvent.hover(screen.getByRole('menuitem', {name: 'Services'}));
// `hidden`: the flyout is placed by floating-ui, which jsdom cannot lay out, so it
// stays hidden to accessibility queries even though it has rendered.
const jellyfin = await screen.findByRole('menuitem', {name: 'Jellyfin', hidden: true});
expect(jellyfin).toHaveAttribute('href', `${window.location.protocol}//${window.location.hostname}:8096/`);
expect(jellyfin).toHaveAttribute('target', '_blank');
expect(screen.getByRole('menuitem', {name: 'Edit bookmarks', hidden: true})).toHaveAttribute('href', '/more/bookmarks');
});
it('offers the edit link even with no bookmarks, so the feature can be found', async () => {
useRealRects();
renderWithBookmarks('/', []);
await userEvent.click(screen.getByRole('button', {name: /Bookmarks/}));
expect(await screen.findByRole('menuitem', {name: 'Edit bookmarks'})).toBeInTheDocument();
expect(screen.getAllByRole('menuitem')).toHaveLength(1);
});
it('is marked current on the editor page, and never for a bookmark pointing at a section', () => {
renderWithBookmarks('/more/bookmarks', bookmarks);
expect(screen.getByRole('button', {name: /Bookmarks/})).toHaveClass('active');
expect(currentTab()).toEqual([]);
});
it('marks More when the editor page is folded inside it', () => {
useBarWidth(200);
renderWithBookmarks('/more/bookmarks', bookmarks);
expect(screen.getByRole('button', {name: /More/})).toHaveClass('active');
});
it('does not own the section a bookmark points at', () => {
renderWithBookmarks('/videos/channel', bookmarks);
expect(screen.getByRole('button', {name: /Bookmarks/})).not.toHaveClass('active');
expect(currentTab()).toEqual(['Videos']);
});
it('lists bookmarks as a labelled group in the mobile menu', async () => {
useViewport('mobile');
useRealRects();
renderWithBookmarks('/', bookmarks);
await userEvent.click(screen.getByRole('button', {name: 'Menu'}));
expect(await screen.findByRole('menuitem', {name: 'Channels'})).toBeInTheDocument();
// No flyout on a phone: the directory's bookmarks are listed directly.
expect(screen.getByRole('menuitem', {name: 'Jellyfin'})).toBeInTheDocument();
expect(screen.getByText('Services')).toBeInTheDocument();
expect(screen.getByRole('menuitem', {name: 'Edit bookmarks'})).toBeInTheDocument();
});
});