Files
siftlode/frontend/src/components/ui/pressableSelectable.contract.test.ts
T
peter db56f25387 feat(files): API tokens in Settings, the Hub folder in Files, a notice before a token runs out
Settings gains an API tokens tab: a row per token (name and prefix; each
folder with its rights; last use and expiry, "expires soon" marked),
revoked from its menu behind a confirm. A new token is the Hub exchange in
one click, or folders and rights picked per folder (no Write where an
upload cannot land); expiry 30/90 days, 1 year or never (warned). The
token shows once in the same dialog, with Copy and a ready curl line.

The Hub folder closes the Sources in the Files rail, takes uploads and
moved-in files. A token about to run out warns its user once, a week
before (the Files GC's sixth pass); the notice opens the tokens tab.
2026-10-14 23:33:34 +02:00

110 lines
4.9 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { readSources } from "../../lib/rawSources.testkit";
// docs/DESIGN.md "Text stays selectable": a click surface whose text is a title or a name (a card, a
// row, a person, a channel, a playlist, a log line) is `<Pressable selectable`, so its words select and
// the click that ends selecting them does nothing. A Pressable that is NOT selectable is a control whose
// text is its own label or that has no text at all (a menu row, an option, a tab, a nav item, a
// picture, a switch). Which one a new surface is cannot be read from the code, so every plain one is
// pinned here with the reason: a new Pressable, or one that lost its flag, fails this test until
// someone decides.
const files = readSources(__dirname, [
"../**/*.tsx",
"!../**/*.test.*",
"!../design-lab/**",
]) as Record<string, string>;
/** file → [how many plain Pressables, why they are not titles]. */
const PLAIN: Record<string, [number, string]> = {
"ApiTokens.tsx": [
1,
"a token kind's tile in the new-token dialog (an option: its text is its label)",
],
"DownloadTray.tsx": [
2,
"the folded pill (its summary is its label); a failure's reason, which opens its details",
],
"FileCard.tsx": [1, "a card's picture (named by aria-label; its title is the caption)"],
"FilesRail.tsx": [2, "the load-error retry; the New-folder command"],
"Home.tsx": [
2,
"the hero's picture and a shelf tile's picture (each named by aria-label; its title sits outside)",
],
"LanguageSwitcher.tsx": [1, "an option row"],
"NavSidebar.tsx": [4, "the account trigger and its menu rows"],
"PaletteLauncher.tsx": [2, "launcher controls"],
"PlaylistsRail.tsx": [1, "the load-error retry"],
"PlexCollectionEditor.tsx": [1, "a checkbox row in a picker"],
"PlexInfo.tsx": [1, "the rating filter (a number)"],
"PlexPlayerSettings.tsx": [1, "the clock's date-style options"],
"PlexPlaylistAdd.tsx": [1, "a checkbox row in a picker"],
"PlexPlaylistView.tsx": [1, "a show's poster stack (aria-hidden)"],
"PlexSearchBox.tsx": [1, "a listbox option"],
"plexCards.tsx": [1, "a card's picture (named by aria-label; its title is the caption)"],
"Scheduler.tsx": [1, "the interval editor trigger"],
"SettingsPanel.tsx": [1, "a colour swatch"],
"SyncStatus.tsx": [1, "the status pill trigger"],
"TagManager.tsx": [1, "a suggestion in a picker"],
"UploadDialog.tsx": [1, "the drop zone"],
"VideoEditor.tsx": [2, "timeline glyphs"],
"Welcome.tsx": [1, "a screenshot (zoom)"],
"playlistColumns.tsx": [1, "the thumbnail"],
"plexDetailUi.tsx": [2, "a switch row and the poster"],
"plexViews.tsx": [1, "the rating filter (a number)"],
"ui/ActionMenu.tsx": [1, "a menu row"],
"ui/BackToTop.tsx": [1, "a control"],
"ui/CommandPalette.tsx": [1, "a command row"],
"ui/NavRail.tsx": [
2,
"the action rows (About, Sign out) and the group row that opens its panel; a page row is a link",
],
"ui/PanelGroup.tsx": [2, "a group's collapse toggle and its label"],
"ui/SidePanel.tsx": [2, "panel controls"],
"ui/tableHeader.tsx": [1, "a column's sort header"],
};
function scan(): { plain: Record<string, number>; selectable: number } {
const plain: Record<string, number> = {};
let selectable = 0;
for (const [path, code] of Object.entries(files)) {
// Glob keys are relative to this folder: `./x.tsx` is ui/, `../x.tsx` is components/.
const rel = path.startsWith("./") ? `ui/${path.slice(2)}` : path.replace(/^\.\.\//, "");
for (const m of code.matchAll(/<Pressable\b/g)) {
if (/\sselectable\b(?!=)/.test(openingTag(code, m.index))) selectable++;
else plain[rel] = (plain[rel] ?? 0) + 1;
}
}
return { plain, selectable };
}
/** The opening tag starting at `from`: up to its `>` outside any `{…}` expression, quoted string or
* line comment (an arrow's `=>` sits inside braces, a class like `[&>svg]` inside quotes). */
function openingTag(code: string, from: number): string {
let depth = 0;
let quote: string | null = null;
for (let i = from + 1; i < code.length; i++) {
const c = code[i];
if (quote) {
if (c === quote) quote = null;
} else if (c === "/" && code[i + 1] === "/") {
i = code.indexOf("\n", i); // a comment between the props: its apostrophes are not quotes
if (i < 0) break;
} else if (c === '"' || c === "'" || c === "`") quote = c;
else if (c === "{") depth++;
else if (c === "}") depth--;
else if (c === ">" && depth === 0) return code.slice(from, i + 1);
}
return code.slice(from);
}
describe("every Pressable decides whether its text is a title", () => {
it("finds the selectable ones, and no plain one outside the pinned list", () => {
const { plain, selectable } = scan();
// A scan that stopped matching would be green over nothing.
expect(selectable).toBeGreaterThan(15);
const pinned = Object.fromEntries(Object.entries(PLAIN).map(([f, [n]]) => [f, n]));
expect(plain).toEqual(pinned);
});
});