feat: give the Asset Library server search and cursor paging

`StudioAssetGateway.listAssets` has always accepted `q`/`cursor` and
returned `nextCursor`, and this screen's own heading promises search
("업로드한 Asset을 검색하고..."). The component asked for `{ limit: 50 }`
and dropped `nextCursor`, so past the 51st asset older assets were
unmanageable with nothing on screen admitting anything was left out.

Follows `document-list.tsx`'s established shape: a `searchDraft`/`q`
pair so only a submitted query is an effect dependency (exactly one
request per deliberate search, never a trailing one), and a
`nextCursor`-driven control that appends rather than replaces.

Two things the pattern did not already cover:

- `RequestOptions.signal` is advisory, so aborting is not enough to stop
  an abandoned response from painting over a newer one. An `active` flag
  that flips synchronously on dependency change closes that race.
- "더 보기" unmounts exactly when the last page arrives, which would
  strand focus on `<body>`. Focus moves to the first appended row
  unconditionally, falling back to the page heading -- the same anchor
  the delete path already uses.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-08-18 13:12:58 +09:00
co-authored by Claude Opus 5
parent 419d9d006d
commit a5825c18b5
3 changed files with 385 additions and 12 deletions
+230 -1
View File
@@ -2,7 +2,7 @@
import assert from "node:assert/strict";
import { test } from "vitest";
import { render, screen } from "@testing-library/react";
import { act, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import {
@@ -227,3 +227,232 @@ test("removes the asset and restores focus to the page heading, not the detached
const heading = screen.getByRole("heading", { name: "Asset", level: 1 });
assert.equal(document.activeElement, heading);
});
// --- Alignment follow-up, item 1: the management screen gets search and paging ---
//
// `listAssets` has always accepted `q`/`cursor` and returned `nextCursor`, and
// this screen's own heading promises search ("업로드한 Asset을 검색하고..."),
// but the component asked for `{ limit: 50 }` and dropped `nextCursor` on the
// floor. Past the 51st asset older assets were simply unmanageable, with
// nothing on screen admitting anything had been left out.
type ListCall = Readonly<{ q?: string; cursor?: string; limit?: number }>;
type ListPage = Readonly<{ items: unknown[]; nextCursor: string | null }>;
function assetNamed(assetKey: string, id: string) {
return { ...(ASSET as object), id, assetKey } as never;
}
function libraryGateway(respond: (query: ListCall) => ListPage) {
const calls: ListCall[] = [];
const gateway = {
async listAssets(query: ListCall) {
calls.push(query);
return respond(query) as never;
},
async getAsset() {
return { asset: ASSET, usages: [], hasPublicationHistory: false } as never;
},
async uploadAsset() {
throw new Error("not used");
},
async updateAssetMetadata() {
return ASSET;
},
async deleteAsset() {},
} as never;
return { gateway, calls };
}
test("searches assets with the typed query and announces the new result count", async () => {
const user = userEvent.setup();
const older = assetNamed("older-diagram", "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeee1");
const newer = assetNamed("newer-diagram", "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeee2");
const { gateway, calls } = libraryGateway((query) =>
query.q === "older"
? { items: [older], nextCursor: null }
: { items: [newer, older], nextCursor: null },
);
render(<AssetLibrary gateway={gateway} />);
await screen.findByRole("button", { name: "newer-diagram" });
await user.type(screen.getByLabelText("Asset 검색"), "older");
await user.click(screen.getByRole("button", { name: "검색" }));
assert.ok(await screen.findByRole("button", { name: "older-diagram" }));
assert.equal(screen.queryByRole("button", { name: "newer-diagram" }), null);
assert.equal(calls.at(-1)?.q, "older");
// The result count lives in a `role="status"` region so a screen reader is
// told the list changed -- a silently re-rendered list is the failure mode
// a search box invites.
const counts = screen
.getAllByRole("status")
.map((node) => node.textContent ?? "");
assert.ok(counts.some((text) => text.includes("1개")), counts.join(" | "));
});
test("typing alone never reaches the gateway -- only the submitted query does", async () => {
const user = userEvent.setup();
const { gateway, calls } = libraryGateway(() => ({ items: [ASSET], nextCursor: null }));
render(<AssetLibrary gateway={gateway} />);
await screen.findByRole("button", { name: "boundary" });
assert.equal(calls.length, 1);
await user.type(screen.getByLabelText("Asset 검색"), "boundary");
assert.equal(calls.length, 1);
});
test("loads the next page with the server cursor and appends it to the list", async () => {
const user = userEvent.setup();
const first = assetNamed("page-one", "ffffffff-ffff-4fff-8fff-fffffffffff1");
const second = assetNamed("page-two", "ffffffff-ffff-4fff-8fff-fffffffffff2");
const { gateway, calls } = libraryGateway((query) =>
query.cursor === "cursor-1"
? { items: [second], nextCursor: null }
: { items: [first], nextCursor: "cursor-1" },
);
render(<AssetLibrary gateway={gateway} />);
await screen.findByRole("button", { name: "page-one" });
await user.click(screen.getByRole("button", { name: "더 보기" }));
assert.ok(await screen.findByRole("button", { name: "page-two" }));
// Appended, not replaced: the first page must stay reachable.
assert.ok(screen.getByRole("button", { name: "page-one" }));
assert.equal(calls.at(-1)?.cursor, "cursor-1");
// Exhausted paging removes the control rather than leaving a button that
// does nothing.
assert.equal(screen.queryByRole("button", { name: "더 보기" }), null);
});
test("더 보기 moves focus to the first newly loaded asset instead of stranding it on the removed control", async () => {
const user = userEvent.setup();
const first = assetNamed("focus-page-one", "10101010-1010-4010-8010-101010101011");
const second = assetNamed("focus-page-two", "10101010-1010-4010-8010-101010101012");
const { gateway } = libraryGateway((query) =>
query.cursor === "cursor-1"
? { items: [second], nextCursor: null }
: { items: [first], nextCursor: "cursor-1" },
);
render(<AssetLibrary gateway={gateway} />);
await screen.findByRole("button", { name: "focus-page-one" });
await user.click(screen.getByRole("button", { name: "더 보기" }));
const appended = await screen.findByRole("button", { name: "focus-page-two" });
// Compared as a boolean, not as two nodes: `assert.equal(node, node)` builds
// its failure message by inspecting both jsdom elements, and that traversal
// is heavy enough to take the worker down instead of reporting the failure.
assert.equal(
document.activeElement === appended,
true,
`focus was on ${document.activeElement?.textContent ?? "nothing"}`,
);
});
test("offers no 더 보기 control when the server reports no next cursor", async () => {
render(<AssetLibrary gateway={gatewayOf({ asset: ASSET, usages: [], hasPublicationHistory: false })} />);
await screen.findByRole("button", { name: "boundary" });
assert.equal(screen.queryByRole("button", { name: "더 보기" }), null);
});
test("a new search restarts paging from the first page instead of carrying the old cursor", async () => {
const user = userEvent.setup();
const first = assetNamed("reset-page-one", "20202020-2020-4020-8020-202020202021");
const second = assetNamed("reset-page-two", "20202020-2020-4020-8020-202020202022");
const found = assetNamed("reset-found", "20202020-2020-4020-8020-202020202023");
const { gateway, calls } = libraryGateway((query) => {
if (query.q === "reset-found") return { items: [found], nextCursor: null };
return query.cursor === "cursor-1"
? { items: [second], nextCursor: null }
: { items: [first], nextCursor: "cursor-1" };
});
render(<AssetLibrary gateway={gateway} />);
await screen.findByRole("button", { name: "reset-page-one" });
await user.click(screen.getByRole("button", { name: "더 보기" }));
await screen.findByRole("button", { name: "reset-page-two" });
await user.type(screen.getByLabelText("Asset 검색"), "reset-found");
await user.click(screen.getByRole("button", { name: "검색" }));
assert.ok(await screen.findByRole("button", { name: "reset-found" }));
assert.equal(screen.queryByRole("button", { name: "reset-page-one" }), null);
assert.equal(screen.queryByRole("button", { name: "reset-page-two" }), null);
assert.equal(calls.at(-1)?.cursor, undefined);
});
test("a slow earlier search never overwrites the newer search's results", async () => {
const user = userEvent.setup();
const initial = assetNamed("race-initial", "30303030-3030-4030-8030-303030303031");
const slow = assetNamed("race-slow", "30303030-3030-4030-8030-303030303032");
const fast = assetNamed("race-fast", "30303030-3030-4030-8030-303030303033");
const pending: Array<{ query: ListCall; settle: (page: ListPage) => void }> = [];
const gateway = {
async listAssets(query: ListCall) {
return new Promise<ListPage>((resolve) => {
pending.push({ query, settle: resolve });
}) as never;
},
async getAsset() {
return { asset: ASSET, usages: [], hasPublicationHistory: false } as never;
},
async uploadAsset() {
throw new Error("not used");
},
async updateAssetMetadata() {
return ASSET;
},
async deleteAsset() {},
} as never;
render(<AssetLibrary gateway={gateway} />);
await waitFor(() => assert.equal(pending.length, 1));
await act(async () => {
pending[0]!.settle({ items: [initial], nextCursor: null });
});
await screen.findByRole("button", { name: "race-initial" });
const input = screen.getByLabelText("Asset 검색");
await user.clear(input);
await user.type(input, "slow");
await user.click(screen.getByRole("button", { name: "검색" }));
await waitFor(() => assert.equal(pending.length, 2));
await user.clear(input);
await user.type(input, "fast");
await user.click(screen.getByRole("button", { name: "검색" }));
await waitFor(() => assert.equal(pending.length, 3));
await act(async () => {
pending[2]!.settle({ items: [fast], nextCursor: null });
});
await screen.findByRole("button", { name: "race-fast" });
// The abandoned "slow" request lands last. Nothing about it may reach the
// screen: the user is looking at "fast" results.
await act(async () => {
pending[1]!.settle({ items: [slow], nextCursor: null });
});
assert.equal(screen.queryByRole("button", { name: "race-slow" }), null);
assert.ok(screen.getByRole("button", { name: "race-fast" }));
});
test("a search that matches nothing says so instead of claiming no asset was ever uploaded", async () => {
const user = userEvent.setup();
const { gateway } = libraryGateway((query) =>
query.q ? { items: [], nextCursor: null } : { items: [ASSET], nextCursor: null },
);
render(<AssetLibrary gateway={gateway} />);
await screen.findByRole("button", { name: "boundary" });
await user.type(screen.getByLabelText("Asset 검색"), "없는키");
await user.click(screen.getByRole("button", { name: "검색" }));
assert.ok(await screen.findByText("조건에 맞는 Asset이 없습니다"));
assert.equal(screen.queryByText("등록된 Asset이 없습니다"), null);
});