Extract sidebar callout state (#884)
This commit is contained in:
@@ -1,224 +0,0 @@
|
|||||||
/**
|
|
||||||
* @vitest-environment jsdom
|
|
||||||
*/
|
|
||||||
import React, { act, useEffect } from "react";
|
|
||||||
import { createRoot, type Root } from "react-dom/client";
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
const { theme } = vi.hoisted(() => ({
|
|
||||||
theme: {
|
|
||||||
spacing: { 0: 0, 1: 4, 2: 8, 3: 12, 4: 16 },
|
|
||||||
borderWidth: { 1: 1 },
|
|
||||||
borderRadius: { md: 6 },
|
|
||||||
fontSize: { xs: 11, sm: 13 },
|
|
||||||
fontWeight: { medium: "500", semibold: "600" },
|
|
||||||
colors: {
|
|
||||||
surface0: "#000",
|
|
||||||
foreground: "#fff",
|
|
||||||
foregroundMuted: "#aaa",
|
|
||||||
border: "#555",
|
|
||||||
destructive: "#f44",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
const asyncStorage = vi.hoisted(() => ({
|
|
||||||
values: new Map<string, string>(),
|
|
||||||
getItem: vi.fn(async (key: string) => asyncStorage.values.get(key) ?? null),
|
|
||||||
setItem: vi.fn(async (key: string, value: string) => {
|
|
||||||
asyncStorage.values.set(key, value);
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@react-native-async-storage/async-storage", () => ({
|
|
||||||
default: asyncStorage,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("react-native-unistyles", () => ({
|
|
||||||
StyleSheet: {
|
|
||||||
create: (factory: unknown) =>
|
|
||||||
typeof factory === "function" ? (factory as (t: typeof theme) => unknown)(theme) : factory,
|
|
||||||
},
|
|
||||||
useUnistyles: () => ({ theme }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("lucide-react-native", () => {
|
|
||||||
const X = (props: Record<string, unknown>) => React.createElement("span", props);
|
|
||||||
return { X };
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.stubGlobal("React", React);
|
|
||||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
|
||||||
|
|
||||||
import {
|
|
||||||
SidebarCalloutProvider,
|
|
||||||
type SidebarCalloutsApi,
|
|
||||||
SidebarCalloutViewport,
|
|
||||||
useSidebarCallouts,
|
|
||||||
} from "./sidebar-callout-context";
|
|
||||||
|
|
||||||
const apiSink: { current: SidebarCalloutsApi | null } = { current: null };
|
|
||||||
|
|
||||||
function handleApi(nextApi: SidebarCalloutsApi): void {
|
|
||||||
apiSink.current = nextApi;
|
|
||||||
}
|
|
||||||
|
|
||||||
function CaptureApi({ onApi }: { onApi: (api: SidebarCalloutsApi) => void }) {
|
|
||||||
const api = useSidebarCallouts();
|
|
||||||
onApi(api);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("SidebarCalloutProvider", () => {
|
|
||||||
let root: Root | null = null;
|
|
||||||
let container: HTMLElement | null = null;
|
|
||||||
let api: SidebarCalloutsApi | null = null;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
api = null;
|
|
||||||
apiSink.current = null;
|
|
||||||
asyncStorage.values.clear();
|
|
||||||
asyncStorage.getItem.mockClear();
|
|
||||||
asyncStorage.setItem.mockClear();
|
|
||||||
container = document.createElement("div");
|
|
||||||
document.body.appendChild(container);
|
|
||||||
root = createRoot(container);
|
|
||||||
await act(async () => {
|
|
||||||
root?.render(
|
|
||||||
<SidebarCalloutProvider>
|
|
||||||
<CaptureApi onApi={handleApi} />
|
|
||||||
<SidebarCalloutViewport />
|
|
||||||
</SidebarCalloutProvider>,
|
|
||||||
);
|
|
||||||
await Promise.resolve();
|
|
||||||
});
|
|
||||||
api = apiSink.current;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
if (root) {
|
|
||||||
act(() => {
|
|
||||||
root?.unmount();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
root = null;
|
|
||||||
container?.remove();
|
|
||||||
container = null;
|
|
||||||
api = null;
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows the highest-priority callout first, then reveals the next when dismissed", () => {
|
|
||||||
act(() => {
|
|
||||||
api?.show({ id: "onboarding", priority: 10, title: "Set up scripts" });
|
|
||||||
api?.show({ id: "update", priority: 200, title: "Update available" });
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(container?.textContent).toContain("Update available");
|
|
||||||
expect(container?.textContent).not.toContain("Set up scripts");
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
api?.dismiss("update");
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(container?.textContent).toContain("Set up scripts");
|
|
||||||
expect(container?.textContent).not.toContain("Update available");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("replaces a callout by id without duplicating the queue item", () => {
|
|
||||||
act(() => {
|
|
||||||
api?.show({ id: "daemon", title: "Old daemon", description: "v1" });
|
|
||||||
api?.show({ id: "daemon", title: "New daemon", description: "v2" });
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(container?.textContent).toContain("New daemon");
|
|
||||||
expect(container?.textContent).toContain("v2");
|
|
||||||
expect(container?.textContent).not.toContain("Old daemon");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps API consumers from rerendering when callout state changes", () => {
|
|
||||||
const renders = vi.fn();
|
|
||||||
function Producer() {
|
|
||||||
const callouts = useSidebarCallouts();
|
|
||||||
renders(callouts);
|
|
||||||
useEffect(() => {
|
|
||||||
callouts.show({ id: "initial", title: "Initial" });
|
|
||||||
}, [callouts]);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
root?.render(
|
|
||||||
<SidebarCalloutProvider>
|
|
||||||
<Producer />
|
|
||||||
<CaptureApi onApi={handleApi} />
|
|
||||||
<SidebarCalloutViewport />
|
|
||||||
</SidebarCalloutProvider>,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
api = apiSink.current;
|
|
||||||
const firstApi = renders.mock.calls[0]?.[0];
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
api?.show({ id: "later", priority: 10, title: "Later" });
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(renders).toHaveBeenCalledTimes(1);
|
|
||||||
expect(renders.mock.calls[0]?.[0]).toBe(firstApi);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("unregisters only the registration returned by show", () => {
|
|
||||||
let unregisterOld: (() => void) | null = null;
|
|
||||||
act(() => {
|
|
||||||
unregisterOld = api?.show({ id: "update", title: "Old" }) ?? null;
|
|
||||||
api?.show({ id: "update", title: "New" });
|
|
||||||
});
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
unregisterOld?.();
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(container?.textContent).toContain("New");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("persists dismissals by dismissal key", () => {
|
|
||||||
act(() => {
|
|
||||||
api?.show({
|
|
||||||
id: "update",
|
|
||||||
dismissalKey: "desktop-update:available:1.2.3",
|
|
||||||
title: "Update available",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(container?.textContent).toContain("Update available");
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
api?.dismiss("update");
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(container?.textContent).not.toContain("Update available");
|
|
||||||
expect(asyncStorage.setItem).toHaveBeenCalledWith(
|
|
||||||
"@paseo:sidebar-callout-dismissals",
|
|
||||||
JSON.stringify(["desktop-update:available:1.2.3"]),
|
|
||||||
);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
api?.show({
|
|
||||||
id: "update",
|
|
||||||
dismissalKey: "desktop-update:available:1.2.3",
|
|
||||||
title: "Dismissed update",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(container?.textContent).not.toContain("Dismissed update");
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
api?.show({
|
|
||||||
id: "update",
|
|
||||||
dismissalKey: "desktop-update:available:1.2.4",
|
|
||||||
title: "New update",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(container?.textContent).toContain("New update");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -8,27 +8,24 @@ import {
|
|||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import {
|
import { SidebarCallout, type SidebarCalloutProps } from "@/components/sidebar-callout";
|
||||||
SidebarCallout,
|
|
||||||
type SidebarCalloutAction,
|
|
||||||
type SidebarCalloutProps,
|
|
||||||
type SidebarCalloutVariant,
|
|
||||||
} from "@/components/sidebar-callout";
|
|
||||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||||
|
import {
|
||||||
|
clearSidebarCallouts,
|
||||||
|
createSidebarCalloutState,
|
||||||
|
dismissSidebarCallout,
|
||||||
|
loadDismissedCalloutKeys,
|
||||||
|
parseDismissedCalloutKeys,
|
||||||
|
selectActiveSidebarCallout,
|
||||||
|
serializeDismissedCalloutKeys,
|
||||||
|
showSidebarCallout,
|
||||||
|
type SidebarCalloutEntry,
|
||||||
|
type SidebarCalloutOptions,
|
||||||
|
type SidebarCalloutState,
|
||||||
|
unregisterSidebarCallout,
|
||||||
|
} from "./sidebar-callout-state";
|
||||||
|
|
||||||
export interface SidebarCalloutOptions {
|
export type { SidebarCalloutOptions } from "./sidebar-callout-state";
|
||||||
id: string;
|
|
||||||
dismissalKey?: string;
|
|
||||||
title: string;
|
|
||||||
description?: ReactNode;
|
|
||||||
icon?: ReactNode;
|
|
||||||
variant?: SidebarCalloutVariant;
|
|
||||||
actions?: readonly SidebarCalloutAction[];
|
|
||||||
dismissible?: boolean;
|
|
||||||
priority?: number;
|
|
||||||
onDismiss?: () => void;
|
|
||||||
testID?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SidebarCalloutsApi {
|
export interface SidebarCalloutsApi {
|
||||||
show: (callout: SidebarCalloutOptions) => () => void;
|
show: (callout: SidebarCalloutOptions) => () => void;
|
||||||
@@ -36,96 +33,48 @@ export interface SidebarCalloutsApi {
|
|||||||
clear: () => void;
|
clear: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type SidebarCalloutEntry = SidebarCalloutOptions & {
|
|
||||||
order: number;
|
|
||||||
priority: number;
|
|
||||||
token: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
const DISMISSED_CALLOUTS_STORAGE_KEY = "@paseo:sidebar-callout-dismissals";
|
const DISMISSED_CALLOUTS_STORAGE_KEY = "@paseo:sidebar-callout-dismissals";
|
||||||
|
|
||||||
const SidebarCalloutApiContext = createContext<SidebarCalloutsApi | null>(null);
|
const SidebarCalloutApiContext = createContext<SidebarCalloutsApi | null>(null);
|
||||||
const SidebarCalloutStateContext = createContext<SidebarCalloutEntry | null>(null);
|
const SidebarCalloutStateContext = createContext<SidebarCalloutEntry | null>(null);
|
||||||
|
|
||||||
function normalizeDismissalKey(key: string | null | undefined): string | null {
|
|
||||||
const trimmed = key?.trim();
|
|
||||||
return trimmed ? trimmed : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseDismissedCalloutKeys(value: string | null): Set<string> {
|
|
||||||
if (!value) {
|
|
||||||
return new Set();
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(value) as unknown;
|
|
||||||
if (!Array.isArray(parsed)) {
|
|
||||||
return new Set();
|
|
||||||
}
|
|
||||||
return new Set(parsed.filter((entry): entry is string => typeof entry === "string"));
|
|
||||||
} catch {
|
|
||||||
return new Set();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function persistDismissedCalloutKeys(keys: ReadonlySet<string>): void {
|
function persistDismissedCalloutKeys(keys: ReadonlySet<string>): void {
|
||||||
void AsyncStorage.setItem(DISMISSED_CALLOUTS_STORAGE_KEY, JSON.stringify([...keys])).catch(
|
void AsyncStorage.setItem(
|
||||||
(error) => {
|
DISMISSED_CALLOUTS_STORAGE_KEY,
|
||||||
console.error("[SidebarCallouts] Failed to persist dismissed callouts", error);
|
serializeDismissedCalloutKeys(keys),
|
||||||
},
|
).catch((error) => {
|
||||||
);
|
console.error("[SidebarCallouts] Failed to persist dismissed callouts", error);
|
||||||
}
|
|
||||||
|
|
||||||
function selectActiveCallout(input: {
|
|
||||||
callouts: readonly SidebarCalloutEntry[];
|
|
||||||
dismissedKeys: ReadonlySet<string>;
|
|
||||||
dismissalStorageLoaded: boolean;
|
|
||||||
}): SidebarCalloutEntry | null {
|
|
||||||
const visibleCallouts = input.callouts.filter((entry) => {
|
|
||||||
const dismissalKey = normalizeDismissalKey(entry.dismissalKey);
|
|
||||||
if (!dismissalKey) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return input.dismissalStorageLoaded && !input.dismissedKeys.has(dismissalKey);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (visibleCallouts.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
[...visibleCallouts].sort((a, b) => b.priority - a.priority || a.order - b.order)[0] ?? null
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SidebarCalloutProvider({ children }: { children: ReactNode }) {
|
export function SidebarCalloutProvider({ children }: { children: ReactNode }) {
|
||||||
const [callouts, setCallouts] = useState<SidebarCalloutEntry[]>([]);
|
const [state, setState] = useState<SidebarCalloutState>(createSidebarCalloutState);
|
||||||
const [dismissedKeys, setDismissedKeys] = useState<Set<string>>(new Set());
|
const stateRef = useRef<SidebarCalloutState>(state);
|
||||||
const [dismissalStorageLoaded, setDismissalStorageLoaded] = useState(false);
|
|
||||||
const calloutsRef = useRef<SidebarCalloutEntry[]>([]);
|
function commitState(next: SidebarCalloutState): void {
|
||||||
const dismissedKeysRef = useRef<Set<string>>(new Set());
|
stateRef.current = next;
|
||||||
const orderRef = useRef(0);
|
setState(next);
|
||||||
const tokenRef = useRef(0);
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let mounted = true;
|
let mounted = true;
|
||||||
void AsyncStorage.getItem(DISMISSED_CALLOUTS_STORAGE_KEY)
|
|
||||||
.then((value) => {
|
async function loadDismissedKeys(): Promise<void> {
|
||||||
if (!mounted) {
|
let dismissedKeys: ReadonlySet<string>;
|
||||||
return;
|
try {
|
||||||
}
|
const value = await AsyncStorage.getItem(DISMISSED_CALLOUTS_STORAGE_KEY);
|
||||||
const nextKeys = parseDismissedCalloutKeys(value);
|
dismissedKeys = parseDismissedCalloutKeys(value);
|
||||||
dismissedKeysRef.current = nextKeys;
|
} catch (error) {
|
||||||
setDismissedKeys(nextKeys);
|
|
||||||
return;
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error("[SidebarCallouts] Failed to load dismissed callouts", error);
|
console.error("[SidebarCallouts] Failed to load dismissed callouts", error);
|
||||||
})
|
dismissedKeys = stateRef.current.dismissedKeys;
|
||||||
.finally(() => {
|
}
|
||||||
if (mounted) {
|
|
||||||
setDismissalStorageLoaded(true);
|
if (mounted) {
|
||||||
}
|
commitState(loadDismissedCalloutKeys(stateRef.current, dismissedKeys));
|
||||||
});
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void loadDismissedKeys();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
mounted = false;
|
mounted = false;
|
||||||
@@ -133,60 +82,33 @@ export function SidebarCalloutProvider({ children }: { children: ReactNode }) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const show = useStableEvent((callout: SidebarCalloutOptions) => {
|
const show = useStableEvent((callout: SidebarCalloutOptions) => {
|
||||||
tokenRef.current += 1;
|
const result = showSidebarCallout(stateRef.current, callout);
|
||||||
const token = tokenRef.current;
|
commitState(result.state);
|
||||||
const current = calloutsRef.current;
|
|
||||||
const existing = current.find((entry) => entry.id === callout.id);
|
|
||||||
const nextEntry: SidebarCalloutEntry = {
|
|
||||||
...callout,
|
|
||||||
priority: callout.priority ?? 0,
|
|
||||||
order: existing?.order ?? ++orderRef.current,
|
|
||||||
token,
|
|
||||||
};
|
|
||||||
const next = existing
|
|
||||||
? current.map((entry) => (entry.id === callout.id ? nextEntry : entry))
|
|
||||||
: [...current, nextEntry];
|
|
||||||
|
|
||||||
calloutsRef.current = next;
|
|
||||||
setCallouts(next);
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
const updated = calloutsRef.current.filter(
|
commitState(
|
||||||
(entry) => entry.id !== callout.id || entry.token !== token,
|
unregisterSidebarCallout(stateRef.current, { id: callout.id, token: result.token }),
|
||||||
);
|
);
|
||||||
calloutsRef.current = updated;
|
|
||||||
setCallouts(updated);
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const dismiss = useStableEvent((id: string) => {
|
const dismiss = useStableEvent((id: string) => {
|
||||||
const dismissed = calloutsRef.current.find((entry) => entry.id === id) ?? null;
|
const result = dismissSidebarCallout(stateRef.current, id);
|
||||||
const next = calloutsRef.current.filter((entry) => entry.id !== id);
|
commitState(result.state);
|
||||||
calloutsRef.current = next;
|
|
||||||
setCallouts(next);
|
|
||||||
|
|
||||||
const dismissalKey = normalizeDismissalKey(dismissed?.dismissalKey);
|
if (result.dismissalKey) {
|
||||||
if (dismissalKey) {
|
persistDismissedCalloutKeys(result.state.dismissedKeys);
|
||||||
const nextKeys = new Set(dismissedKeysRef.current);
|
|
||||||
nextKeys.add(dismissalKey);
|
|
||||||
dismissedKeysRef.current = nextKeys;
|
|
||||||
setDismissedKeys(nextKeys);
|
|
||||||
persistDismissedCalloutKeys(nextKeys);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
dismissed?.onDismiss?.();
|
result.dismissedCallout?.onDismiss?.();
|
||||||
});
|
});
|
||||||
|
|
||||||
const clear = useStableEvent(() => {
|
const clear = useStableEvent(() => {
|
||||||
calloutsRef.current = [];
|
commitState(clearSidebarCallouts(stateRef.current));
|
||||||
setCallouts([]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const api = useMemo<SidebarCalloutsApi>(() => ({ show, dismiss, clear }), [clear, dismiss, show]);
|
const api = useMemo<SidebarCalloutsApi>(() => ({ show, dismiss, clear }), [clear, dismiss, show]);
|
||||||
const activeCallout = useMemo(
|
const activeCallout = useMemo(() => selectActiveSidebarCallout(state), [state]);
|
||||||
() => selectActiveCallout({ callouts, dismissedKeys, dismissalStorageLoaded }),
|
|
||||||
[callouts, dismissedKeys, dismissalStorageLoaded],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarCalloutApiContext.Provider value={api}>
|
<SidebarCalloutApiContext.Provider value={api}>
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
clearSidebarCallouts,
|
||||||
|
createSidebarCalloutState,
|
||||||
|
dismissSidebarCallout,
|
||||||
|
loadDismissedCalloutKeys,
|
||||||
|
parseDismissedCalloutKeys,
|
||||||
|
selectActiveSidebarCallout,
|
||||||
|
serializeDismissedCalloutKeys,
|
||||||
|
showSidebarCallout,
|
||||||
|
unregisterSidebarCallout,
|
||||||
|
} from "./sidebar-callout-state";
|
||||||
|
|
||||||
|
describe("sidebar callout state", () => {
|
||||||
|
it("shows the highest-priority callout first, then reveals the next when dismissed", () => {
|
||||||
|
let state = createSidebarCalloutState();
|
||||||
|
state = showSidebarCallout(state, {
|
||||||
|
id: "onboarding",
|
||||||
|
priority: 10,
|
||||||
|
title: "Set up scripts",
|
||||||
|
}).state;
|
||||||
|
state = showSidebarCallout(state, {
|
||||||
|
id: "update",
|
||||||
|
priority: 200,
|
||||||
|
title: "Update available",
|
||||||
|
}).state;
|
||||||
|
|
||||||
|
expect(selectActiveSidebarCallout(state)?.title).toBe("Update available");
|
||||||
|
|
||||||
|
state = dismissSidebarCallout(state, "update").state;
|
||||||
|
|
||||||
|
expect(selectActiveSidebarCallout(state)?.title).toBe("Set up scripts");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replaces a callout by id without duplicating the queue item", () => {
|
||||||
|
let state = createSidebarCalloutState();
|
||||||
|
state = showSidebarCallout(state, {
|
||||||
|
id: "daemon",
|
||||||
|
title: "Old daemon",
|
||||||
|
description: "v1",
|
||||||
|
}).state;
|
||||||
|
state = showSidebarCallout(state, {
|
||||||
|
id: "daemon",
|
||||||
|
title: "New daemon",
|
||||||
|
description: "v2",
|
||||||
|
}).state;
|
||||||
|
|
||||||
|
expect(state.callouts).toMatchObject([
|
||||||
|
{
|
||||||
|
id: "daemon",
|
||||||
|
title: "New daemon",
|
||||||
|
description: "v2",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unregisters only the registration returned by show", () => {
|
||||||
|
let state = createSidebarCalloutState();
|
||||||
|
const oldRegistration = showSidebarCallout(state, { id: "update", title: "Old" });
|
||||||
|
state = oldRegistration.state;
|
||||||
|
state = showSidebarCallout(state, { id: "update", title: "New" }).state;
|
||||||
|
|
||||||
|
state = unregisterSidebarCallout(state, { id: "update", token: oldRegistration.token });
|
||||||
|
|
||||||
|
expect(selectActiveSidebarCallout(state)?.title).toBe("New");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists dismissals by dismissal key and hides matching future callouts", () => {
|
||||||
|
const onDismiss = vi.fn();
|
||||||
|
let state = loadDismissedCalloutKeys(createSidebarCalloutState(), new Set());
|
||||||
|
state = showSidebarCallout(state, {
|
||||||
|
id: "update",
|
||||||
|
dismissalKey: "desktop-update:available:1.2.3",
|
||||||
|
title: "Update available",
|
||||||
|
onDismiss,
|
||||||
|
}).state;
|
||||||
|
|
||||||
|
const result = dismissSidebarCallout(state, "update");
|
||||||
|
state = result.state;
|
||||||
|
|
||||||
|
expect(result.dismissalKey).toBe("desktop-update:available:1.2.3");
|
||||||
|
expect(serializeDismissedCalloutKeys(state.dismissedKeys)).toBe(
|
||||||
|
JSON.stringify(["desktop-update:available:1.2.3"]),
|
||||||
|
);
|
||||||
|
expect(onDismiss).not.toHaveBeenCalled();
|
||||||
|
result.dismissedCallout?.onDismiss?.();
|
||||||
|
expect(onDismiss).toHaveBeenCalledOnce();
|
||||||
|
|
||||||
|
state = showSidebarCallout(state, {
|
||||||
|
id: "update",
|
||||||
|
dismissalKey: "desktop-update:available:1.2.3",
|
||||||
|
title: "Dismissed update",
|
||||||
|
}).state;
|
||||||
|
|
||||||
|
expect(selectActiveSidebarCallout(state)).toBeNull();
|
||||||
|
|
||||||
|
state = showSidebarCallout(state, {
|
||||||
|
id: "update",
|
||||||
|
dismissalKey: "desktop-update:available:1.2.4",
|
||||||
|
title: "New update",
|
||||||
|
}).state;
|
||||||
|
|
||||||
|
expect(selectActiveSidebarCallout(state)?.title).toBe("New update");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("waits for dismissal storage before showing dismissible callouts", () => {
|
||||||
|
let state = createSidebarCalloutState();
|
||||||
|
state = showSidebarCallout(state, {
|
||||||
|
id: "update",
|
||||||
|
dismissalKey: "desktop-update:available:1.2.3",
|
||||||
|
title: "Update available",
|
||||||
|
}).state;
|
||||||
|
|
||||||
|
expect(selectActiveSidebarCallout(state)).toBeNull();
|
||||||
|
|
||||||
|
state = loadDismissedCalloutKeys(state, new Set());
|
||||||
|
|
||||||
|
expect(selectActiveSidebarCallout(state)?.title).toBe("Update available");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses stored dismissal keys defensively", () => {
|
||||||
|
expect(parseDismissedCalloutKeys(JSON.stringify(["a", 4, "b"]))).toEqual(new Set(["a", "b"]));
|
||||||
|
expect(parseDismissedCalloutKeys("{")).toEqual(new Set());
|
||||||
|
expect(parseDismissedCalloutKeys(JSON.stringify({ key: "a" }))).toEqual(new Set());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears visible callouts without dropping dismissal state", () => {
|
||||||
|
let state = loadDismissedCalloutKeys(createSidebarCalloutState(), new Set(["dismissed"]));
|
||||||
|
state = showSidebarCallout(state, { id: "visible", title: "Visible" }).state;
|
||||||
|
|
||||||
|
state = clearSidebarCallouts(state);
|
||||||
|
|
||||||
|
expect(state.callouts).toEqual([]);
|
||||||
|
expect(state.dismissedKeys).toEqual(new Set(["dismissed"]));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import type { SidebarCalloutAction, SidebarCalloutVariant } from "@/components/sidebar-callout";
|
||||||
|
|
||||||
|
export interface SidebarCalloutOptions {
|
||||||
|
id: string;
|
||||||
|
dismissalKey?: string;
|
||||||
|
title: string;
|
||||||
|
description?: ReactNode;
|
||||||
|
icon?: ReactNode;
|
||||||
|
variant?: SidebarCalloutVariant;
|
||||||
|
actions?: readonly SidebarCalloutAction[];
|
||||||
|
dismissible?: boolean;
|
||||||
|
priority?: number;
|
||||||
|
onDismiss?: () => void;
|
||||||
|
testID?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SidebarCalloutEntry extends SidebarCalloutOptions {
|
||||||
|
order: number;
|
||||||
|
priority: number;
|
||||||
|
token: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SidebarCalloutState {
|
||||||
|
callouts: readonly SidebarCalloutEntry[];
|
||||||
|
dismissedKeys: ReadonlySet<string>;
|
||||||
|
dismissalStorageLoaded: boolean;
|
||||||
|
nextOrder: number;
|
||||||
|
nextToken: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSidebarCalloutState(): SidebarCalloutState {
|
||||||
|
return {
|
||||||
|
callouts: [],
|
||||||
|
dismissedKeys: new Set(),
|
||||||
|
dismissalStorageLoaded: false,
|
||||||
|
nextOrder: 0,
|
||||||
|
nextToken: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeDismissalKey(key: string | null | undefined): string | null {
|
||||||
|
const trimmed = key?.trim();
|
||||||
|
return trimmed ? trimmed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseDismissedCalloutKeys(value: string | null): Set<string> {
|
||||||
|
if (!value) {
|
||||||
|
return new Set();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value) as unknown;
|
||||||
|
if (!Array.isArray(parsed)) {
|
||||||
|
return new Set();
|
||||||
|
}
|
||||||
|
return new Set(parsed.filter((entry): entry is string => typeof entry === "string"));
|
||||||
|
} catch {
|
||||||
|
return new Set();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeDismissedCalloutKeys(keys: ReadonlySet<string>): string {
|
||||||
|
return JSON.stringify([...keys]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadDismissedCalloutKeys(
|
||||||
|
state: SidebarCalloutState,
|
||||||
|
dismissedKeys: ReadonlySet<string>,
|
||||||
|
): SidebarCalloutState {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
dismissedKeys: new Set(dismissedKeys),
|
||||||
|
dismissalStorageLoaded: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showSidebarCallout(
|
||||||
|
state: SidebarCalloutState,
|
||||||
|
callout: SidebarCalloutOptions,
|
||||||
|
): { state: SidebarCalloutState; token: number } {
|
||||||
|
const token = state.nextToken + 1;
|
||||||
|
const existing = state.callouts.find((entry) => entry.id === callout.id);
|
||||||
|
const nextEntry: SidebarCalloutEntry = {
|
||||||
|
...callout,
|
||||||
|
priority: callout.priority ?? 0,
|
||||||
|
order: existing?.order ?? state.nextOrder + 1,
|
||||||
|
token,
|
||||||
|
};
|
||||||
|
const callouts = existing
|
||||||
|
? state.callouts.map((entry) => (entry.id === callout.id ? nextEntry : entry))
|
||||||
|
: [...state.callouts, nextEntry];
|
||||||
|
|
||||||
|
return {
|
||||||
|
state: {
|
||||||
|
...state,
|
||||||
|
callouts,
|
||||||
|
nextOrder: existing ? state.nextOrder : state.nextOrder + 1,
|
||||||
|
nextToken: token,
|
||||||
|
},
|
||||||
|
token,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unregisterSidebarCallout(
|
||||||
|
state: SidebarCalloutState,
|
||||||
|
input: { id: string; token: number },
|
||||||
|
): SidebarCalloutState {
|
||||||
|
const callouts = state.callouts.filter(
|
||||||
|
(entry) => entry.id !== input.id || entry.token !== input.token,
|
||||||
|
);
|
||||||
|
return callouts.length === state.callouts.length ? state : { ...state, callouts };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dismissSidebarCallout(
|
||||||
|
state: SidebarCalloutState,
|
||||||
|
id: string,
|
||||||
|
): {
|
||||||
|
state: SidebarCalloutState;
|
||||||
|
dismissedCallout: SidebarCalloutEntry | null;
|
||||||
|
dismissalKey: string | null;
|
||||||
|
} {
|
||||||
|
const dismissedCallout = state.callouts.find((entry) => entry.id === id) ?? null;
|
||||||
|
const callouts = state.callouts.filter((entry) => entry.id !== id);
|
||||||
|
const dismissalKey = normalizeDismissalKey(dismissedCallout?.dismissalKey);
|
||||||
|
const dismissedKeys = dismissalKey
|
||||||
|
? new Set([...state.dismissedKeys, dismissalKey])
|
||||||
|
: state.dismissedKeys;
|
||||||
|
|
||||||
|
return {
|
||||||
|
state: {
|
||||||
|
...state,
|
||||||
|
callouts,
|
||||||
|
dismissedKeys,
|
||||||
|
},
|
||||||
|
dismissedCallout,
|
||||||
|
dismissalKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearSidebarCallouts(state: SidebarCalloutState): SidebarCalloutState {
|
||||||
|
return { ...state, callouts: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectActiveSidebarCallout(
|
||||||
|
state: Pick<SidebarCalloutState, "callouts" | "dismissedKeys" | "dismissalStorageLoaded">,
|
||||||
|
): SidebarCalloutEntry | null {
|
||||||
|
const visibleCallouts = state.callouts.filter((entry) => {
|
||||||
|
const dismissalKey = normalizeDismissalKey(entry.dismissalKey);
|
||||||
|
if (!dismissalKey) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return state.dismissalStorageLoaded && !state.dismissedKeys.has(dismissalKey);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (visibleCallouts.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
[...visibleCallouts].sort((a, b) => b.priority - a.priority || a.order - b.order)[0] ?? null
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user