feat(feed): flatten the filter rail — dropdown upload-date, non-collapsible widgets, title triggers
Rework the feed filter rail into simpler label + control rows: - Remove the rail's own "Clear all" (the chip row above the cards already surfaces one whenever any filter is active). - Replace the Upload date chip group with a single-select dropdown (DateFilterSelect): an "Any time" clear, more presets (2 weeks / 3 months / 2 years / 5 years) and a "Custom range" that reveals from/to on select; the trigger then reads the chosen range. Selection logic is the pure lib/dateFilter (applyDateChip -> selectDateChip, no toggle-off; tests updated). - Widgets are no longer collapsible: PanelGroup gains `collapsible` (feed passes false) so groups render as always-open label + control rows; reorder + hide in edit mode stay. Other rails (Plex/Playlists) keep the collapsible islands. - A non-collapsible group's title can act as its own trigger (PanelItem onTitleClick): "Your tags" opens the tag manager, replacing the inline Manage button.
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { applyDateChip, deriveDateKey, type DatePreset } from "./dateFilter";
|
||||
import { selectDateChip, deriveDateKey, type DatePreset } from "./dateFilter";
|
||||
import { type FeedFilters } from "./api";
|
||||
|
||||
const PRESETS: DatePreset[] = [
|
||||
@@ -48,41 +48,43 @@ describe("deriveDateKey", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyDateChip", () => {
|
||||
describe("selectDateChip", () => {
|
||||
it("picking a preset sets maxAgeDays and clears any custom range", () => {
|
||||
const change = applyDateChip(
|
||||
"7",
|
||||
base({ dateFrom: "2026-01-01", dateTo: "2026-02-01" }),
|
||||
false,
|
||||
);
|
||||
expect(change.customDates).toBeUndefined();
|
||||
const change = selectDateChip("7", base({ dateFrom: "2026-01-01", dateTo: "2026-02-01" }));
|
||||
expect(change.customOpen).toBe(false);
|
||||
expect(change.filters).toMatchObject({ maxAgeDays: 7, dateFrom: undefined, dateTo: undefined });
|
||||
});
|
||||
|
||||
it("re-picking the active preset toggles it off", () => {
|
||||
const change = applyDateChip("7", base({ maxAgeDays: 7 }), false);
|
||||
expect(change.filters?.maxAgeDays).toBeUndefined();
|
||||
it("picking a preset always sets it (no toggle-off) even when it's already active", () => {
|
||||
const change = selectDateChip("7", base({ maxAgeDays: 7 }));
|
||||
expect(change.filters.maxAgeDays).toBe(7);
|
||||
expect(change.customOpen).toBe(false);
|
||||
});
|
||||
|
||||
it("a preset is not 'active' while a custom range is open, so it sets rather than toggling off", () => {
|
||||
const change = applyDateChip("7", base({ maxAgeDays: 7 }), true);
|
||||
expect(change.filters?.maxAgeDays).toBe(7);
|
||||
it("'any' clears the preset and any custom range", () => {
|
||||
const change = selectDateChip("any", base({ maxAgeDays: 7, dateFrom: "2026-01-01" }));
|
||||
expect(change.filters).toMatchObject({
|
||||
maxAgeDays: undefined,
|
||||
dateFrom: undefined,
|
||||
dateTo: undefined,
|
||||
});
|
||||
expect(change.customOpen).toBe(false);
|
||||
});
|
||||
|
||||
it("opening Custom sets the flag and clears a preset's maxAgeDays", () => {
|
||||
const change = applyDateChip("custom", base({ maxAgeDays: 7 }), false);
|
||||
expect(change.customDates).toBe(true);
|
||||
expect(change.filters).toMatchObject({ maxAgeDays: undefined });
|
||||
it("an empty key behaves like 'any'", () => {
|
||||
const change = selectDateChip("", base({ maxAgeDays: 7 }));
|
||||
expect(change.filters.maxAgeDays).toBeUndefined();
|
||||
expect(change.customOpen).toBe(false);
|
||||
});
|
||||
|
||||
it("closing Custom clears the flag and leaves filters untouched", () => {
|
||||
const change = applyDateChip("custom", base({ maxAgeDays: 7 }), true);
|
||||
expect(change.customDates).toBe(false);
|
||||
expect(change.filters).toBeUndefined();
|
||||
it("opening Custom sets the flag and clears a preset's maxAgeDays but keeps a typed range", () => {
|
||||
const change = selectDateChip("custom", base({ maxAgeDays: 7, dateFrom: "2026-01-01" }));
|
||||
expect(change.customOpen).toBe(true);
|
||||
expect(change.filters).toMatchObject({ maxAgeDays: undefined, dateFrom: "2026-01-01" });
|
||||
});
|
||||
|
||||
it("preserves unrelated filter fields when picking a preset", () => {
|
||||
const change = applyDateChip("30", base({ q: "hello", tags: [1, 2] }), false);
|
||||
const change = selectDateChip("30", base({ q: "hello", tags: [1, 2] }));
|
||||
expect(change.filters).toMatchObject({ q: "hello", tags: [1, 2], maxAgeDays: 30 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,51 +1,49 @@
|
||||
import { type FeedFilters } from "./api";
|
||||
|
||||
// Pure date-filter selection logic for the feed sidebar's "Upload date" chip row, lifted out of the
|
||||
// Pure date-filter selection logic for the feed sidebar's "Upload date" selector, lifted out of the
|
||||
// component so the single-select derivation + the preset/custom dispatch can be unit-tested without
|
||||
// mounting Sidebar (same split as columnSort.ts for sort). The row is single-select across the day
|
||||
// presets plus a "Custom" range chip.
|
||||
// mounting Sidebar (same split as columnSort.ts for sort). The control is a single-select dropdown
|
||||
// across an "Any time" clear, the day presets, and a "Custom range" (reveal-on-select from/to).
|
||||
|
||||
export interface DatePreset {
|
||||
days: number;
|
||||
key: string;
|
||||
}
|
||||
|
||||
/** Which date chip is active: a preset's day-count (as a string), "custom", or "" (none). A custom
|
||||
* range (the editor is open, or a from/to is set) owns the selection; otherwise a preset matches only
|
||||
* when no custom range is active; a maxAge that matches no preset (e.g. a shared-link value) is none. */
|
||||
/** Which date option is active: a preset's day-count (as a string), "custom", or "" (none / "Any
|
||||
* time"). A custom range (the reveal is open, or a from/to is set) owns the selection; otherwise a
|
||||
* preset matches only when no custom range is active; a maxAge that matches no preset (e.g. a
|
||||
* shared-link value) is none. */
|
||||
export function deriveDateKey(
|
||||
filters: Pick<FeedFilters, "maxAgeDays" | "dateFrom" | "dateTo">,
|
||||
customDates: boolean,
|
||||
customOpen: boolean,
|
||||
presets: readonly DatePreset[],
|
||||
): string {
|
||||
if (customDates || filters.dateFrom || filters.dateTo) return "custom";
|
||||
if (customOpen || filters.dateFrom || filters.dateTo) return "custom";
|
||||
const preset = presets.find((p) => filters.maxAgeDays === p.days);
|
||||
return preset ? String(preset.days) : "";
|
||||
}
|
||||
|
||||
/** The state change when a date chip is picked. A preset is single-select with toggle-off (re-picking
|
||||
* the active preset clears it) and clears any custom range; "custom" toggles the range editor and,
|
||||
* when opened, clears a preset. Returns the next `filters` (only when they change) and/or the next
|
||||
* `customDates` flag (only when it changes) so the caller applies exactly what moved. */
|
||||
export function applyDateChip(
|
||||
/** The state change when a date option is picked from the dropdown. Unlike the old chip row there is no
|
||||
* toggle-off — "Any time" is the explicit clear. A preset sets maxAge and clears any custom range;
|
||||
* "custom" opens the reveal and clears the preset (keeping any typed range); "any"/"" clears everything.
|
||||
* Returns the next `filters` and the next `customOpen` flag so the caller applies both. */
|
||||
export function selectDateChip(
|
||||
key: string,
|
||||
filters: FeedFilters,
|
||||
customDates: boolean,
|
||||
): { filters?: FeedFilters; customDates?: boolean } {
|
||||
): { filters: FeedFilters; customOpen: boolean } {
|
||||
if (key === "custom") {
|
||||
const next = !customDates;
|
||||
return next
|
||||
? { customDates: true, filters: { ...filters, maxAgeDays: undefined } }
|
||||
: { customDates: false };
|
||||
return { filters: { ...filters, maxAgeDays: undefined }, customOpen: true };
|
||||
}
|
||||
if (key === "any" || key === "") {
|
||||
return {
|
||||
filters: { ...filters, maxAgeDays: undefined, dateFrom: undefined, dateTo: undefined },
|
||||
customOpen: false,
|
||||
};
|
||||
}
|
||||
const days = Number(key);
|
||||
const activePreset = filters.maxAgeDays === days && !customDates;
|
||||
return {
|
||||
filters: {
|
||||
...filters,
|
||||
maxAgeDays: activePreset ? undefined : days,
|
||||
dateFrom: undefined,
|
||||
dateTo: undefined,
|
||||
},
|
||||
filters: { ...filters, maxAgeDays: days, dateFrom: undefined, dateTo: undefined },
|
||||
customOpen: false,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user