feat: default statusFilter to active with sessionStorage persistence
- Export DashboardPage for testability - Initialize statusFilter from sessionStorage (key: initiatives.statusFilter), falling back to "active" when absent or invalid - Write new filter value to sessionStorage on every change via handleStatusFilterChange, enabling same-session navigation persistence - Add aria-label="Status" to the status select for accessible querying - Add Vitest unit tests covering all 8 scenarios (default, read, write, fallback) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
104
apps/web/src/routes/initiatives/index.test.tsx
Normal file
104
apps/web/src/routes/initiatives/index.test.tsx
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
|
import { vi, describe, it, expect, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
// ── Mocks ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
vi.mock("@tanstack/react-router", () => ({
|
||||||
|
createFileRoute: () => () => ({}),
|
||||||
|
useNavigate: () => vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/trpc", () => ({
|
||||||
|
trpc: {
|
||||||
|
listProjects: { useQuery: () => ({ data: [] }) },
|
||||||
|
useUtils: () => ({}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/hooks", () => ({
|
||||||
|
useLiveUpdates: vi.fn(),
|
||||||
|
INITIATIVE_LIST_RULES: {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/InitiativeList", () => ({
|
||||||
|
InitiativeList: () => <div data-testid="initiative-list" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/CreateInitiativeDialog", () => ({
|
||||||
|
CreateInitiativeDialog: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ── Import after mocks ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
import { DashboardPage } from "@/routes/initiatives/index";
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function renderPage() {
|
||||||
|
return render(<DashboardPage />);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("DashboardPage — statusFilter default and sessionStorage", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
sessionStorage.clear();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults to 'active' when sessionStorage is empty", () => {
|
||||||
|
renderPage();
|
||||||
|
const select = screen.getByRole("combobox", { name: /status/i }) as HTMLSelectElement;
|
||||||
|
expect(select.value).toBe("active");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("displays the 'Active' option as selected on first render", () => {
|
||||||
|
renderPage();
|
||||||
|
const select = screen.getByDisplayValue("Active") as HTMLSelectElement;
|
||||||
|
expect(select.value).toBe("active");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads 'completed' from sessionStorage as initial value", () => {
|
||||||
|
sessionStorage.setItem("initiatives.statusFilter", "completed");
|
||||||
|
renderPage();
|
||||||
|
const select = screen.getByDisplayValue("Completed") as HTMLSelectElement;
|
||||||
|
expect(select.value).toBe("completed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads 'archived' from sessionStorage as initial value", () => {
|
||||||
|
sessionStorage.setItem("initiatives.statusFilter", "archived");
|
||||||
|
renderPage();
|
||||||
|
const select = screen.getByDisplayValue("Archived") as HTMLSelectElement;
|
||||||
|
expect(select.value).toBe("archived");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to 'active' when sessionStorage contains an invalid value", () => {
|
||||||
|
sessionStorage.setItem("initiatives.statusFilter", "bogus");
|
||||||
|
renderPage();
|
||||||
|
const select = screen.getByDisplayValue("Active") as HTMLSelectElement;
|
||||||
|
expect(select.value).toBe("active");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes the new value to sessionStorage when the filter changes", () => {
|
||||||
|
renderPage();
|
||||||
|
const select = screen.getByDisplayValue("Active");
|
||||||
|
fireEvent.change(select, { target: { value: "all" } });
|
||||||
|
expect(sessionStorage.getItem("initiatives.statusFilter")).toBe("all");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates the select's displayed value after the filter changes", () => {
|
||||||
|
renderPage();
|
||||||
|
const select = screen.getByDisplayValue("Active");
|
||||||
|
fireEvent.change(select, { target: { value: "completed" } });
|
||||||
|
expect((select as HTMLSelectElement).value).toBe("completed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes 'archived' to sessionStorage when filter changes to archived", () => {
|
||||||
|
renderPage();
|
||||||
|
const select = screen.getByDisplayValue("Active");
|
||||||
|
fireEvent.change(select, { target: { value: "archived" } });
|
||||||
|
expect(sessionStorage.getItem("initiatives.statusFilter")).toBe("archived");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -21,11 +21,35 @@ const filterOptions: { value: StatusFilter; label: string }[] = [
|
|||||||
{ value: "archived", label: "Archived" },
|
{ value: "archived", label: "Archived" },
|
||||||
];
|
];
|
||||||
|
|
||||||
function DashboardPage() {
|
export function DashboardPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
|
const [statusFilter, setStatusFilter] = useState<StatusFilter>(() => {
|
||||||
|
try {
|
||||||
|
const stored = sessionStorage.getItem("initiatives.statusFilter");
|
||||||
|
if (
|
||||||
|
stored === "all" ||
|
||||||
|
stored === "active" ||
|
||||||
|
stored === "completed" ||
|
||||||
|
stored === "archived"
|
||||||
|
) {
|
||||||
|
return stored;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// sessionStorage unavailable (SSR, private-browsing restriction, etc.)
|
||||||
|
}
|
||||||
|
return "active";
|
||||||
|
});
|
||||||
const [projectFilter, setProjectFilter] = useState<string>("all");
|
const [projectFilter, setProjectFilter] = useState<string>("all");
|
||||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||||
|
|
||||||
|
const handleStatusFilterChange = (value: StatusFilter) => {
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem("initiatives.statusFilter", value);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
setStatusFilter(value);
|
||||||
|
};
|
||||||
const projectsQuery = trpc.listProjects.useQuery();
|
const projectsQuery = trpc.listProjects.useQuery();
|
||||||
|
|
||||||
// Single SSE stream for live updates
|
// Single SSE stream for live updates
|
||||||
@@ -55,9 +79,10 @@ function DashboardPage() {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<select
|
<select
|
||||||
|
aria-label="Status"
|
||||||
value={statusFilter}
|
value={statusFilter}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setStatusFilter(e.target.value as StatusFilter)
|
handleStatusFilterChange(e.target.value as StatusFilter)
|
||||||
}
|
}
|
||||||
className="rounded-md border border-input bg-background px-3 py-1.5 text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
className="rounded-md border border-input bg-background px-3 py-1.5 text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user