diff --git a/frontend/src/components/PersonalViewsModal.dom.test.tsx b/frontend/src/components/PersonalViewsModal.dom.test.tsx new file mode 100644 index 00000000..9b9a18dc --- /dev/null +++ b/frontend/src/components/PersonalViewsModal.dom.test.tsx @@ -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) => true), + saveCurrent: vi.fn(async (_id: number) => ({ saved: true }) as Record | 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(); + 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) => ({ + ...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( + + + , + ); +} + +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")); + }); +}); diff --git a/frontend/src/components/PersonalViewsModal.tsx b/frontend/src/components/PersonalViewsModal.tsx index cc027bc4..2e85c361 100644 --- a/frontend/src/components/PersonalViewsModal.tsx +++ b/frontend/src/components/PersonalViewsModal.tsx @@ -1,6 +1,6 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { BookmarkPlus, GripVertical, Play, Star, Trash2 } from "lucide-react"; +import { BookmarkPlus, Check, GripVertical, Play, Save, Star, Trash2, X } from "lucide-react"; import { closestCenter, DndContext, @@ -16,15 +16,18 @@ import { verticalListSortingStrategy, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; -import { coercePlexFilters, type FeedFilters, type SavedView } from "../lib/api"; +import { coercePlexFilters, type FeedFilters, type PlexFilters, type SavedView } from "../lib/api"; import Modal from "./Modal"; import { useSavedViews, asFilters } from "./SavedViewsProvider"; import { useActiveFilters } from "../lib/useActiveFilters"; import { buildPlexActiveFilters } from "./usePlexActiveFilters"; +import { useUndoable } from "../lib/useUndoable"; +import UndoToolbar from "./UndoToolbar"; // Master–detail manager for saved ("personal") views: the left column lists the views (drag to -// reorder) plus a "save the current filters" input; the right column shows the selected view's name, -// what filters it captures, its default flag, apply, and delete. Opened from the filter panel's title +// reorder) 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, 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, @@ -80,6 +83,8 @@ export default function PersonalViewsModal() { apply, create, rename, + updateFilters, + saveCurrent, toggleDefault, remove, reorder, @@ -89,21 +94,55 @@ export default function PersonalViewsModal() { const [selectedId, setSelectedId] = useState(activeViewId ?? views[0]?.id ?? null); const selected = views.find((v) => v.id === selectedId) ?? null; - const [draftName, setDraftName] = useState(""); + // Two independent name fields: the left one names a NEW view; the right one renames the selected one. + const [createName, setCreateName] = useState(""); + const [nameDraft, setNameDraft] = useState(""); - // The selected view's captured filters, as the same human labels its module's chip row shows. The - // feed hook runs unconditionally (fed empty filters off-feed); Plex uses the pure builder (no-op - // removes — this is display-only). A plex view captures its rail filters, not the watch-state. + // A local, undoable draft of the SELECTED view's captured filters. Removing a chip prunes the 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. We only re-seed it on an + // external change of the visible filters: switching the selection, or Save-current below. + const filtersDraft = useUndoable>(selected?.filters ?? {}, { + onApply: (f) => { + if (selected) void updateFilters(selected.id, f); + }, + }); + const resetDraft = filtersDraft.reset; + useEffect(() => { + resetDraft(selected?.filters ?? {}); + setNameDraft(selected?.name ?? ""); + // Re-seed only when the selection changes (not on every same-view refetch), so pruning keeps its + // undo history. Save-current re-seeds explicitly in its own handler. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedId]); + + // 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); const feedCaptured = useActiveFilters( source === "feed" && selected - ? asFilters(selected.filters as unknown as FeedFilters) + ? asFilters(filtersDraft.value as unknown as FeedFilters) : ({ tags: [], channelIds: [] } as never), - noop, + source === "feed" && selected ? setDraftFeed : noop, ); + const patchDraftPlex = (p: Partial) => + filtersDraft.set({ + ...coercePlexFilters(filtersDraft.value), + ...p, + } as unknown as Record); const captured = source === "plex" && selected - ? buildPlexActiveFilters(coercePlexFilters(selected.filters), "all", noop, noop, t) + ? buildPlexActiveFilters( + coercePlexFilters(filtersDraft.value), + "all", + patchDraftPlex, + noop, + t, + ) : feedCaptured; async function onDragEnd(e: DragEndEvent) { @@ -115,11 +154,20 @@ export default function PersonalViewsModal() { } async function onCreate() { - const name = draftName.trim(); + const name = createName.trim(); if (!name) return; - if (await create(name)) setDraftName(""); + 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 (
@@ -148,15 +196,15 @@ export default function PersonalViewsModal() {
setDraftName(e.target.value)} + value={createName} + onChange={(e) => setCreateName(e.target.value)} onKeyDown={(e) => e.key === "Enter" && onCreate()} placeholder={t("views.namePlaceholder")} className="min-w-0 flex-1 rounded-lg border border-border bg-surface px-2 py-1.5 text-sm focus:border-accent focus:outline-hidden focus:ring-2 focus:ring-accent/30" /> +
-
- {t("views.capturedFilters")} +
+ + {t("views.capturedFilters")} + + {(filtersDraft.canUndo || filtersDraft.canRedo) && ( + + )}
{captured.length === 0 ? (

{t("views.capturedNone")}

@@ -188,15 +255,23 @@ export default function PersonalViewsModal() { {captured.map((f) => ( {f.label} + ))}
)}
-
+
+