test(sidebar): cover the date preset/custom selection logic (R13 S2c)
The deep-review flagged that the feed sidebar's new date single-select derivation was the one bit of migrated logic without a test, while its sibling primitives (FilterChipGroup/SegmentedToggle/SortControl) all gained one. Lift the pure logic into lib/dateFilter (deriveDateKey + applyDateChip), same split as columnSort, and cover it: dateKey for none/preset/custom/off-preset-value, the preset toggle-off, the preset-not-active-while-custom-open case, and the Custom open/close + maxAgeDays clearing. Sidebar now calls the helpers; the onChange is behaviour-identical (self-E2E'd: preset on/off, Custom editor). 11 new tests (vitest 136 -> 147). gate green. Branch not merged.
This commit is contained in:
@@ -10,6 +10,7 @@ import { copyText } from "../lib/clipboard";
|
|||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
import { useDebounced } from "../lib/useDebounced";
|
import { useDebounced } from "../lib/useDebounced";
|
||||||
import { clearedFilters, useActiveFilters } from "../lib/useActiveFilters";
|
import { clearedFilters, useActiveFilters } from "../lib/useActiveFilters";
|
||||||
|
import { applyDateChip, deriveDateKey } from "../lib/dateFilter";
|
||||||
import TagManager from "./TagManager";
|
import TagManager from "./TagManager";
|
||||||
import SavedViewsWidget from "./SavedViewsWidget";
|
import SavedViewsWidget from "./SavedViewsWidget";
|
||||||
import SidePanel from "./SidePanel";
|
import SidePanel from "./SidePanel";
|
||||||
@@ -152,17 +153,8 @@ export default function Sidebar({
|
|||||||
case "channel":
|
case "channel":
|
||||||
return <ChannelPicker filters={filters} setFilters={setFilters} />;
|
return <ChannelPicker filters={filters} setFilters={setFilters} />;
|
||||||
case "date": {
|
case "date": {
|
||||||
// Single-select across the presets + Custom: a preset is active only when its maxAge matches
|
// Single-select across the presets + Custom; pure logic lives in lib/dateFilter.
|
||||||
// and no custom range is open; otherwise Custom owns the selection (or none).
|
const dateKey = deriveDateKey(filters, customDates, DATE_PRESETS);
|
||||||
const activePresetDays = DATE_PRESETS.find(
|
|
||||||
(p) => filters.maxAgeDays === p.days && !customDates,
|
|
||||||
)?.days;
|
|
||||||
const dateKey =
|
|
||||||
customDates || filters.dateFrom || filters.dateTo
|
|
||||||
? "custom"
|
|
||||||
: activePresetDays != null
|
|
||||||
? String(activePresetDays)
|
|
||||||
: "";
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<FilterChipGroup
|
<FilterChipGroup
|
||||||
@@ -176,20 +168,9 @@ export default function Sidebar({
|
|||||||
{ value: "custom", label: t("sidebar.custom") },
|
{ value: "custom", label: t("sidebar.custom") },
|
||||||
]}
|
]}
|
||||||
onChange={(key) => {
|
onChange={(key) => {
|
||||||
if (key === "custom") {
|
const change = applyDateChip(key, filters, customDates);
|
||||||
const next = !customDates;
|
if (change.customDates !== undefined) setCustomDates(change.customDates);
|
||||||
setCustomDates(next);
|
if (change.filters) setFilters(change.filters);
|
||||||
if (next) setFilters({ ...filters, maxAgeDays: undefined });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const days = Number(key);
|
|
||||||
const activePreset = filters.maxAgeDays === days && !customDates;
|
|
||||||
setFilters({
|
|
||||||
...filters,
|
|
||||||
maxAgeDays: activePreset ? undefined : days,
|
|
||||||
dateFrom: undefined,
|
|
||||||
dateTo: undefined,
|
|
||||||
});
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { applyDateChip, deriveDateKey, type DatePreset } from "./dateFilter";
|
||||||
|
import { type FeedFilters } from "./api";
|
||||||
|
|
||||||
|
const PRESETS: DatePreset[] = [
|
||||||
|
{ days: 1, key: "24h" },
|
||||||
|
{ days: 7, key: "1week" },
|
||||||
|
{ days: 30, key: "1month" },
|
||||||
|
{ days: 180, key: "6months" },
|
||||||
|
{ days: 365, key: "1year" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const base = (over: Partial<FeedFilters> = {}): FeedFilters => ({
|
||||||
|
tags: [],
|
||||||
|
tagMode: "or",
|
||||||
|
q: "",
|
||||||
|
sort: "date",
|
||||||
|
scope: "my",
|
||||||
|
includeNormal: true,
|
||||||
|
includeShorts: true,
|
||||||
|
includeLive: true,
|
||||||
|
show: "unwatched",
|
||||||
|
channelIds: [],
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deriveDateKey", () => {
|
||||||
|
it("is empty when no date filter is set", () => {
|
||||||
|
expect(deriveDateKey(base(), false, PRESETS)).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is the preset day-count when maxAgeDays matches a preset", () => {
|
||||||
|
expect(deriveDateKey(base({ maxAgeDays: 7 }), false, PRESETS)).toBe("7");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is empty when maxAgeDays matches no preset (e.g. a shared-link value)", () => {
|
||||||
|
expect(deriveDateKey(base({ maxAgeDays: 45 }), false, PRESETS)).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is 'custom' when the custom editor is open", () => {
|
||||||
|
expect(deriveDateKey(base(), true, PRESETS)).toBe("custom");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is 'custom' when a from/to range is set, even over a stale maxAgeDays", () => {
|
||||||
|
expect(deriveDateKey(base({ dateFrom: "2026-01-01", maxAgeDays: 7 }), false, PRESETS)).toBe(
|
||||||
|
"custom",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("applyDateChip", () => {
|
||||||
|
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();
|
||||||
|
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("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("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("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("preserves unrelated filter fields when picking a preset", () => {
|
||||||
|
const change = applyDateChip("30", base({ q: "hello", tags: [1, 2] }), false);
|
||||||
|
expect(change.filters).toMatchObject({ q: "hello", tags: [1, 2], maxAgeDays: 30 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { type FeedFilters } from "./api";
|
||||||
|
|
||||||
|
// Pure date-filter selection logic for the feed sidebar's "Upload date" chip row, 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.
|
||||||
|
|
||||||
|
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. */
|
||||||
|
export function deriveDateKey(
|
||||||
|
filters: Pick<FeedFilters, "maxAgeDays" | "dateFrom" | "dateTo">,
|
||||||
|
customDates: boolean,
|
||||||
|
presets: readonly DatePreset[],
|
||||||
|
): string {
|
||||||
|
if (customDates || 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(
|
||||||
|
key: string,
|
||||||
|
filters: FeedFilters,
|
||||||
|
customDates: boolean,
|
||||||
|
): { filters?: FeedFilters; customDates?: boolean } {
|
||||||
|
if (key === "custom") {
|
||||||
|
const next = !customDates;
|
||||||
|
return next
|
||||||
|
? { customDates: true, filters: { ...filters, maxAgeDays: undefined } }
|
||||||
|
: { customDates: false };
|
||||||
|
}
|
||||||
|
const days = Number(key);
|
||||||
|
const activePreset = filters.maxAgeDays === days && !customDates;
|
||||||
|
return {
|
||||||
|
filters: {
|
||||||
|
...filters,
|
||||||
|
maxAgeDays: activePreset ? undefined : days,
|
||||||
|
dateFrom: undefined,
|
||||||
|
dateTo: undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user