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:
@@ -1,11 +1,12 @@
|
|||||||
import { ArrowDown, ArrowUp } from "lucide-react";
|
|
||||||
import { type Column } from "./DataTable";
|
import { type Column } from "./DataTable";
|
||||||
import { type SortState } from "../lib/columnSort";
|
import { type SortState } from "../lib/columnSort";
|
||||||
|
import { optionsFromColumns } from "../lib/sortOptions";
|
||||||
|
import SortControl from "./SortControl";
|
||||||
|
|
||||||
// A sort control for views without clickable column headers (the channel manager's card layout):
|
// The headerless sort control for views without clickable column headers (the channel manager's and
|
||||||
// a dropdown of the sortable columns + an asc/desc toggle, mirroring the feed toolbar's sort. It's
|
// discovery's card layouts). Now a thin adapter over the generic `SortControl`, feeding it the
|
||||||
// driven by the SAME `SortState` the DataTable header uses, so table and cards stay in sync. The
|
// column-derived options and a "no sort" default — kept as its own component so the existing call
|
||||||
// dropdown's options come straight from the columns' `sortable`/`sortValue` metadata — no new list.
|
// sites stay untouched and table/cards keep sharing one `SortState`.
|
||||||
export default function ColumnSortControl<T>({
|
export default function ColumnSortControl<T>({
|
||||||
columns,
|
columns,
|
||||||
sort,
|
sort,
|
||||||
@@ -25,44 +26,15 @@ export default function ColumnSortControl<T>({
|
|||||||
ascLabel: string;
|
ascLabel: string;
|
||||||
descLabel: string;
|
descLabel: string;
|
||||||
}) {
|
}) {
|
||||||
const sortable = columns.filter((c) => c.sortable && c.sortValue);
|
|
||||||
// A stored sort can name a column this table no longer offers (renamed, or `sortable` dropped).
|
|
||||||
// `sortRows` already leaves the rows unsorted then, so treat it as no sort here too — otherwise the
|
|
||||||
// select falls back to showing "default" while a direction arrow claims a sort that isn't applied.
|
|
||||||
const active = sortable.some((c) => c.key === sort?.key) ? sort : null;
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1">
|
<SortControl
|
||||||
<select
|
options={optionsFromColumns(columns)}
|
||||||
value={active?.key ?? ""}
|
sort={sort}
|
||||||
onChange={(e) => {
|
onChange={onChange}
|
||||||
const key = e.target.value;
|
label={label}
|
||||||
// Keep the direction when re-picking the same column; a new column starts ascending.
|
defaultLabel={defaultLabel}
|
||||||
onChange(key ? { key, dir: active?.key === key ? active.dir : "asc" } : null);
|
ascLabel={ascLabel}
|
||||||
}}
|
descLabel={descLabel}
|
||||||
aria-label={label}
|
/>
|
||||||
className="bg-surface border border-border rounded-lg px-2 py-1.5 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
|
||||||
>
|
|
||||||
<option value="">{defaultLabel}</option>
|
|
||||||
{sortable.map((c) => (
|
|
||||||
<option key={c.key} value={c.key}>
|
|
||||||
{c.header}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
{active && (
|
|
||||||
<button
|
|
||||||
onClick={() => onChange({ key: active.key, dir: active.dir === "asc" ? "desc" : "asc" })}
|
|
||||||
title={active.dir === "asc" ? ascLabel : descLabel}
|
|
||||||
aria-label={active.dir === "asc" ? ascLabel : descLabel}
|
|
||||||
className="shrink-0 p-1.5 rounded-lg border border-border bg-card text-fg hover:border-accent hover:text-accent active:translate-y-px transition"
|
|
||||||
>
|
|
||||||
{active.dir === "asc" ? (
|
|
||||||
<ArrowUp className="w-4 h-4" />
|
|
||||||
) : (
|
|
||||||
<ArrowDown className="w-4 h-4" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { type ReactNode } from "react";
|
||||||
|
import { ArrowDown, ArrowUp } from "lucide-react";
|
||||||
|
import { pickSort, type SortOption, type SortState } from "../lib/columnSort";
|
||||||
|
|
||||||
|
// The one sort control for every content surface (YT feed, Plex library, channel manager + its card
|
||||||
|
// view, channel discovery): a dropdown of `SortOption`s + an asc/desc toggle, driven by the shared
|
||||||
|
// `SortState`. Presentation-agnostic to its container — a table feeds column-derived options via
|
||||||
|
// `ColumnSortControl`, the feed/Plex declare their own vocabulary. `defaultLabel` opts a view into a
|
||||||
|
// "no sort" choice (tables); omit it for always-sorted views (feed/Plex). `trailing` is an adornment
|
||||||
|
// slot (the feed's reshuffle button) so the shuffle affordance stays out of the generic control.
|
||||||
|
export default function SortControl({
|
||||||
|
options,
|
||||||
|
sort,
|
||||||
|
onChange,
|
||||||
|
label,
|
||||||
|
ascLabel,
|
||||||
|
descLabel,
|
||||||
|
defaultLabel,
|
||||||
|
trailing,
|
||||||
|
}: {
|
||||||
|
options: SortOption[];
|
||||||
|
sort: SortState;
|
||||||
|
onChange: (next: SortState) => void;
|
||||||
|
/** Accessible name for the dropdown (e.g. "Sort"). */
|
||||||
|
label: string;
|
||||||
|
ascLabel: string;
|
||||||
|
descLabel: string;
|
||||||
|
/** When set, a leading "no sort / original order" option is offered (tables). Omit for
|
||||||
|
* always-sorted views (feed/Plex), which never surface a null sort. */
|
||||||
|
defaultLabel?: string;
|
||||||
|
/** Rendered after the direction toggle (e.g. the feed's reshuffle button). */
|
||||||
|
trailing?: ReactNode;
|
||||||
|
}) {
|
||||||
|
// A stored sort can name an option this surface no longer offers; show it as unsorted rather than
|
||||||
|
// letting an arrow claim a sort that `sortRows`/the server won't actually apply.
|
||||||
|
const active = sort && options.some((o) => o.key === sort.key) ? sort : null;
|
||||||
|
const activeOpt = active ? options.find((o) => o.key === active.key) : undefined;
|
||||||
|
const showArrow = !!activeOpt && !activeOpt.directionless;
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<select
|
||||||
|
value={active?.key ?? ""}
|
||||||
|
onChange={(e) => onChange(pickSort(options, active, e.target.value))}
|
||||||
|
aria-label={label}
|
||||||
|
className="bg-surface border border-border rounded-lg px-2 py-1.5 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
||||||
|
>
|
||||||
|
{defaultLabel !== undefined && <option value="">{defaultLabel}</option>}
|
||||||
|
{options.map((o) => (
|
||||||
|
<option key={o.key} value={o.key}>
|
||||||
|
{o.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{showArrow && active && (
|
||||||
|
<button
|
||||||
|
onClick={() => onChange({ key: active.key, dir: active.dir === "asc" ? "desc" : "asc" })}
|
||||||
|
title={active.dir === "asc" ? ascLabel : descLabel}
|
||||||
|
aria-label={active.dir === "asc" ? ascLabel : descLabel}
|
||||||
|
className="shrink-0 p-1.5 rounded-lg border border-border bg-card text-fg hover:border-accent hover:text-accent active:translate-y-px transition"
|
||||||
|
>
|
||||||
|
{active.dir === "asc" ? (
|
||||||
|
<ArrowUp className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<ArrowDown className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{trailing}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,14 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
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 {
|
interface Row {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -84,3 +93,80 @@ describe("cycleSort", () => {
|
|||||||
expect(cycleSort({ key: "name", dir: "desc" }, "views")).toEqual({ key: "views", dir: "asc" });
|
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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -40,3 +40,56 @@ export function cycleSort(current: SortState, key: string): SortState {
|
|||||||
if (current.dir === "asc") return { key, dir: "desc" };
|
if (current.dir === "asc") return { key, dir: "desc" };
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- the source-agnostic sort vocabulary -------------------------------------------------
|
||||||
|
// A `SortOption[]` is the one shape every content surface (YT feed, Plex library, channel
|
||||||
|
// manager, discovery) hands to the shared `SortControl`. A table derives it from its columns;
|
||||||
|
// the feed and Plex declare it explicitly. Direction lives in `SortState`, never encoded into
|
||||||
|
// the key — the feed's wire string (`feedSort.ts`) is a codec at the server boundary only.
|
||||||
|
|
||||||
|
export interface SortOption {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
/** No meaningful asc/desc flip (the feed's `shuffle`/`relevance`) — hides the direction toggle. */
|
||||||
|
directionless?: boolean;
|
||||||
|
/** The natural first direction when this option is freshly picked (defaults to `"asc"`). */
|
||||||
|
defaultDir?: "asc" | "desc";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Decide the next `SortState` when the user PICKS `key` from a dropdown/chip (NOT the header-click
|
||||||
|
* cycle — that's `cycleSort`). Re-picking the same key keeps its direction; a new key starts at its
|
||||||
|
* `defaultDir` (asc fallback); a directionless option carries a stable dir; an empty key clears sort. */
|
||||||
|
export function pickSort(options: SortOption[], prev: SortState, key: string): SortState {
|
||||||
|
if (!key) return null;
|
||||||
|
const opt = options.find((o) => o.key === key);
|
||||||
|
if (!opt) return null;
|
||||||
|
if (opt.directionless) return { key, dir: "desc" };
|
||||||
|
if (prev?.key === key) return prev;
|
||||||
|
return { key, dir: opt.defaultDir ?? "asc" };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Collapse a sort that names an option no longer offered (a renamed column, or Plex's movie/show
|
||||||
|
* scope swap dropping a key) to `fallback` — otherwise a stale key claims a sort nothing applies. */
|
||||||
|
export function healSort(
|
||||||
|
sort: SortState,
|
||||||
|
options: SortOption[],
|
||||||
|
fallback: SortState = null,
|
||||||
|
): SortState {
|
||||||
|
return sort && options.some((o) => o.key === sort.key) ? sort : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hydrate a persisted raw string into a healed `SortState`. Accepts the JSON `{key,dir}` shape and
|
||||||
|
* tolerates a legacy bare-key string (older builds stored just the key), falling back otherwise. */
|
||||||
|
export function hydrateSort(
|
||||||
|
raw: string | null,
|
||||||
|
options: SortOption[],
|
||||||
|
fallback: SortState = null,
|
||||||
|
): SortState {
|
||||||
|
if (!raw) return fallback;
|
||||||
|
try {
|
||||||
|
return healSort(parseSortState(JSON.parse(raw)), options, fallback);
|
||||||
|
} catch {
|
||||||
|
const opt = options.find((o) => o.key === raw);
|
||||||
|
return opt ? { key: raw, dir: opt.defaultDir ?? "asc" } : fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { type Column } from "../components/tableHeader";
|
||||||
|
import { type SortOption } from "./columnSort";
|
||||||
|
|
||||||
|
/** The `SortOption[]` a table exposes for sorting — its sortable columns, labelled by header. Bridges
|
||||||
|
* the `Column<T>` contract to the source-agnostic sort vocabulary the shared `SortControl` consumes. */
|
||||||
|
export function optionsFromColumns<T>(columns: Column<T>[]): SortOption[] {
|
||||||
|
return columns
|
||||||
|
.filter((c) => c.sortable && c.sortValue)
|
||||||
|
.map((c) => ({ key: c.key, label: c.header }));
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import { getAccountRaw, setAccountRaw } from "./storage";
|
||||||
|
import { hydrateSort, type SortOption, type SortState } from "./columnSort";
|
||||||
|
|
||||||
|
// The per-account persisted sort state, hydrated + healed against the current options — the plumbing
|
||||||
|
// the channel manager and channel discovery each hand-rolled (useState + parseSortState + a persist
|
||||||
|
// key). One home so a new sortable surface (Plex, a future table) gets persistence for free.
|
||||||
|
export function useSortState(
|
||||||
|
storageBase: string,
|
||||||
|
options: SortOption[],
|
||||||
|
fallback: SortState = null,
|
||||||
|
): [SortState, (next: SortState) => void] {
|
||||||
|
const [sort, setSort] = useState<SortState>(() =>
|
||||||
|
hydrateSort(getAccountRaw(storageBase), options, fallback),
|
||||||
|
);
|
||||||
|
const set = useCallback(
|
||||||
|
(next: SortState) => {
|
||||||
|
setSort(next);
|
||||||
|
setAccountRaw(storageBase, JSON.stringify(next));
|
||||||
|
},
|
||||||
|
[storageBase],
|
||||||
|
);
|
||||||
|
return [sort, set];
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user