mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-28 08:02:30 +00:00
feat: add validated browser-tab browsing preferences scoped to the signed-in user and workspace, restore destination filters before rendering, preserve explicit navigation intent, and clear saved browsing state on logout or session expiry
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
Devin AI
parent
c7144c1502
commit
acc3d62ba3
@@ -0,0 +1,123 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { type ComponentProps, type ReactNode } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { z } from "zod";
|
||||
import { UserContext } from "./context/user";
|
||||
import { useAppStore } from "@/stores/useAppStore";
|
||||
import { BROWSE_STORAGE_PREFIX, clearBrowseState } from "@/lib/browseState";
|
||||
import useBrowseState from "./useBrowseState";
|
||||
|
||||
let userID = "user";
|
||||
const key = (name = "tasks", owner = "user", organization = "workspace") => `${BROWSE_STORAGE_PREFIX}${owner}:${organization}:${name}`;
|
||||
const schema = z.object({ query: z.string(), status: z.enum(["all", "pending", "done"]), reverse: z.boolean() });
|
||||
const defaults = { query: "", status: "all" as const, reverse: false };
|
||||
|
||||
function Owner({ children }: { children: ReactNode }) {
|
||||
const profile = userID ? { user: { id: userID } } as ComponentProps<typeof UserContext.Provider>["value"] : null;
|
||||
return <UserContext.Provider value={profile}>{children}</UserContext.Provider>;
|
||||
}
|
||||
|
||||
describe("workspace-scoped browse state", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
sessionStorage.clear();
|
||||
userID = "user";
|
||||
useAppStore.setState({ currentOrganization: { id: "workspace", name: "Workspace", role: "owner" } });
|
||||
});
|
||||
|
||||
it("restores filters and sort on a fresh mount without first rendering defaults", () => {
|
||||
const page = renderHook(() => useBrowseState("tasks", defaults, schema), { wrapper: Owner });
|
||||
act(() => page.result.current[1]({ query: "alice", status: "pending", reverse: true }));
|
||||
page.unmount();
|
||||
const refreshed = renderHook(() => useBrowseState("tasks", defaults, schema), { wrapper: Owner });
|
||||
expect(refreshed.result.current[0]).toEqual({ query: "alice", status: "pending", reverse: true });
|
||||
});
|
||||
|
||||
it("supports consecutive functional updates and remembers explicit clearing", () => {
|
||||
const page = renderHook(() => useBrowseState("tasks", defaults, schema), { wrapper: Owner });
|
||||
act(() => {
|
||||
page.result.current[1]((previous) => ({ ...previous, query: "alice" }));
|
||||
page.result.current[1]((previous) => ({ ...previous, reverse: true }));
|
||||
});
|
||||
expect(page.result.current[0]).toEqual({ query: "alice", status: "all", reverse: true });
|
||||
act(() => page.result.current[1](defaults));
|
||||
page.unmount();
|
||||
const refreshed = renderHook(() => useBrowseState("tasks", defaults, schema), { wrapper: Owner });
|
||||
expect(refreshed.result.current[0]).toEqual(defaults);
|
||||
});
|
||||
|
||||
it("gives an explicit navigation intent priority over remembered filters", () => {
|
||||
sessionStorage.setItem(key(), JSON.stringify({ value: { ...defaults, query: "remembered" } }));
|
||||
const intent = { ...defaults, query: "linked" };
|
||||
const page = renderHook(() => useBrowseState("tasks", defaults, schema, { initialOverride: intent }), { wrapper: Owner });
|
||||
expect(page.result.current[0].query).toBe("linked");
|
||||
act(() => page.result.current[1](defaults));
|
||||
expect(page.result.current[0]).toEqual(defaults);
|
||||
});
|
||||
|
||||
it("isolates pages, workspaces and users while the view stays mounted", () => {
|
||||
sessionStorage.setItem(key("tasks", "user", "another"), JSON.stringify({ value: { ...defaults, query: "receiving" } }));
|
||||
const page = renderHook(({ name }) => useBrowseState(name, defaults, schema), { wrapper: Owner, initialProps: { name: "tasks" } });
|
||||
act(() => page.result.current[1]({ ...defaults, query: "sending" }));
|
||||
const staleSetter = page.result.current[1];
|
||||
act(() => useAppStore.setState({ currentOrganization: { id: "another", name: "Another", role: "owner" } }));
|
||||
expect(page.result.current[0].query).toBe("receiving");
|
||||
act(() => staleSetter(defaults));
|
||||
expect(page.result.current[0].query).toBe("receiving");
|
||||
expect(JSON.parse(sessionStorage.getItem(key())!).value.query).toBe("sending");
|
||||
userID = "another-user";
|
||||
page.rerender({ name: "tasks" });
|
||||
expect(page.result.current[0]).toEqual(defaults);
|
||||
userID = "user";
|
||||
page.rerender({ name: "tasks" });
|
||||
expect(page.result.current[0].query).toBe("receiving");
|
||||
page.rerender({ name: "campaigns" });
|
||||
expect(page.result.current[0]).toEqual(defaults);
|
||||
page.rerender({ name: "tasks" });
|
||||
expect(page.result.current[0].query).toBe("receiving");
|
||||
});
|
||||
|
||||
it.each(["{broken", JSON.stringify({ value: { query: 123, status: "pending", reverse: false } }), JSON.stringify({ value: { ...defaults, status: "unknown" } })])("ignores malformed or stale stored filters: %s", (saved) => {
|
||||
sessionStorage.setItem(key(), saved);
|
||||
const page = renderHook(() => useBrowseState("tasks", () => defaults, schema), { wrapper: Owner });
|
||||
expect(page.result.current[0]).toEqual(defaults);
|
||||
});
|
||||
|
||||
it("revives typed dates and keeps an explicitly cleared optional field", () => {
|
||||
const dateSchema = z.object({ since: z.coerce.date().optional() });
|
||||
const initial = { since: new Date("2026-10-01T00:00:00Z") };
|
||||
const page = renderHook(() => useBrowseState("history", initial, dateSchema), { wrapper: Owner });
|
||||
page.unmount();
|
||||
const refreshed = renderHook(() => useBrowseState("history", initial, dateSchema), { wrapper: Owner });
|
||||
expect(refreshed.result.current[0].since).toEqual(initial.since);
|
||||
act(() => refreshed.result.current[1]({ since: undefined }));
|
||||
refreshed.unmount();
|
||||
const cleared = renderHook(() => useBrowseState("history", initial, dateSchema), { wrapper: Owner });
|
||||
expect(cleared.result.current[0].since).toBeUndefined();
|
||||
});
|
||||
|
||||
it("continues working with blocked or full storage", () => {
|
||||
vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => { throw new Error("blocked"); });
|
||||
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { throw new Error("full"); });
|
||||
const page = renderHook(() => useBrowseState("tasks", defaults, schema), { wrapper: Owner });
|
||||
act(() => page.result.current[1]({ ...defaults, query: "still works" }));
|
||||
expect(page.result.current[0].query).toBe("still works");
|
||||
});
|
||||
|
||||
it("does not save anonymous browsing state", () => {
|
||||
userID = "";
|
||||
const page = renderHook(() => useBrowseState("tasks", defaults, schema), { wrapper: Owner });
|
||||
act(() => page.result.current[1]({ ...defaults, query: "anonymous" }));
|
||||
expect(sessionStorage.getItem(key())).toBeNull();
|
||||
});
|
||||
|
||||
it("removes browsing preferences and legacy tags on logout without clearing other storage", () => {
|
||||
sessionStorage.setItem(key(), "private search");
|
||||
sessionStorage.setItem("warmbly:mailbox-tag:user:workspace", "tag");
|
||||
sessionStorage.setItem("other-feature", "preserve");
|
||||
clearBrowseState();
|
||||
expect(sessionStorage.getItem(key())).toBeNull();
|
||||
expect(sessionStorage.getItem("warmbly:mailbox-tag:user:workspace")).toBeNull();
|
||||
expect(sessionStorage.getItem("other-feature")).toBe("preserve");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useCallback, useContext, useEffect, useState, type Dispatch, type SetStateAction } from "react";
|
||||
import type { z } from "zod";
|
||||
import { UserContext } from "./context/user";
|
||||
import { useAppStore } from "@/stores/useAppStore";
|
||||
import { BROWSE_STORAGE_PREFIX } from "@/lib/browseState";
|
||||
|
||||
export default function useBrowseState<T>(
|
||||
name: string,
|
||||
initial: T | (() => T),
|
||||
schema: z.ZodType<T>,
|
||||
options?: { initialOverride: T },
|
||||
): [T, Dispatch<SetStateAction<T>>] {
|
||||
const userID = useContext(UserContext)?.user.id;
|
||||
const organizationID = useAppStore((state) => state.currentOrganization?.id ?? "personal");
|
||||
const key = userID ? `${BROWSE_STORAGE_PREFIX}${userID}:${organizationID}:${name}` : null;
|
||||
const read = () => {
|
||||
if (options) return options.initialOverride;
|
||||
if (key) {
|
||||
try {
|
||||
const saved = sessionStorage.getItem(key);
|
||||
if (saved !== null) {
|
||||
const result = schema.safeParse(JSON.parse(saved)?.value);
|
||||
if (result.success) return result.data;
|
||||
}
|
||||
} catch { /* Storage may be unavailable or malformed. */ }
|
||||
}
|
||||
return typeof initial === "function" ? (initial as () => T)() : initial;
|
||||
};
|
||||
const [memory, setMemory] = useState(() => ({ key, value: read() }));
|
||||
let current = memory;
|
||||
// Restore before committing, so a workspace switch never queries or saves the old filters.
|
||||
if (memory.key !== key) {
|
||||
current = { key, value: read() };
|
||||
setMemory(current);
|
||||
}
|
||||
const setValue = useCallback<Dispatch<SetStateAction<T>>>((next) => {
|
||||
setMemory((previous) => previous.key !== key ? previous : ({
|
||||
key: previous.key,
|
||||
value: typeof next === "function" ? (next as (value: T) => T)(previous.value) : next,
|
||||
}));
|
||||
}, [key]);
|
||||
useEffect(() => {
|
||||
if (memory.key === null || memory.key !== key) return;
|
||||
try { sessionStorage.setItem(memory.key, JSON.stringify({ value: memory.value })); }
|
||||
catch { /* Browsing must work when storage is disabled or full. */ }
|
||||
}, [key, memory]);
|
||||
return [current.value, setValue];
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export const BROWSE_STORAGE_PREFIX = "warmbly:browse:v1:";
|
||||
|
||||
export function clearBrowseState() {
|
||||
try {
|
||||
for (let index = sessionStorage.length - 1; index >= 0; index--) {
|
||||
const key = sessionStorage.key(index);
|
||||
if (key?.startsWith(BROWSE_STORAGE_PREFIX) || key?.startsWith("warmbly:mailbox-tag:")) sessionStorage.removeItem(key);
|
||||
}
|
||||
} catch { /* Storage may be disabled. */ }
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import type { QueryClient } from "@tanstack/react-query";
|
||||
import { clearTokens } from "./auth";
|
||||
import { useAppStore } from "@/stores/useAppStore";
|
||||
import { resetBoot } from "./boot";
|
||||
import { clearBrowseState } from "./browseState";
|
||||
|
||||
// clearClientSession drops everything the signed-in person left in this
|
||||
// browser: tokens, drafts, the persisted workspace selection, and the fetched
|
||||
@@ -15,6 +16,7 @@ import { resetBoot } from "./boot";
|
||||
// queryClient is optional: some callers run outside the provider.
|
||||
export function clearClientSession(queryClient?: QueryClient) {
|
||||
clearTokens();
|
||||
clearBrowseState();
|
||||
queryClient?.clear();
|
||||
resetBoot();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user