E32 S3. The compiler skipped 93 functions; it now skips 29 (try/finally shapes, two suppressions,
VirtualGrid). react-hooks/refs hits 245 -> 31.
- Popover engine: `ref={pop.refs.setReference}` made the compiler treat the whole popover handle as
a ref, so every component holding one was skipped (21 call sites, the engine and its surface
among them). The setter is destructured now; the same for useScrollFade and useReturnFocus.
- Render-phase `ref.current = x` writes moved to `useEffectEvent` where only effects read them,
else to the new `useLatest` (written in a layout effect) / `useStableCallback`.
- FocusSafeButton (under every Button) read the focused element during render; it reads it through
useSyncExternalStore, which React asks on every render instead of a cached memo.
Bugs this surfaced, fixed:
- Combobox: a chip could keep showing its raw id. The label cache was a ref an effect filled after
the render that needed it; it is state merged during render now (test seen RED).
- useUndoable rendered from a ref plus a forced re-render; compiled, that memoizes a stale value.
The history is state now, mirrored in a ref for the stable callbacks.
- ConfigPanel and the saved-views editor re-seeded their drafts in an effect, painting the stale
draft for a frame; both adjust during render against the remembered key.
- Toaster's handover is written after commit instead of during render; a new test pins that the
inherited set is taken once, at the takeover (seen RED on a per-render re-read).
- IssueComposer mutated a ref prop as a one-shot focus token; it is plain state.
- PlexBrowse wrote its info-scroll memory from an effect declared before the ref, past its own
"one writer" rule; the writer comes first now and the effect goes through it.
The compiler ratchet drops to the measured 29 and now also requires FocusSafeButton and the popover
engine to compile.
378 lines
14 KiB
TypeScript
378 lines
14 KiB
TypeScript
import { useState } from "react";
|
||
import { useTranslation } from "react-i18next";
|
||
import { BookmarkPlus, Check, Play, Save, Star, Trash2 } from "lucide-react";
|
||
import {
|
||
closestCenter,
|
||
DndContext,
|
||
KeyboardSensor,
|
||
PointerSensor,
|
||
useSensor,
|
||
useSensors,
|
||
type DragEndEvent,
|
||
} from "@dnd-kit/core";
|
||
import {
|
||
arrayMove,
|
||
SortableContext,
|
||
sortableKeyboardCoordinates,
|
||
verticalListSortingStrategy,
|
||
} from "@dnd-kit/sortable";
|
||
import { CSS } from "@dnd-kit/utilities";
|
||
import { coercePlexFilters, type FeedFilters, type PlexFilters, type SavedView } from "../lib/api";
|
||
import Modal from "./ui/Modal";
|
||
import { Button } from "./ui/Button";
|
||
import { IconButton } from "./ui/IconButton";
|
||
import { useSavedViews, asFilters } from "./savedViewsContext";
|
||
import { useActiveFilters } from "../lib/useActiveFilters";
|
||
import { buildPlexActiveFilters } from "./usePlexActiveFilters";
|
||
import { useUndoable } from "../lib/useUndoable";
|
||
import UndoToolbar from "./ui/UndoToolbar";
|
||
import { useSortableA11y, useSortableItem } from "./ui/sortableA11y";
|
||
import { DragHandle } from "./ui/DragHandle";
|
||
import { OrderButtons } from "./ui/OrderButtons";
|
||
import { Pressable } from "./ui/Pressable";
|
||
import { DismissibleChip } from "./ui/Chip";
|
||
import { Input } from "./ui/Input";
|
||
|
||
// Master–detail manager for saved ("personal") views: the left column lists the views (drag to
|
||
// reorder, or the selected one's order buttons on the right) plus a "save the current filters" input;
|
||
// the right column shows the selected view's name (renamable), its captured filters as REMOVABLE
|
||
// chips (pruning writes back to the stored view, undoable), its default flag, its place in the order,
|
||
// apply, save-current, and delete. Opened from the filter panel's title or the saved-views popover;
|
||
// all mutations flow through SavedViewsProvider.
|
||
function ViewRow({
|
||
view,
|
||
selected,
|
||
active,
|
||
onSelect,
|
||
}: {
|
||
view: SavedView;
|
||
selected: boolean;
|
||
active: boolean;
|
||
onSelect: () => void;
|
||
}) {
|
||
const { t } = useTranslation();
|
||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortableItem({
|
||
id: view.id,
|
||
});
|
||
return (
|
||
<div
|
||
ref={setNodeRef}
|
||
style={{ transform: CSS.Transform.toString(transform), transition }}
|
||
className={`flex items-center gap-1.5 rounded-lg pr-1.5 ${isDragging ? "opacity-60" : ""} ${
|
||
selected ? "bg-accent/15" : "state-layer"
|
||
}`}
|
||
>
|
||
<DragHandle
|
||
label={t("common.order.dragNamed", { name: view.name })}
|
||
{...attributes}
|
||
{...listeners}
|
||
/>
|
||
<Pressable
|
||
selectable
|
||
onClick={onSelect}
|
||
className={`flex-1 min-w-0 truncate py-1.5 text-sm ${selected ? "text-accent" : "text-fg"}`}
|
||
>
|
||
{view.name}
|
||
</Pressable>
|
||
{view.is_default && <Star className="h-3 w-3 shrink-0 fill-current text-accent" />}
|
||
{active && <span className="mr-1 h-1.5 w-1.5 shrink-0 rounded-full bg-accent" />}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** The SELECTED view's two drafts: its name (the rename field) and a local, undoable draft of its
|
||
* captured filters. Removing a chip prunes the filter draft and — via onApply — writes the pruned set
|
||
* straight back to the stored view; Undo/Redo re-persist the same way. The draft is the single
|
||
* source of truth for the chip row, so a chip-prune refetch (which returns the value we just wrote)
|
||
* never clobbers the undo history: both re-seed only when the SELECTION changes (and on Save-current,
|
||
* by the caller). Adjusted DURING render against the remembered selection — an effect painted the
|
||
* previous view's filters and name for a frame first. `selectedId` is the trigger, `selected` only
|
||
* the data. */
|
||
function useSelectedViewDrafts(
|
||
selected: SavedView | null,
|
||
selectedId: number | null,
|
||
updateFilters: (id: number, filters: Record<string, unknown>) => unknown,
|
||
) {
|
||
const [nameDraft, setNameDraft] = useState("");
|
||
const filtersDraft = useUndoable<Record<string, unknown>>(selected?.filters ?? {}, {
|
||
onApply: (f) => {
|
||
if (selected) void updateFilters(selected.id, f);
|
||
},
|
||
});
|
||
const [seededFor, setSeededFor] = useState<number | null | undefined>(undefined);
|
||
if (seededFor !== selectedId) {
|
||
setSeededFor(selectedId);
|
||
filtersDraft.reset(selected?.filters ?? {});
|
||
setNameDraft(selected?.name ?? "");
|
||
}
|
||
return { filtersDraft, nameDraft, setNameDraft };
|
||
}
|
||
|
||
export default function PersonalViewsModal() {
|
||
const { t } = useTranslation();
|
||
const {
|
||
source,
|
||
views,
|
||
activeViewId,
|
||
apply,
|
||
create,
|
||
rename,
|
||
updateFilters,
|
||
saveCurrent,
|
||
toggleDefault,
|
||
remove,
|
||
reorder,
|
||
closeModal,
|
||
} = useSavedViews();
|
||
// The grip's keyboard pick-up (space, the arrows, space) is what its screen-reader instructions
|
||
// promise; without the sensor they described a gesture the list did not have.
|
||
const sensors = useSensors(
|
||
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
|
||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||
);
|
||
|
||
const [selectedId, setSelectedId] = useState<number | null>(activeViewId ?? views[0]?.id ?? null);
|
||
const selected = views.find((v) => v.id === selectedId) ?? null;
|
||
// Two independent name fields: the left one names a NEW view; the right one renames the selected one.
|
||
const [createName, setCreateName] = useState("");
|
||
const { filtersDraft, nameDraft, setNameDraft } = useSelectedViewDrafts(
|
||
selected,
|
||
selectedId,
|
||
updateFilters,
|
||
);
|
||
|
||
// The selected view's captured filters, as the same human labels its module's chip row shows, read
|
||
// from the undoable draft. The feed hook runs unconditionally (fed empty filters off-feed); Plex
|
||
// uses the pure builder. A plex view captures its rail filters, not the watch-state (show="all").
|
||
const noop = () => {};
|
||
const setDraftFeed = (f: FeedFilters) =>
|
||
filtersDraft.set(f as unknown as Record<string, unknown>);
|
||
const feedCaptured = useActiveFilters(
|
||
source === "feed" && selected
|
||
? asFilters(filtersDraft.value as unknown as FeedFilters)
|
||
: ({ tags: [], channelIds: [] } as never),
|
||
source === "feed" && selected ? setDraftFeed : noop,
|
||
);
|
||
const patchDraftPlex = (p: Partial<PlexFilters>) =>
|
||
filtersDraft.set({
|
||
...coercePlexFilters(filtersDraft.value),
|
||
...p,
|
||
} as unknown as Record<string, unknown>);
|
||
const captured =
|
||
source === "plex" && selected
|
||
? buildPlexActiveFilters(
|
||
coercePlexFilters(filtersDraft.value),
|
||
"all",
|
||
patchDraftPlex,
|
||
noop,
|
||
t,
|
||
)
|
||
: feedCaptured;
|
||
|
||
const dndA11y = useSortableA11y((id) => ({
|
||
title: views.find((v) => v.id === id)?.name ?? "",
|
||
pos: views.findIndex((v) => v.id === id) + 1,
|
||
total: views.length,
|
||
}));
|
||
|
||
async function onDragEnd(e: DragEndEvent) {
|
||
const { active, over } = e;
|
||
if (!over || active.id === over.id) return;
|
||
const ids = views.map((v) => v.id);
|
||
const next = arrayMove(ids, ids.indexOf(active.id as number), ids.indexOf(over.id as number));
|
||
await reorder(next);
|
||
}
|
||
// The non-drag way (WCAG 2.5.7): the selected view's Top / Up / Down / Bottom, beside its editor.
|
||
const selectedAt = views.findIndex((v) => v.id === selectedId);
|
||
function moveSelected(to: number) {
|
||
const ids = views.map((v) => v.id);
|
||
void reorder(arrayMove(ids, selectedAt, to));
|
||
}
|
||
|
||
async function onCreate() {
|
||
const name = createName.trim();
|
||
if (!name) return;
|
||
if (await create(name)) setCreateName("");
|
||
}
|
||
|
||
const nameChanged = !!selected && nameDraft.trim() !== "" && nameDraft.trim() !== selected.name;
|
||
async function onRename() {
|
||
if (!selected || !nameChanged) return;
|
||
await rename(selected.id, nameDraft.trim());
|
||
}
|
||
|
||
// Save-current is a no-op when the live filters already equal this exact view (it's the active one).
|
||
const saveCurrentDisabled = selected != null && selected.id === activeViewId;
|
||
|
||
return (
|
||
<Modal
|
||
initialFocus="close"
|
||
title={t("views.manageTitle")}
|
||
onClose={closeModal}
|
||
maxWidth="max-w-2xl"
|
||
>
|
||
<div className="grid min-h-[20rem] grid-cols-[minmax(0,14rem)_1fr] gap-4">
|
||
{/* Left: the list + create */}
|
||
<div className="flex flex-col border-r border-border/60 pr-4">
|
||
<div className="flex-1 space-y-1 overflow-y-auto">
|
||
{views.length === 0 && (
|
||
<p className="px-1 py-2 text-2xs leading-snug text-muted">{t("views.empty")}</p>
|
||
)}
|
||
<DndContext
|
||
sensors={sensors}
|
||
collisionDetection={closestCenter}
|
||
onDragEnd={onDragEnd}
|
||
accessibility={dndA11y}
|
||
>
|
||
<SortableContext
|
||
items={views.map((v) => v.id)}
|
||
strategy={verticalListSortingStrategy}
|
||
>
|
||
{views.map((v) => (
|
||
<ViewRow
|
||
key={v.id}
|
||
view={v}
|
||
selected={v.id === selectedId}
|
||
active={v.id === activeViewId}
|
||
onSelect={() => setSelectedId(v.id)}
|
||
/>
|
||
))}
|
||
</SortableContext>
|
||
</DndContext>
|
||
</div>
|
||
<div className="mt-2 flex items-center gap-1.5 border-t border-border/60 pt-2">
|
||
<Input
|
||
className="flex-1"
|
||
value={createName}
|
||
onChange={(e) => setCreateName(e.target.value)}
|
||
onKeyDown={(e) => e.key === "Enter" && onCreate()}
|
||
placeholder={t("views.namePlaceholder")}
|
||
/>
|
||
<IconButton
|
||
label={t("views.save")}
|
||
size="sm"
|
||
variant="primary"
|
||
onClick={onCreate}
|
||
disabled={!createName.trim()}
|
||
>
|
||
<BookmarkPlus className="h-4 w-4" />
|
||
</IconButton>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Right: the selected view's detail */}
|
||
{selected ? (
|
||
<div className="flex flex-col gap-3">
|
||
<div className="flex items-center gap-1.5">
|
||
<Input
|
||
className="flex-1 font-medium"
|
||
value={nameDraft}
|
||
onChange={(e) => setNameDraft(e.target.value)}
|
||
onKeyDown={(e) => e.key === "Enter" && void onRename()}
|
||
onBlur={() => void onRename()}
|
||
/>
|
||
<IconButton
|
||
variant="outline"
|
||
size="md"
|
||
onClick={() => void onRename()}
|
||
disabled={!nameChanged}
|
||
label={t("views.rename")}
|
||
>
|
||
<Check />
|
||
</IconButton>
|
||
</div>
|
||
<div>
|
||
<div className="mb-1.5 flex items-center justify-between gap-2">
|
||
<span className="text-2xs font-semibold uppercase tracking-wider text-muted">
|
||
{t("views.capturedFilters")}
|
||
</span>
|
||
{(filtersDraft.canUndo || filtersDraft.canRedo) && (
|
||
<UndoToolbar
|
||
canUndo={filtersDraft.canUndo}
|
||
canRedo={filtersDraft.canRedo}
|
||
onUndo={filtersDraft.undo}
|
||
onRedo={filtersDraft.redo}
|
||
/>
|
||
)}
|
||
</div>
|
||
{captured.length === 0 ? (
|
||
<p className="text-xs text-muted">{t("views.capturedNone")}</p>
|
||
) : (
|
||
<div className="flex flex-wrap gap-1.5">
|
||
{captured.map((f) => (
|
||
<DismissibleChip
|
||
key={f.key}
|
||
size="xs"
|
||
onDismiss={f.remove}
|
||
dismissLabel={t("feed.chips.remove", { name: f.label })}
|
||
>
|
||
{f.label}
|
||
</DismissibleChip>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
{views.length > 1 && (
|
||
<OrderButtons from={selectedAt} count={views.length} onMove={moveSelected} />
|
||
)}
|
||
<div className="mt-auto flex flex-wrap items-center gap-2 border-t border-border/60 pt-3">
|
||
<Button
|
||
variant="primary"
|
||
size="sm"
|
||
icon={<Play className="h-3.5 w-3.5" />}
|
||
onClick={() => apply(selected)}
|
||
>
|
||
{t("views.apply")}
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
icon={<Save />}
|
||
onClick={async () => {
|
||
const saved = await saveCurrent(selected.id);
|
||
// Re-seed the draft to what we just stored (the live filters) so the chip row
|
||
// reflects the save; clears the now-stale undo history.
|
||
if (saved) filtersDraft.reset(saved);
|
||
}}
|
||
disabled={saveCurrentDisabled}
|
||
title={
|
||
saveCurrentDisabled ? t("views.saveCurrentUpToDate") : t("views.saveCurrentHint")
|
||
}
|
||
>
|
||
{t("views.saveCurrent")}
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
pressed={selected.is_default}
|
||
onClick={() => void toggleDefault(selected)}
|
||
icon={<Star className={selected.is_default ? "fill-current" : ""} />}
|
||
>
|
||
{t("views.default")}
|
||
</Button>
|
||
<Button
|
||
variant="danger-soft"
|
||
size="sm"
|
||
icon={<Trash2 />}
|
||
className="ml-auto"
|
||
onClick={async () => {
|
||
const next = views.find((v) => v.id !== selected.id);
|
||
// Only move the selection if the delete actually happened (remove awaits a confirm
|
||
// and resolves false on cancel) — otherwise cancelling would still switch the pane.
|
||
if (await remove(selected)) setSelectedId(next?.id ?? null);
|
||
}}
|
||
>
|
||
{t("views.delete")}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="flex items-center justify-center text-sm text-muted">
|
||
{t("views.selectPrompt")}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</Modal>
|
||
);
|
||
}
|