Skip to content

Commit cfd4ea7

Browse files
committed
fix(studio): show failures and empty searches in the global asset view
The global asset view showed "No assets in the global cache yet" for an empty cache, a failed request and a search with no matches: a non-OK response became { assets: [] }, a rejected request became [], and any empty filtered list fell through to the empty-cache message. Track loading, failed and loaded separately. A failed request shows a failure message with a Try again button and no automatic retry. A search that hides every record says nothing matches, and clearing it restores the rows from memory without a second request. A successful empty response keeps the existing guidance. Fixes heygen-com#4385
1 parent 75fe18c commit cfd4ea7

2 files changed

Lines changed: 162 additions & 7 deletions

File tree

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
// @vitest-environment happy-dom
2+
3+
import { act } from "react";
4+
import { createRoot, type Root } from "react-dom/client";
5+
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from "vitest";
6+
import { GlobalAssetsView } from "./GlobalAssetsView";
7+
8+
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
9+
10+
const EMPTY_CACHE = "No assets in the global cache yet";
11+
const FAILURE = "Couldn't load the global asset cache";
12+
const LOADING = "Loading global assets";
13+
const LOGO = { id: "logo", type: "image", description: "Brand logo" };
14+
15+
let fetchMock: Mock<typeof fetch>;
16+
let root: Root | null = null;
17+
let host: HTMLDivElement;
18+
19+
beforeEach(() => {
20+
fetchMock = vi.fn<typeof fetch>();
21+
vi.stubGlobal("fetch", fetchMock);
22+
});
23+
24+
afterEach(() => {
25+
if (root) act(() => root?.unmount());
26+
root = null;
27+
document.body.innerHTML = "";
28+
vi.unstubAllGlobals();
29+
});
30+
31+
function respond(status: number, body: unknown): Response {
32+
return new Response(JSON.stringify(body), { status });
33+
}
34+
35+
async function settle(): Promise<void> {
36+
await act(async () => {
37+
await new Promise((resolve) => setTimeout(resolve, 0));
38+
});
39+
}
40+
41+
async function show(searchQuery: string): Promise<void> {
42+
await act(async () => root?.render(<GlobalAssetsView searchQuery={searchQuery} />));
43+
await settle();
44+
}
45+
46+
async function mount(searchQuery = ""): Promise<void> {
47+
host = document.createElement("div");
48+
document.body.append(host);
49+
root = createRoot(host);
50+
await show(searchQuery);
51+
}
52+
53+
function tryAgainButton(): HTMLButtonElement | undefined {
54+
return [...host.querySelectorAll("button")].find((b) => b.textContent === "Try again");
55+
}
56+
57+
describe("GlobalAssetsView", () => {
58+
it.each([
59+
["an error status", () => fetchMock.mockResolvedValueOnce(respond(500, { error: "boom" }))],
60+
["a rejected request", () => fetchMock.mockRejectedValueOnce(new TypeError("Failed to fetch"))],
61+
])("reports %s as a failure instead of an empty cache", async (_case, failOnce) => {
62+
failOnce();
63+
await mount();
64+
65+
expect(host.textContent).toContain(FAILURE);
66+
expect(host.textContent).not.toContain(EMPTY_CACHE);
67+
expect(tryAgainButton()).toBeDefined();
68+
});
69+
70+
it("keeps the empty-cache guidance for a successful empty response", async () => {
71+
fetchMock.mockResolvedValueOnce(respond(200, { assets: [] }));
72+
await mount();
73+
74+
expect(host.textContent).toContain(EMPTY_CACHE);
75+
expect(host.textContent).not.toContain(FAILURE);
76+
});
77+
78+
it("says a search matched nothing, and restores the rows without refetching when it clears", async () => {
79+
fetchMock.mockResolvedValueOnce(respond(200, { assets: [LOGO] }));
80+
await mount("zzz");
81+
82+
expect(host.textContent).toContain("No global assets match");
83+
expect(host.textContent).not.toContain(EMPTY_CACHE);
84+
85+
await show("");
86+
87+
expect(host.textContent).toContain("Brand logo");
88+
expect(fetchMock).toHaveBeenCalledTimes(1);
89+
});
90+
91+
it.each([
92+
["the cached assets", { assets: [LOGO] }, "Brand logo"],
93+
["the empty-cache guidance", { assets: [] }, EMPTY_CACHE],
94+
])("retries a failed request only on request, then shows %s", async (_case, body, expected) => {
95+
let answerRetry: (response: Response) => void = () => {};
96+
fetchMock.mockResolvedValueOnce(respond(503, {})).mockReturnValueOnce(
97+
new Promise((resolve) => {
98+
answerRetry = resolve;
99+
}),
100+
);
101+
await mount();
102+
await settle();
103+
104+
expect(fetchMock).toHaveBeenCalledTimes(1);
105+
106+
await act(async () => tryAgainButton()?.click());
107+
108+
expect(host.textContent).toContain(LOADING);
109+
110+
await act(async () => answerRetry(respond(200, body)));
111+
await settle();
112+
113+
expect(host.textContent).toContain(expected);
114+
expect(tryAgainButton()).toBeUndefined();
115+
expect(fetchMock).toHaveBeenCalledTimes(2);
116+
});
117+
});

‎packages/studio/src/components/sidebar/GlobalAssetsView.tsx‎

Lines changed: 45 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -40,28 +40,66 @@ export function globalAssetRows(records: GlobalAssetRecord[], query = ""): Globa
4040
}));
4141
}
4242

43+
type GlobalAssetsLoad =
44+
| { status: "loading" }
45+
| { status: "failed" }
46+
| { status: "loaded"; records: GlobalAssetRecord[] };
47+
4348
export function GlobalAssetsView({ searchQuery }: { searchQuery: string }) {
44-
const [records, setRecords] = useState<GlobalAssetRecord[] | null>(null);
49+
const [load, setLoad] = useState<GlobalAssetsLoad>({ status: "loading" });
50+
const [attempt, setAttempt] = useState(0);
4551
useEffect(() => {
4652
let cancelled = false;
4753
studioApiFetch("/api/assets/global")
48-
.then((r) => (r.ok ? r.json() : { assets: [] }))
54+
.then((r) => {
55+
if (!r.ok) throw new Error(`GET /api/assets/global responded ${r.status}`);
56+
return r.json();
57+
})
4958
.then((d) => {
50-
if (!cancelled) setRecords(Array.isArray(d.assets) ? d.assets : []);
59+
if (!cancelled) {
60+
setLoad({ status: "loaded", records: Array.isArray(d.assets) ? d.assets : [] });
61+
}
5162
})
5263
.catch(() => {
53-
if (!cancelled) setRecords([]);
64+
if (!cancelled) setLoad({ status: "failed" });
5465
});
5566
return () => {
5667
cancelled = true;
5768
};
58-
}, []);
69+
}, [attempt]);
5970

60-
const rows = useMemo(() => globalAssetRows(records ?? [], searchQuery), [records, searchQuery]);
71+
const rows = useMemo(
72+
() => (load.status === "loaded" ? globalAssetRows(load.records, searchQuery) : []),
73+
[load, searchQuery],
74+
);
6175

62-
if (records === null) {
76+
if (load.status === "loading") {
6377
return <p className="px-4 py-3 text-[11px] text-panel-text-5">Loading global assets…</p>;
6478
}
79+
if (load.status === "failed") {
80+
return (
81+
<div className="px-4 py-3 flex flex-col items-start gap-2">
82+
<p className="text-[11px] text-panel-text-5">Couldn't load the global asset cache.</p>
83+
<button
84+
type="button"
85+
onClick={() => {
86+
setLoad({ status: "loading" });
87+
setAttempt((n) => n + 1);
88+
}}
89+
className="px-2.5 py-1 text-[11px] font-medium rounded-md bg-panel-input text-panel-text-3 hover:text-panel-text-1 active:scale-[0.98] transition-colors"
90+
>
91+
Try again
92+
</button>
93+
</div>
94+
);
95+
}
96+
if (rows.length === 0 && load.records.length > 0) {
97+
return (
98+
<p className="px-4 py-3 text-[11px] text-panel-text-5">
99+
No global assets match &ldquo;{searchQuery.trim()}&rdquo;
100+
</p>
101+
);
102+
}
65103
if (rows.length === 0) {
66104
return (
67105
<p className="px-4 py-3 text-[11px] text-panel-text-5">

0 commit comments

Comments
 (0)