Files
siftlode/frontend/src/components/PersonalViewsModal.dom.test.tsx
T
peter 4d0abc61d5 feat(design): reorder without a drag everywhere a grip drags (WCAG 2.5.7, E28 S3e4a)
One rule, two forms: useOrderItems (Top / Up / Down / Bottom rows of a row
menu) and OrderButtons (four buttons with the place, or a compact Up / Down
pair). A move that would go nowhere is shown off.

- YouTube playlist rows: the four moves now come from useOrderItems.
- Plex playlist rows: a block among the blocks, an episode within its show,
  off while a write is in flight; drag and menu share moveBlock/moveEpisode.
- Saved views editor: the selected view's four buttons (owner: A), and the
  grip's keyboard pick-up (its instructions promised one it did not have).
- Side panel groups in edit mode: an Up / Down pair (owner: A); a group the
  stored order does not hold yet moves too; the name wraps there.
- The grip's touch-action: none is now test-held.
2026-09-26 18:14:28 +02:00

234 lines
9.2 KiB
TypeScript

// @vitest-environment jsdom
//
// S7 — the Personal-views manager gains an EDITABLE detail pane: captured filters are now removable
// chips that write the pruned set back to the stored view (undoable), an explicit Save-current button
// stores the live filters into the view, and rename has a visible commit affordance. This drives the
// real modal against a mocked savedViewsContext so we can assert exactly which mutation fires; the
// captured-filter row uses the REAL useActiveFilters (fed a tag from a seeded react-query cache), so
// the prune snapshots are the genuine ones the live chip row computes.
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, waitFor, cleanup, fireEvent } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const h = vi.hoisted(() => ({
updateFilters: vi.fn(async (_id: number, _filters: Record<string, unknown>) => true),
saveCurrent: vi.fn(async (_id: number) => ({ saved: true }) as Record<string, unknown> | null),
rename: vi.fn(async (_id: number, _name: string) => true),
activeViewId: null as number | null,
}));
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
});
// A full feed filter set that differs from the sidebar default ONLY in `tags`, so useActiveFilters
// surfaces exactly one chip (the tag) — a partial object would read missing show/sort/content as
// "active" and spray spurious chips.
const VIEW_FILTERS = {
tags: [1],
tagModes: { language: "or", topic: "or", other: "or" },
untagged: false,
channelIds: [],
sort: "newest",
includeNormal: true,
includeShorts: false,
includeLive: false,
show: "unwatched",
scope: "my",
q: "",
};
const CTX = {
source: "feed" as const,
views: [{ id: 5, name: "Docs", filters: VIEW_FILTERS, position: 0, is_default: false }],
loading: false,
displayView: null,
isDirty: false,
modalOpen: true,
openModal: vi.fn(),
closeModal: vi.fn(),
apply: vi.fn(),
clearActive: vi.fn(),
create: vi.fn(async () => true),
reorder: vi.fn(async () => {}),
remove: vi.fn(async () => true),
toggleDefault: vi.fn(async () => {}),
};
vi.mock("./savedViewsContext", () => ({
useSavedViews: () => ({
...CTX,
activeViewId: h.activeViewId,
updateFilters: h.updateFilters,
saveCurrent: h.saveCurrent,
rename: h.rename,
}),
// Passthrough coercion — the modal only needs tags/channelIds to be arrays before the chip builder.
asFilters: (raw: Record<string, unknown>) => ({
...raw,
tags: (raw.tags as unknown[]) ?? [],
channelIds: (raw.channelIds as unknown[]) ?? [],
}),
}));
import PersonalViewsModal from "./PersonalViewsModal";
import { api } from "../lib/api";
import { qk } from "../lib/queryKeys";
function mount() {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
qc.setQueryData(qk.tags(), [{ id: 1, name: "Docu" }]);
vi.spyOn(api, "tags").mockResolvedValue([{ id: 1, name: "Docu" }] as never);
const utils = render(
<QueryClientProvider client={qc}>
<PersonalViewsModal />
</QueryClientProvider>,
);
return { ...utils, qc };
}
beforeEach(() => {
h.updateFilters.mockClear();
h.saveCurrent.mockClear();
h.rename.mockClear();
h.activeViewId = null;
// Fresh baseline each test — one test mutates CTX.views to simulate a refetch.
CTX.views = [{ id: 5, name: "Docs", filters: VIEW_FILTERS, position: 0, is_default: false }];
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
describe("PersonalViewsModal — S7 editable detail pane", () => {
it("removing a captured-filter chip writes the pruned filters back to the stored view", async () => {
mount();
const removeBtn = await screen.findByLabelText("feed.chips.remove");
fireEvent.click(removeBtn);
await waitFor(() => expect(h.updateFilters).toHaveBeenCalledTimes(1));
expect(h.updateFilters.mock.calls[0]![0]).toBe(5);
expect(h.updateFilters.mock.calls[0]![1]).toMatchObject({ tags: [] });
});
it("the prune is undoable — Undo re-persists the original filters", async () => {
mount();
fireEvent.click(await screen.findByLabelText("feed.chips.remove"));
await waitFor(() => expect(h.updateFilters).toHaveBeenCalledTimes(1));
// The undo control only appears once there is history.
const undo = await screen.findByLabelText("common.undo");
fireEvent.click(undo);
await waitFor(() => expect(h.updateFilters).toHaveBeenCalledTimes(2));
expect(h.updateFilters.mock.calls[1]![1]).toMatchObject({ tags: [1] });
});
it("a same-view refetch (new-identity filters) does not wipe the undo history", async () => {
// Guards the [selectedId]-only re-seed effect (its exhaustive-deps disable): after a chip prune the
// real provider refetches and hands back the SAME view id with a fresh-identity filters object. The
// draft must NOT re-seed on that (it would destroy the undo history and the Undo control). A deps
// regression that added selected.filters would make this fail.
const { rerender, qc } = mount();
fireEvent.click(await screen.findByLabelText("feed.chips.remove"));
await waitFor(() => expect(h.updateFilters).toHaveBeenCalledTimes(1));
expect(await screen.findByLabelText("common.undo")).toBeTruthy();
// Simulate the post-write refetch: same id 5, new object identity, carrying the pruned value.
CTX.views = [
{
id: 5,
name: "Docs",
filters: { ...VIEW_FILTERS, tags: [] },
position: 0,
is_default: false,
},
];
rerender(
<QueryClientProvider client={qc}>
<PersonalViewsModal />
</QueryClientProvider>,
);
// Undo history survived the refetch — the control is still there and still re-persists on click.
const undo = await screen.findByLabelText("common.undo");
fireEvent.click(undo);
await waitFor(() => expect(h.updateFilters).toHaveBeenCalledTimes(2));
expect(h.updateFilters.mock.calls[1]![1]).toMatchObject({ tags: [1] });
});
it("Save-current stores the live filters into the view when it isn't already the active one", async () => {
mount();
fireEvent.click(await screen.findByText("views.saveCurrent"));
await waitFor(() => expect(h.saveCurrent).toHaveBeenCalledWith(5));
});
it("Save-current is disabled when the selected view is already the active one", async () => {
h.activeViewId = 5;
mount();
const btn = (await screen.findByText("views.saveCurrent")).closest(
"button",
) as HTMLButtonElement;
expect(btn.disabled).toBe(true);
fireEvent.click(btn);
expect(h.saveCurrent).not.toHaveBeenCalled();
});
it("rename commits via the visible check button once the name changes", async () => {
mount();
const input = await screen.findByDisplayValue("Docs");
// No change yet → the commit button is disabled.
const commit = screen.getByLabelText("views.rename").closest("button") as HTMLButtonElement;
expect(commit.disabled).toBe(true);
fireEvent.change(input, { target: { value: "Documentaries" } });
expect(commit.disabled).toBe(false);
fireEvent.click(commit);
await waitFor(() => expect(h.rename).toHaveBeenCalledWith(5, "Documentaries"));
});
});
describe("PersonalViewsModal — reordering without a drag (WCAG 2.5.7)", () => {
const view = (id: number, name: string, position: number) => ({
id,
name,
filters: VIEW_FILTERS,
position,
is_default: false,
});
it("moves the selected view with its order buttons, the moves that go nowhere held off", async () => {
CTX.reorder.mockClear();
CTX.views = [view(5, "Docs", 0), view(6, "Music", 1), view(7, "Tech", 2)];
mount();
const group = await screen.findByRole("group", { name: /common\.order\.label/ });
const btn = (name: string) =>
Array.from(group.querySelectorAll("button")).find(
(b) => b.getAttribute("aria-label") === name,
)!;
expect(btn("common.order.top").disabled).toBe(true);
expect(btn("common.order.up").disabled).toBe(true);
fireEvent.click(btn("common.order.down"));
expect(CTX.reorder).toHaveBeenLastCalledWith([6, 5, 7]);
fireEvent.click(btn("common.order.bottom"));
expect(CTX.reorder).toHaveBeenLastCalledWith([6, 7, 5]);
// A view picked in the list moves from ITS place, not the first one's.
fireEvent.click(screen.getByRole("button", { name: "Tech" }));
await waitFor(() => expect(btn("common.order.up").disabled).toBe(false));
fireEvent.click(btn("common.order.up"));
expect(CTX.reorder).toHaveBeenLastCalledWith([5, 7, 6]);
});
it("has no order buttons for a single view", async () => {
mount();
await screen.findByDisplayValue("Docs");
expect(screen.queryByRole("group", { name: /common\.order\.label/ })).toBeNull();
});
it("picks a view up from the keyboard, as its grip's instructions say", async () => {
CTX.views = [view(5, "Docs", 0), view(6, "Music", 1)];
mount();
const grip = (await screen.findAllByRole("button", { name: "views.reorder" }))[0]!;
grip.focus();
fireEvent.keyDown(grip, { code: "Space", key: " " });
await waitFor(() => expect(document.body.textContent).toContain("common.dnd.pickedUp"));
});
});