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:
co-authored by
Claude Opus 5
parent
419d9d006d
commit
a5825c18b5
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user