feat(views): editable saved-view detail — removable filter chips + undo + save-current (S7)
The Personal-views manager's detail pane becomes editable instead of read-only: - Captured filters render as REMOVABLE chips. Removing one prunes the set and writes it straight back to the stored view (PATCH filters), via the same live-row builders (useActiveFilters / buildPlexActiveFilters) that already compute the pruned snapshot — so the modal and the live chip row can't drift. The prune is wrapped in useUndoable + the shared UndoToolbar, so undo/redo re-persist the same way. The undoable draft is the single source of truth for the chip row, so a chip-prune refetch never clobbers the history. - A "Save current" button beside Apply stores the module's currently-applied filters INTO the view (the live→stored inverse of Apply). Disabled when the view already equals the live filters (it's the active one). On save the draft re-seeds to the saved snapshot and clears the stale undo history. - Rename gains a visible commit affordance: a check button beside the name input that enables once the name changes (Enter and blur still commit). Provider gains two methods mirroring `rename`: updateFilters(id, filters) and saveCurrent(id). One shared provider serves feed + plex, so both get it. PATCH filters was already supported end-to-end — FE-only. New i18n: views.saveCurrent / saveCurrentHint / saveCurrentUpToDate (HU+EN). Tests: 5 new in PersonalViewsModal.dom.test.tsx (chip-remove persists, undo re-persists, save-current fires + disables when up-to-date, rename commit).
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
// @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 SavedViewsProvider 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("./SavedViewsProvider", () => ({
|
||||
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);
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<PersonalViewsModal />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
h.updateFilters.mockClear();
|
||||
h.saveCurrent.mockClear();
|
||||
h.rename.mockClear();
|
||||
h.activeViewId = null;
|
||||
});
|
||||
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("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"));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user