feat(frontend): the folders are layers — an import only goes down, a module is reached through its index.ts
`local/layers` (eslint.config.js) resolves every import to its file and refuses one that goes up (lib/ → components/ui/ → features/ → modules/ → shell/) or that reaches into another module's own files. A module's index.ts is its public face: the stores, mounted at the root and read app-wide, which features/ may read too; a page is still opened lazily by import(). Its contract test probes every edge, and three mutants of the rule (off, no face check, a door for everyone) each turn it red. Six modules got that face; the shared channel UI (about, tags, tag manager) moved to features/channel, the filter chips and the saved-views context to features/filters, and the prefs store's controller type moved next to the store instead of importing it from its own page. The header's per-module controls and the eagerly loaded rails keep eight inline suppressions, each naming why: they go when the header gets slots and every rail loads lazily. `chunkGuard` fails the production build when a lazily imported module lands in a boot chunk (the entry, the shell, what they import) — e.g. a page re-exported from an index.ts, which makes its import() a silent no-op. Proved on a build with that mutant; today's build has none.
This commit is contained in:
@@ -24,7 +24,7 @@ vi.mock("./feedFiltersContext", () => ({
|
||||
useFeedFilters: () => ({ includeNormal: true, includeShorts: false, includeLive: false }),
|
||||
}));
|
||||
vi.mock("./Feed", () => ({ default: () => null }));
|
||||
vi.mock("../channels/ChannelAbout", () => ({ default: () => null }));
|
||||
vi.mock("../../features/channel/ChannelAbout", () => ({ default: () => null }));
|
||||
|
||||
import ChannelPage from "./ChannelPage";
|
||||
import { api, type ChannelDetail, type FeedFilters, type Me } from "../../lib/api";
|
||||
|
||||
@@ -22,7 +22,7 @@ vi.mock("./feedFiltersContext", () => ({
|
||||
useFeedFilters: () => ({ includeNormal: true, includeShorts: false, includeLive: false }),
|
||||
}));
|
||||
vi.mock("./Feed", () => ({ default: () => null }));
|
||||
vi.mock("../channels/ChannelAbout", () => ({ default: () => null }));
|
||||
vi.mock("../../features/channel/ChannelAbout", () => ({ default: () => null }));
|
||||
// The page's chrome portals into the shell's fixed toolbar band, which this test has no shell for.
|
||||
vi.mock("../../components/ui/PageShell", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../../components/ui/PageShell")>()),
|
||||
|
||||
@@ -22,7 +22,7 @@ vi.mock("./feedFiltersContext", () => ({
|
||||
useFeedFilters: () => ({ includeNormal: true, includeShorts: false, includeLive: false }),
|
||||
}));
|
||||
vi.mock("./Feed", () => ({ default: () => null }));
|
||||
vi.mock("../channels/ChannelAbout", () => ({ default: () => null }));
|
||||
vi.mock("../../features/channel/ChannelAbout", () => ({ default: () => null }));
|
||||
// The page's chrome portals into the shell's fixed toolbar band, which this test has no shell for.
|
||||
vi.mock("../../components/ui/PageShell", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../../components/ui/PageShell")>()),
|
||||
|
||||
@@ -21,7 +21,7 @@ vi.mock("./feedFiltersContext", () => ({
|
||||
useFeedFilters: () => ({ includeNormal: true, includeShorts: false, includeLive: false }),
|
||||
}));
|
||||
vi.mock("./Feed", () => ({ default: () => null }));
|
||||
vi.mock("../channels/ChannelAbout", () => ({ default: () => null }));
|
||||
vi.mock("../../features/channel/ChannelAbout", () => ({ default: () => null }));
|
||||
vi.mock("../../components/ui/PageShell", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../../components/ui/PageShell")>()),
|
||||
PageToolbar: ({ children }: { children?: ReactNode }) => <>{children}</>,
|
||||
|
||||
@@ -26,7 +26,7 @@ import Tabs from "../../components/ui/Tabs";
|
||||
import { useTabPanel } from "../../components/ui/tabPanel";
|
||||
import { useScrollFold } from "../../components/ui/scrollFold";
|
||||
import Feed from "./Feed";
|
||||
import ChannelAboutContent from "../channels/ChannelAbout";
|
||||
import ChannelAboutContent from "../../features/channel/ChannelAbout";
|
||||
import { PageToolbar } from "../../components/ui/PageShell";
|
||||
import { PageToolbarSlot } from "../../components/ui/pageToolbarSlot";
|
||||
import { useFeedFilters } from "./feedFiltersContext";
|
||||
@@ -46,7 +46,7 @@ import {
|
||||
} from "../../lib/api";
|
||||
import type { FeedView } from "../../lib/feedView";
|
||||
import { channelYouTubeUrl, formatDate, formatCompact } from "../../lib/format";
|
||||
import { ChannelTagsControl } from "../channels/ChannelTags";
|
||||
import { ChannelTagsControl } from "../../features/channel/ChannelTags";
|
||||
import { useChannelTagActions } from "../../lib/useChannelTagActions";
|
||||
import { Button } from "../../components/ui/Button";
|
||||
import { IconButton } from "../../components/ui/IconButton";
|
||||
|
||||
@@ -28,7 +28,7 @@ vi.mock("./Feed", () => ({
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
vi.mock("../channels/ChannelAbout", () => ({ default: () => null }));
|
||||
vi.mock("../../features/channel/ChannelAbout", () => ({ default: () => null }));
|
||||
vi.mock("../../components/ui/PageShell", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../../components/ui/PageShell")>()),
|
||||
PageToolbar: ({ children }: { children?: ReactNode }) => <>{children}</>,
|
||||
|
||||
@@ -45,7 +45,7 @@ vi.mock("../plex/plexContext", () => ({
|
||||
vi.mock("../../components/ui/confirmContext", () => ({ useConfirm: () => async () => true }));
|
||||
|
||||
import { SavedViewsProvider } from "./SavedViewsProvider";
|
||||
import { useSavedViews } from "./savedViewsContext";
|
||||
import { useSavedViews } from "../../features/filters/savedViewsContext";
|
||||
import { api, type SavedView } from "../../lib/api";
|
||||
import { claimLocalData, LS } from "../../lib/storage";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
|
||||
@@ -17,13 +17,13 @@ import { LS, ownKey } from "../../lib/storage";
|
||||
import { useConfirm } from "../../components/ui/confirmContext";
|
||||
import { useNavigation } from "../../features/navigation/navigationContext";
|
||||
import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
|
||||
import { usePlex, usePlexActions } from "../plex/plexContext";
|
||||
import { usePlex, usePlexActions } from "../plex";
|
||||
import {
|
||||
asFilters,
|
||||
SavedViewsCtx,
|
||||
type SavedViewsContext,
|
||||
type ViewSource,
|
||||
} from "./savedViewsContext";
|
||||
} from "../../features/filters/savedViewsContext";
|
||||
|
||||
// Saved "smart views" now serve TWO modules — the feed and Plex — keyed by `source`. This one provider
|
||||
// switches which module it operates on by the active page (both filter providers are global, so it can
|
||||
|
||||
@@ -7,9 +7,9 @@ import { api, NO_TAG_SENTINEL, type FeedFilters, type Tag, type TagCategory } fr
|
||||
import { defaultLayout, type PanelLayout } from "../../lib/panelLayout";
|
||||
import { useDebounced } from "../../lib/useDebounced";
|
||||
import { useActiveFilters } from "../../lib/useActiveFilters";
|
||||
import TagManager from "../channels/TagManager";
|
||||
import TagManager from "../../features/channel/TagManager";
|
||||
import SavedViewsPopover from "../../features/filters/SavedViewsPopover";
|
||||
import { useSavedViews } from "./savedViewsContext";
|
||||
import { useSavedViews } from "../../features/filters/savedViewsContext";
|
||||
import FilterRail from "../../components/ui/FilterRail";
|
||||
import { type PanelItem } from "../../components/ui/PanelGroups";
|
||||
import { type MediaSourceKind } from "../../lib/mediaSource";
|
||||
@@ -20,7 +20,7 @@ import YearFilter from "./YearFilter";
|
||||
import Combobox from "../../components/ui/Combobox";
|
||||
import MatchToggle from "../../components/ui/MatchToggle";
|
||||
import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
|
||||
import { useChannelsActions } from "../channels/channelsContext";
|
||||
import { useChannelsActions } from "../channels";
|
||||
import { useTags } from "../../lib/useTags";
|
||||
import { compareText } from "../../lib/collate";
|
||||
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The chip-overflow popovers are open/closed by a DERIVED flag (`openRaw && hidden > 0`), which is
|
||||
// what keeps a menu from outliving the "+N" trigger it is anchored to for a frame. The trap the
|
||||
// derivation sets is that it hides the surface without closing it: floating-ui only reports a close
|
||||
// for a real event on a mounted surface, so when the lane stops overflowing, the trigger and the
|
||||
// menu disappear while the raw flag stays true — and the next re-fit that overflows again brings
|
||||
// back a menu nobody asked for. `useChipOverflowFit` re-decides from a ResizeObserver, so that
|
||||
// second re-fit needs no user action at all.
|
||||
//
|
||||
// The fit hook is mocked so `visible` is drivable: in jsdom every box measures 0, so the real
|
||||
// measurement can only ever produce one answer.
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (k: string, o?: Record<string, unknown>) => (o?.count != null ? `${k}:${o.count}` : k),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const fit = { visible: 1 };
|
||||
vi.mock("../../lib/useChipOverflowFit", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../lib/useChipOverflowFit")>();
|
||||
return { ...actual, useChipOverflowFit: () => fit.visible };
|
||||
});
|
||||
|
||||
import TagFilterChips, { type FilterChip } from "./TagFilterChips";
|
||||
|
||||
const chip = (key: string): FilterChip => ({ key, label: key, active: false, onToggle: () => {} });
|
||||
const chips = [chip("alpha"), chip("beta"), chip("gamma")];
|
||||
|
||||
// The surface mounts with `visibility: hidden` until floating-ui has positioned it, which never
|
||||
// happens in jsdom (every box measures 0) — so it is out of the accessibility tree and only a
|
||||
// `hidden: true` query can see it. Presence is the whole question here, not visibility.
|
||||
const menu = () => screen.queryByRole("menu", { hidden: true });
|
||||
const moreBtn = () => screen.queryByRole("button", { name: /channels.tags.moreCount/ });
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
fit.visible = 1;
|
||||
});
|
||||
|
||||
describe("TagFilterChips — the overflow menu", () => {
|
||||
it("does not come back on its own once the lane stops overflowing", () => {
|
||||
const { rerender } = render(<TagFilterChips chips={chips} />);
|
||||
fireEvent.click(moreBtn()!);
|
||||
expect(menu()).toBeTruthy();
|
||||
|
||||
// The lane widens (a resize, the text-size slider, the trailing Clear button unmounting) and
|
||||
// nothing is collapsed any more: both trigger and menu go.
|
||||
fit.visible = 3;
|
||||
rerender(<TagFilterChips chips={chips} />);
|
||||
expect(menu()).toBeNull();
|
||||
expect(moreBtn()).toBeNull();
|
||||
|
||||
// …and it narrows again, with no user interaction in between. The menu must stay shut, and the
|
||||
// returning trigger must not claim to be expanded.
|
||||
fit.visible = 1;
|
||||
rerender(<TagFilterChips chips={chips} />);
|
||||
expect(menu()).toBeNull();
|
||||
expect(moreBtn()).toHaveProperty("ariaExpanded", "false");
|
||||
});
|
||||
|
||||
it("does not come back when the chip set SHRINKS under the open menu", () => {
|
||||
// The nastier half of the same bug, and it needs the hook's real timing to reproduce.
|
||||
// `useChipOverflowFit` keeps `visible` in state and only rewrites it from a LAYOUT EFFECT, so
|
||||
// the render in which the set shrinks still carries the measurement taken for the PREVIOUS,
|
||||
// longer set. `length - visible` is negative in that render, a `hidden === 0` guard reading it
|
||||
// unclamped never fires, and the flag survives the one window it exists to close.
|
||||
//
|
||||
// So `fit.visible` is left ALONE across the shrink — that staleness is the precondition, not a
|
||||
// convenience of the mock. Five chips of which three fit, then three of them are deleted in the
|
||||
// tag manager — so the shrink render computes `chips.length - visible` = 2 - 3 = -1.
|
||||
const five = ["alpha", "beta", "gamma", "delta", "epsilon"].map(chip);
|
||||
fit.visible = 3;
|
||||
const { rerender } = render(<TagFilterChips chips={five} />);
|
||||
fireEvent.click(moreBtn()!);
|
||||
expect(menu()).toBeTruthy();
|
||||
|
||||
rerender(<TagFilterChips chips={five.slice(0, 2)} />); // `visible` is still the stale 3
|
||||
expect(menu()).toBeNull();
|
||||
|
||||
// …and now the layout effect lands: two long names, only one of them fits.
|
||||
fit.visible = 1;
|
||||
rerender(<TagFilterChips chips={five.slice(0, 2)} />);
|
||||
expect(menu()).toBeNull();
|
||||
expect(moreBtn()).toHaveProperty("ariaExpanded", "false");
|
||||
});
|
||||
|
||||
it("still opens and closes on the trigger", () => {
|
||||
render(<TagFilterChips chips={chips} />);
|
||||
fireEvent.click(moreBtn()!);
|
||||
expect(menu()).toBeTruthy();
|
||||
fireEvent.click(moreBtn()!);
|
||||
expect(menu()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,147 +0,0 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { fitChipsWithPill } from "../../lib/chipOverflow";
|
||||
import { chipLaneClass, useChipOverflowFit } from "../../lib/useChipOverflowFit";
|
||||
import { Chip } from "../../components/ui/Chip";
|
||||
import { chipClass } from "../../components/ui/chipStyles";
|
||||
import { PopoverSurface } from "../../components/ui/AnchoredPopover";
|
||||
import { useAnchoredPopover } from "../../components/ui/anchoredPopoverHooks";
|
||||
import { SURFACE } from "../../components/ui/surfaces";
|
||||
import { ChipLabelPending } from "../../components/ui/Skeleton";
|
||||
|
||||
// A single-line row of toggle filter chips that collapses its overflow into a trailing "+N" pill whose
|
||||
// popover holds the rest (still toggleable) — the same compression the feed's applied-filter row uses
|
||||
// (ActiveFilterChips), sharing the same measure core (useChipOverflowFit + fitChipsWithPill). Used by
|
||||
// the channel manager's tag-filter row so it never wraps to a second line and the trailing "…" actions
|
||||
// stay pinned on the first line. Any fixed leading (the TAGS editor trigger) / trailing (Clear, the "…"
|
||||
// menu) chrome lives OUTSIDE this lane in the caller, so only the chips themselves fold into "+N".
|
||||
|
||||
export interface FilterChip {
|
||||
key: string;
|
||||
label: string;
|
||||
/** The name is still on its way: the chip draws a placeholder, not `label` (a raw id). */
|
||||
pending?: boolean;
|
||||
active: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
// A chip's text — the visible chip and the hidden measuring mirror both draw it, so the mirror
|
||||
// measures what shows (ActiveFilterChips' ChipText, for this row's chips).
|
||||
function ChipText({ chip }: { chip: FilterChip }) {
|
||||
return chip.pending ? <ChipLabelPending value={chip.label} /> : <>{chip.label}</>;
|
||||
}
|
||||
|
||||
function TagChip({ chip }: { chip: FilterChip }) {
|
||||
return (
|
||||
<Chip onClick={chip.onToggle} pressed={chip.active}>
|
||||
<ChipText chip={chip} />
|
||||
</Chip>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TagFilterChips({
|
||||
chips,
|
||||
className,
|
||||
}: {
|
||||
chips: FilterChip[];
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const rowRef = useRef<HTMLDivElement | null>(null);
|
||||
const chipRefs = useRef<Array<HTMLElement | null>>([]);
|
||||
const pillRef = useRef<HTMLSpanElement | null>(null);
|
||||
// Re-fit when the set, the labels, OR the pressed state change (the pressed paint can change a chip's
|
||||
// width via the `.pill` recipe, so an aria-pressed flip must re-measure too).
|
||||
const key = chips
|
||||
.map((c) => `${c.key}:${c.label}:${c.active ? 1 : 0}${c.pending ? "…" : ""}`)
|
||||
.join("|");
|
||||
|
||||
const visible = useChipOverflowFit<number>({
|
||||
rowRef,
|
||||
chipsRef: chipRefs,
|
||||
pillRef,
|
||||
deps: key,
|
||||
initial: chips.length,
|
||||
decide: (g) =>
|
||||
fitChipsWithPill({ chipRights: g.chipRights, pillW: g.pillW, avail: g.avail, gap: g.gap }),
|
||||
});
|
||||
|
||||
// Clamped, and not defensively: `useChipOverflowFit` keeps `visible` in state and re-decides in a
|
||||
// layout effect, so the render in which the chip set SHRINKS still holds the previous measurement
|
||||
// and a raw subtraction goes negative. An unclamped `hidden` slips past the `=== 0` reset below —
|
||||
// the flag survives exactly the window it exists to close, and the popover springs back on the
|
||||
// next re-fit.
|
||||
const hidden = Math.max(0, chips.length - visible);
|
||||
// The popover can only be open while there is a "+N" trigger to anchor it. Derived rather than
|
||||
// closed by an effect, so the frame in which the last overflowing chip fits again cannot also
|
||||
// contain a menu hanging off a reference that is already gone.
|
||||
//
|
||||
// Deriving alone is NOT enough, and the missing half is the flag itself: floating-ui only reports
|
||||
// a close for a real event on a mounted surface, so when the lane stops overflowing the trigger
|
||||
// and the menu just disappear and `openRaw` stays true. The next re-fit that overflows again —
|
||||
// a window resize, the text-size slider, or the trailing Clear button mounting as a tag is
|
||||
// toggled; `useChipOverflowFit` re-decides from a ResizeObserver, with no user near this menu —
|
||||
// would then re-open a menu nobody asked for, with its trigger already `aria-expanded`. So the
|
||||
// flag is reset in RENDER against the same condition that hides it: convergent (the write
|
||||
// falsifies it) and, unlike an effect, gone before the frame is painted.
|
||||
const [openRaw, setOpen] = useState(false);
|
||||
if (openRaw && hidden === 0) setOpen(false);
|
||||
const open = openRaw && hidden > 0;
|
||||
const pop = useAnchoredPopover({
|
||||
open,
|
||||
onOpenChange: setOpen,
|
||||
mode: "menu",
|
||||
placement: "bottom-end",
|
||||
});
|
||||
const { setReference } = pop.refs;
|
||||
const moreLabel = t("channels.tags.moreCount", { count: hidden });
|
||||
|
||||
return (
|
||||
<div className={`${chipLaneClass} flex items-center gap-1.5 min-w-0 ${className ?? ""}`}>
|
||||
{/* Hidden measurer: all chips + a sample "+N" pill at natural widths, so the shared fit core can
|
||||
read each chip's right edge, the rem gap and the pill width off real boxes. */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none invisible absolute left-0 top-0 flex items-center gap-1.5 whitespace-nowrap"
|
||||
>
|
||||
{chips.map((c, i) => (
|
||||
<span
|
||||
key={c.key}
|
||||
ref={(el) => {
|
||||
chipRefs.current[i] = el;
|
||||
}}
|
||||
className="inline-flex"
|
||||
>
|
||||
<span className={chipClass()}>
|
||||
<ChipText chip={c} />
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
<span ref={pillRef} className={chipClass()}>
|
||||
+{chips.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div ref={rowRef} className="flex items-center gap-1.5 min-w-0 flex-1 overflow-hidden">
|
||||
{chips.slice(0, visible).map((c) => (
|
||||
<TagChip key={c.key} chip={c} />
|
||||
))}
|
||||
{hidden > 0 && (
|
||||
<Chip ref={setReference} aria-label={moreLabel} {...pop.getReferenceProps()}>
|
||||
+{hidden}
|
||||
</Chip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<PopoverSurface
|
||||
popover={pop}
|
||||
ariaLabel={moreLabel}
|
||||
className={`${SURFACE.popover} flex max-w-[22rem] flex-wrap items-center gap-1.5 p-2 animate-[popIn_0.16s_ease]`}
|
||||
>
|
||||
{chips.slice(visible).map((c) => (
|
||||
<TagChip key={c.key} chip={c} />
|
||||
))}
|
||||
</PopoverSurface>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// The feed's public face: its stores (filters, view, saved views), mounted at the root and read app-wide.
|
||||
export { FeedFiltersProvider } from "./FeedFiltersProvider";
|
||||
export { FeedViewProvider } from "./FeedViewProvider";
|
||||
export { SavedViewsProvider } from "./SavedViewsProvider";
|
||||
export { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
|
||||
export { useFeedView, useFeedViewActions } from "./feedViewContext";
|
||||
@@ -4,7 +4,7 @@ import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
|
||||
import { useFeedView, useFeedViewActions } from "./feedViewContext";
|
||||
import { useNavigation, useNavigationActions } from "../../features/navigation/navigationContext";
|
||||
import PersonalViewsModal from "../../features/filters/PersonalViewsModal";
|
||||
import { useSavedViews } from "./savedViewsContext";
|
||||
import { useSavedViews } from "../../features/filters/savedViewsContext";
|
||||
|
||||
// The two module-registry pages that need per-render wiring (see moduleRegistry), split out of it so
|
||||
// that file exports only data and this one only components (Fast Refresh). Their lazy chunks are
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
import { createContext, useContext } from "react";
|
||||
import { coerceTagModes, coerceUntagged, type FeedFilters, type SavedView } from "../../lib/api";
|
||||
|
||||
// The saved-views context + hook (and the feed blob coercion its consumers share), split out of
|
||||
// SavedViewsProvider so that file exports only components (Fast Refresh).
|
||||
|
||||
// A saved view's `filters` is an opaque JSON blob from the DB; coerce it to the module's shape before it
|
||||
// reaches app state or a serializer (an older build's dropped field just falls away — NOT a migration).
|
||||
export const asFilters = (raw: FeedFilters): FeedFilters => ({
|
||||
...raw,
|
||||
tags: raw.tags ?? [],
|
||||
channelIds: raw.channelIds ?? [],
|
||||
tagModes: coerceTagModes(raw),
|
||||
untagged: coerceUntagged(raw),
|
||||
});
|
||||
|
||||
export type ViewSource = "feed" | "plex";
|
||||
|
||||
export interface SavedViewsContext {
|
||||
/** Which module these views belong to — the shared UI branches its filter-describer on it. */
|
||||
source: ViewSource;
|
||||
views: SavedView[];
|
||||
loading: boolean;
|
||||
activeViewId: number | null;
|
||||
displayView: SavedView | null;
|
||||
isDirty: boolean;
|
||||
modalOpen: boolean;
|
||||
openModal: () => void;
|
||||
closeModal: () => void;
|
||||
apply: (v: SavedView) => void;
|
||||
clearActive: () => void;
|
||||
create: (name: string) => Promise<boolean>;
|
||||
rename: (id: number, name: string) => Promise<boolean>;
|
||||
/** Overwrite a view's captured filters (used by the modal's removable chips). */
|
||||
updateFilters: (id: number, filters: Record<string, unknown>) => Promise<boolean>;
|
||||
/** Save the module's CURRENTLY-applied filters into a view (the live→stored inverse of `apply`);
|
||||
* resolves to the saved snapshot on success (so the caller can resync its editor) or null. */
|
||||
saveCurrent: (id: number) => Promise<Record<string, unknown> | null>;
|
||||
toggleDefault: (v: SavedView) => Promise<void>;
|
||||
remove: (v: SavedView) => Promise<boolean>;
|
||||
reorder: (nextIds: number[]) => Promise<void>;
|
||||
}
|
||||
|
||||
export const SavedViewsCtx = createContext<SavedViewsContext | null>(null);
|
||||
|
||||
export function useSavedViews(): SavedViewsContext {
|
||||
const ctx = useContext(SavedViewsCtx);
|
||||
if (!ctx) throw new Error("useSavedViews must be used within a SavedViewsProvider");
|
||||
return ctx;
|
||||
}
|
||||
Reference in New Issue
Block a user