refactor(sort): source-agnostic SortControl + SortOption model (R13 S1 foundation)

Introduce the one sort control every content surface will share:
- columnSort.ts gains the SortOption vocabulary + pure pickSort/healSort/
  hydrateSort helpers (direction lives in SortState, never in the key).
- SortControl.tsx: the generic dropdown + asc/desc toggle, with an optional
  "no sort" choice (tables) and a trailing adornment slot (the feed's reshuffle).
- ColumnSortControl is now a thin wrapper over SortControl (API unchanged, so
  Channels + ChannelDiscovery are untouched); optionsFromColumns moved to
  lib/sortOptions.ts to keep the component file fast-refresh clean.
- useSortState.ts: the per-account persist/hydrate/heal plumbing the channel
  manager and discovery each hand-rolled, ready to replace both.

Pure logic is fully unit-tested (columnSort.test.ts 10 -> 23). Gate green:
tsc/eslint/prettier clean, vitest 93 pass. Feed/Plex/channels migration follows.
This commit is contained in:
2026-08-13 23:24:59 +02:00
parent f3b95ed543
commit 7d9da80795
6 changed files with 260 additions and 44 deletions
+87 -1
View File
@@ -1,5 +1,14 @@
import { describe, expect, it } from "vitest";
import { sortRows, parseSortState, cycleSort, type SortState } from "./columnSort";
import {
sortRows,
parseSortState,
cycleSort,
pickSort,
healSort,
hydrateSort,
type SortOption,
type SortState,
} from "./columnSort";
interface Row {
name: string;
@@ -84,3 +93,80 @@ describe("cycleSort", () => {
expect(cycleSort({ key: "name", dir: "desc" }, "views")).toEqual({ key: "views", dir: "asc" });
});
});
const OPTIONS: SortOption[] = [
{ key: "date", label: "Date", defaultDir: "desc" },
{ key: "title", label: "Title" }, // defaultDir omitted → asc
{ key: "shuffle", label: "Shuffle", directionless: true },
];
describe("pickSort", () => {
it("starts a freshly-picked option at its defaultDir (asc fallback)", () => {
expect(pickSort(OPTIONS, null, "date")).toEqual({ key: "date", dir: "desc" });
expect(pickSort(OPTIONS, null, "title")).toEqual({ key: "title", dir: "asc" });
});
it("keeps the current direction when re-picking the same key", () => {
expect(pickSort(OPTIONS, { key: "date", dir: "asc" }, "date")).toEqual({
key: "date",
dir: "asc",
});
});
it("carries a stable dir for a directionless option and never shows a flip", () => {
expect(pickSort(OPTIONS, { key: "date", dir: "asc" }, "shuffle")).toEqual({
key: "shuffle",
dir: "desc",
});
});
it("clears to null on an empty key or an unknown option", () => {
expect(pickSort(OPTIONS, { key: "date", dir: "desc" }, "")).toBeNull();
expect(pickSort(OPTIONS, { key: "date", dir: "desc" }, "ghost")).toBeNull();
});
});
describe("healSort", () => {
it("keeps a sort whose key is still offered", () => {
const s: SortState = { key: "title", dir: "asc" };
expect(healSort(s, OPTIONS)).toBe(s);
});
it("collapses a sort naming an absent option to the fallback", () => {
expect(healSort({ key: "gone", dir: "asc" }, OPTIONS)).toBeNull();
expect(healSort({ key: "gone", dir: "asc" }, OPTIONS, { key: "date", dir: "desc" })).toEqual({
key: "date",
dir: "desc",
});
});
});
describe("hydrateSort", () => {
it("returns the fallback for missing storage", () => {
expect(hydrateSort(null, OPTIONS)).toBeNull();
expect(hydrateSort(null, OPTIONS, { key: "date", dir: "desc" })).toEqual({
key: "date",
dir: "desc",
});
});
it("parses and heals a stored JSON state", () => {
expect(hydrateSort(JSON.stringify({ key: "title", dir: "asc" }), OPTIONS)).toEqual({
key: "title",
dir: "asc",
});
expect(hydrateSort(JSON.stringify({ key: "gone", dir: "asc" }), OPTIONS)).toBeNull();
});
it("tolerates a legacy bare-key string, applying the option's defaultDir", () => {
expect(hydrateSort("date", OPTIONS)).toEqual({ key: "date", dir: "desc" });
expect(hydrateSort("title", OPTIONS)).toEqual({ key: "title", dir: "asc" });
});
it("falls back when a legacy bare-key names an unknown option", () => {
expect(hydrateSort("ghost", OPTIONS, { key: "date", dir: "desc" })).toEqual({
key: "date",
dir: "desc",
});
});
});