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
@@ -1,10 +1,13 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useId, useRef, useState, type FormEvent } from "react";
|
||||||
|
|
||||||
import type { Asset, AssetDetail } from "../../../contracts/studio/contract.ts";
|
import type { Asset, AssetDetail } from "../../../contracts/studio/contract.ts";
|
||||||
import type { StudioAssetGateway } from "../../../application/ports/studio-asset-gateway.ts";
|
import type { StudioAssetGateway } from "../../../application/ports/studio-asset-gateway.ts";
|
||||||
import { isStudioGatewayError } from "../../../application/ports/studio-gateway-error.ts";
|
import { isStudioGatewayError } from "../../../application/ports/studio-gateway-error.ts";
|
||||||
import { createLocalId } from "../../../domain/studio/local-id.ts";
|
import { createLocalId } from "../../../domain/studio/local-id.ts";
|
||||||
|
|
||||||
|
/** `document-list.tsx`'s page size for the same screen shape. */
|
||||||
|
const PAGE_SIZE = 20;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 공개 이력이 있거나 사용 중인 Asset은 hard delete하지 않는다. 서버도 같은
|
* 공개 이력이 있거나 사용 중인 Asset은 hard delete하지 않는다. 서버도 같은
|
||||||
* 규칙으로 `ASSET_IN_USE`를 던지므로 화면은 시도 자체를 막아 왕복을 줄인다.
|
* 규칙으로 `ASSET_IN_USE`를 던지므로 화면은 시도 자체를 막아 왕복을 줄인다.
|
||||||
@@ -26,32 +29,61 @@ export function AssetLibrary(props: Readonly<{ gateway: StudioAssetGateway }>) {
|
|||||||
const [listStatus, setListStatus] = useState<"LOADING" | "ERROR" | "READY">(
|
const [listStatus, setListStatus] = useState<"LOADING" | "ERROR" | "READY">(
|
||||||
"LOADING",
|
"LOADING",
|
||||||
);
|
);
|
||||||
|
// Alignment follow-up, item 1. `q` is the *submitted* query, `searchDraft`
|
||||||
|
// what the user is currently typing. Only `q` is an effect dependency, so
|
||||||
|
// typing costs nothing -- `document-list.tsx` splits the same pair for the
|
||||||
|
// same reason, and it is a stronger guarantee than a debounce: exactly one
|
||||||
|
// request per deliberate search, never a trailing one after the user stops.
|
||||||
|
const [searchDraft, setSearchDraft] = useState("");
|
||||||
|
const [q, setQ] = useState("");
|
||||||
|
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
||||||
|
const [morePending, setMorePending] = useState(false);
|
||||||
const [selected, setSelected] = useState<AssetDetail | null>(null);
|
const [selected, setSelected] = useState<AssetDetail | null>(null);
|
||||||
const [pending, setPending] = useState(false);
|
const [pending, setPending] = useState(false);
|
||||||
const [notice, setNotice] = useState("");
|
const [notice, setNotice] = useState("");
|
||||||
|
// Wrapped in an object, not a bare `string | null`: the "no appended row to
|
||||||
|
// land on" case still needs a focus move (to the page heading), and a bare
|
||||||
|
// null cannot express "a move is pending, target unknown".
|
||||||
|
const [moreFocus, setMoreFocus] = useState<{ id: string | null } | null>(null);
|
||||||
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
||||||
const detailHeadingRef = useRef<HTMLHeadingElement | null>(null);
|
const detailHeadingRef = useRef<HTMLHeadingElement | null>(null);
|
||||||
const pageHeadingRef = useRef<HTMLHeadingElement | null>(null);
|
const pageHeadingRef = useRef<HTMLHeadingElement | null>(null);
|
||||||
|
const rowRefs = useRef(new Map<string, HTMLButtonElement>());
|
||||||
const openAssetId = useRef<string | null>(null);
|
const openAssetId = useRef<string | null>(null);
|
||||||
|
const searchId = useId();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
|
// `active` rather than the abort signal alone: `RequestOptions.signal` is
|
||||||
|
// advisory -- an adapter that ignores it still resolves, and a search the
|
||||||
|
// user has already replaced would then paint stale rows over the newer
|
||||||
|
// ones. This flag flips synchronously the moment `q` changes, so an
|
||||||
|
// abandoned response can never reach `setAssets`.
|
||||||
|
let active = true;
|
||||||
setListStatus("LOADING");
|
setListStatus("LOADING");
|
||||||
props.gateway
|
props.gateway
|
||||||
.listAssets({ limit: 50 }, { signal: controller.signal })
|
.listAssets(
|
||||||
|
{ ...(q ? { q } : {}), limit: PAGE_SIZE },
|
||||||
|
{ signal: controller.signal },
|
||||||
|
)
|
||||||
.then((page) => {
|
.then((page) => {
|
||||||
|
if (!active) return;
|
||||||
setAssets(page.items);
|
setAssets(page.items);
|
||||||
|
setNextCursor(page.nextCursor);
|
||||||
setListStatus("READY");
|
setListStatus("READY");
|
||||||
})
|
})
|
||||||
.catch((error: unknown) => {
|
.catch((error: unknown) => {
|
||||||
if (isAbortError(error)) return;
|
if (!active || isAbortError(error)) return;
|
||||||
// The `role="alert"` paragraph below is the sole announcement for a
|
// The `role="alert"` paragraph below is the sole announcement for a
|
||||||
// load failure -- also routing it through `notice`'s `role="status"`
|
// load failure -- also routing it through `notice`'s `role="status"`
|
||||||
// paragraph would announce the same sentence twice.
|
// paragraph would announce the same sentence twice.
|
||||||
setListStatus("ERROR");
|
setListStatus("ERROR");
|
||||||
});
|
});
|
||||||
return () => controller.abort();
|
return () => {
|
||||||
}, [props.gateway]);
|
active = false;
|
||||||
|
controller.abort();
|
||||||
|
};
|
||||||
|
}, [props.gateway, q]);
|
||||||
|
|
||||||
// A freshly opened detail panel should take focus so keyboard and screen
|
// A freshly opened detail panel should take focus so keyboard and screen
|
||||||
// reader users land on it without hunting; re-rendering the same asset
|
// reader users land on it without hunting; re-rendering the same asset
|
||||||
@@ -64,6 +96,21 @@ export function AssetLibrary(props: Readonly<{ gateway: StudioAssetGateway }>) {
|
|||||||
openAssetId.current = selected?.asset.id ?? null;
|
openAssetId.current = selected?.asset.id ?? null;
|
||||||
}, [selected]);
|
}, [selected]);
|
||||||
|
|
||||||
|
// Alignment follow-up, item 1. "더 보기" disappears once the server reports
|
||||||
|
// no further cursor, so keeping focus on it would strand focus on `<body>`
|
||||||
|
// exactly when the last page arrives. Moving to the first appended row is
|
||||||
|
// unconditional -- it cannot depend on whether the control survived -- and
|
||||||
|
// it puts the user at the content they asked for. Run as an effect (not
|
||||||
|
// inline after `await`) so the row is committed and its ref attached.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!moreFocus) return;
|
||||||
|
const row = moreFocus.id ? rowRefs.current.get(moreFocus.id) : undefined;
|
||||||
|
// The page heading is the fallback the delete path already relies on: the
|
||||||
|
// one node in this screen that survives every list change.
|
||||||
|
(row ?? pageHeadingRef.current)?.focus();
|
||||||
|
setMoreFocus(null);
|
||||||
|
}, [moreFocus]);
|
||||||
|
|
||||||
async function openDetail(asset: Asset, trigger: HTMLButtonElement) {
|
async function openDetail(asset: Asset, trigger: HTMLButtonElement) {
|
||||||
triggerRef.current = trigger;
|
triggerRef.current = trigger;
|
||||||
try {
|
try {
|
||||||
@@ -74,6 +121,50 @@ export function AssetLibrary(props: Readonly<{ gateway: StudioAssetGateway }>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function submitSearch(event: FormEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
// A new query restarts paging: `nextCursor` was issued against the old
|
||||||
|
// query's ordering and means nothing under the new one.
|
||||||
|
setSelected(null);
|
||||||
|
setNextCursor(null);
|
||||||
|
setQ(searchDraft.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMore() {
|
||||||
|
if (!nextCursor || morePending) return;
|
||||||
|
const cursor = nextCursor;
|
||||||
|
setMorePending(true);
|
||||||
|
try {
|
||||||
|
const page = await props.gateway.listAssets({
|
||||||
|
...(q ? { q } : {}),
|
||||||
|
cursor,
|
||||||
|
limit: PAGE_SIZE,
|
||||||
|
});
|
||||||
|
// Append, never replace -- and drop anything already on screen so a
|
||||||
|
// server that re-sends a boundary row cannot produce a duplicate React
|
||||||
|
// key (or a duplicate row for the reader). The dedupe lives inside the
|
||||||
|
// updater so it reads the committed list; the focus target is derived
|
||||||
|
// separately from the rendered `assets`, because a state updater is not
|
||||||
|
// invoked synchronously and must stay free of side effects.
|
||||||
|
const known = new Set(assets.map((item) => item.id));
|
||||||
|
const appended = page.items.filter((item) => !known.has(item.id));
|
||||||
|
setAssets((current) => {
|
||||||
|
const seen = new Set(current.map((item) => item.id));
|
||||||
|
return [...current, ...page.items.filter((item) => !seen.has(item.id))];
|
||||||
|
});
|
||||||
|
setNextCursor(page.nextCursor);
|
||||||
|
setMoreFocus({ id: appended[0]?.id ?? page.items[0]?.id ?? null });
|
||||||
|
} catch (error) {
|
||||||
|
setNotice(
|
||||||
|
isStudioGatewayError(error)
|
||||||
|
? error.problem.detail
|
||||||
|
: "다음 Asset을 불러오지 못했습니다.",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setMorePending(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Dismissing the panel without changing the list (cancel/close): the row
|
// Dismissing the panel without changing the list (cancel/close): the row
|
||||||
// that opened it is still on screen, so restore focus there.
|
// that opened it is still on screen, so restore focus there.
|
||||||
function closeDetail() {
|
function closeDetail() {
|
||||||
@@ -146,6 +237,22 @@ export function AssetLibrary(props: Readonly<{ gateway: StudioAssetGateway }>) {
|
|||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<section className="studio-asset-tools" aria-label="Asset 검색 도구">
|
||||||
|
<form role="search" onSubmit={submitSearch}>
|
||||||
|
<label htmlFor={searchId}>Asset 검색</label>
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
id={searchId}
|
||||||
|
type="search"
|
||||||
|
value={searchDraft}
|
||||||
|
placeholder="Asset 키, 파일 이름"
|
||||||
|
onChange={(event) => setSearchDraft(event.target.value)}
|
||||||
|
/>
|
||||||
|
<button type="submit">검색</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
<p role="status" aria-live="polite">
|
<p role="status" aria-live="polite">
|
||||||
{notice}
|
{notice}
|
||||||
</p>
|
</p>
|
||||||
@@ -160,10 +267,24 @@ export function AssetLibrary(props: Readonly<{ gateway: StudioAssetGateway }>) {
|
|||||||
Asset 목록을 불러오지 못했습니다.
|
Asset 목록을 불러오지 못했습니다.
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
{/* The list changing is otherwise silent: a search replaces every row
|
||||||
|
and "더 보기" appends without moving anything the reader can see. */}
|
||||||
|
<p className="studio-result-count" role="status">
|
||||||
|
{listStatus === "READY" ? `${assets.length}개의 Asset` : ""}
|
||||||
|
</p>
|
||||||
{listStatus === "READY" && assets.length === 0 ? (
|
{listStatus === "READY" && assets.length === 0 ? (
|
||||||
<section className="studio-empty-state">
|
<section className="studio-empty-state">
|
||||||
<h2>등록된 Asset이 없습니다</h2>
|
{q ? (
|
||||||
<p>Case 편집 화면에서 Asset을 업로드하면 여기에서 관리할 수 있습니다.</p>
|
<>
|
||||||
|
<h2>조건에 맞는 Asset이 없습니다</h2>
|
||||||
|
<p>검색어를 바꾸거나 비우면 전체 Asset을 다시 볼 수 있습니다.</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<h2>등록된 Asset이 없습니다</h2>
|
||||||
|
<p>Case 편집 화면에서 Asset을 업로드하면 여기에서 관리할 수 있습니다.</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -173,6 +294,11 @@ export function AssetLibrary(props: Readonly<{ gateway: StudioAssetGateway }>) {
|
|||||||
<li key={asset.id}>
|
<li key={asset.id}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
ref={(node) => {
|
||||||
|
const rows = rowRefs.current;
|
||||||
|
if (node) rows.set(asset.id, node);
|
||||||
|
else rows.delete(asset.id);
|
||||||
|
}}
|
||||||
aria-current={selected?.asset.id === asset.id ? "true" : undefined}
|
aria-current={selected?.asset.id === asset.id ? "true" : undefined}
|
||||||
onClick={(event) => void openDetail(asset, event.currentTarget)}
|
onClick={(event) => void openDetail(asset, event.currentTarget)}
|
||||||
>
|
>
|
||||||
@@ -185,6 +311,17 @@ export function AssetLibrary(props: Readonly<{ gateway: StudioAssetGateway }>) {
|
|||||||
</ul>
|
</ul>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{nextCursor && listStatus === "READY" ? (
|
||||||
|
<button
|
||||||
|
className="studio-secondary-button"
|
||||||
|
type="button"
|
||||||
|
disabled={morePending}
|
||||||
|
onClick={() => void loadMore()}
|
||||||
|
>
|
||||||
|
더 보기
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{selected ? (
|
{selected ? (
|
||||||
<div className="asset-detail" aria-label={`${selected.asset.assetKey} 상세`}>
|
<div className="asset-detail" aria-label={`${selected.asset.assetKey} 상세`}>
|
||||||
<h2 ref={detailHeadingRef} tabIndex={-1}>
|
<h2 ref={detailHeadingRef} tabIndex={-1}>
|
||||||
|
|||||||
@@ -51,6 +51,7 @@
|
|||||||
.studio-app .studio-primary-button,
|
.studio-app .studio-primary-button,
|
||||||
.studio-app .studio-secondary-button,
|
.studio-app .studio-secondary-button,
|
||||||
.studio-app .studio-document-tools button,
|
.studio-app .studio-document-tools button,
|
||||||
|
.studio-app .studio-asset-tools button,
|
||||||
.studio-app .studio-empty-state a { display: inline-flex; min-height: 44px; align-items: center; justify-content: center; padding-inline: 16px; border: 1px solid var(--line-strong); border-radius: 5px; background: var(--paper); color: var(--ink); font-weight: 650; }
|
.studio-app .studio-empty-state a { display: inline-flex; min-height: 44px; align-items: center; justify-content: center; padding-inline: 16px; border: 1px solid var(--line-strong); border-radius: 5px; background: var(--paper); color: var(--ink); font-weight: 650; }
|
||||||
.studio-app .studio-primary-action,
|
.studio-app .studio-primary-action,
|
||||||
.studio-app .studio-primary-button { margin-bottom: 42px; border-color: var(--signal); background: var(--signal); color: #fff; }
|
.studio-app .studio-primary-button { margin-bottom: 42px; border-color: var(--signal); background: var(--signal); color: #fff; }
|
||||||
@@ -76,12 +77,18 @@
|
|||||||
.studio-app .studio-screen-error { margin-top: 26px; padding: 16px; border-left: 3px solid #a13b31; background: #fff7f4; color: #75271f; }
|
.studio-app .studio-screen-error { margin-top: 26px; padding: 16px; border-left: 3px solid #a13b31; background: #fff7f4; color: #75271f; }
|
||||||
|
|
||||||
.studio-app .studio-document-tools { display: grid; grid-template-columns: minmax(280px, 1fr) 180px 180px; gap: 16px; padding-block: 28px; border-bottom: 1px solid var(--line-strong); }
|
.studio-app .studio-document-tools { display: grid; grid-template-columns: minmax(280px, 1fr) 180px 180px; gap: 16px; padding-block: 28px; border-bottom: 1px solid var(--line-strong); }
|
||||||
|
.studio-app .studio-asset-tools { display: grid; grid-template-columns: minmax(0, 1fr); gap: 16px; padding-block: 28px; border-bottom: 1px solid var(--line-strong); }
|
||||||
.studio-app .studio-document-tools form,
|
.studio-app .studio-document-tools form,
|
||||||
.studio-app .studio-document-tools label { display: grid; gap: 8px; color: var(--muted); font-size: 12px; font-weight: 650; }
|
.studio-app .studio-document-tools label,
|
||||||
.studio-app .studio-document-tools form div { display: flex; gap: 8px; }
|
.studio-app .studio-asset-tools form,
|
||||||
|
.studio-app .studio-asset-tools label { display: grid; gap: 8px; color: var(--muted); font-size: 12px; font-weight: 650; }
|
||||||
|
.studio-app .studio-document-tools form div,
|
||||||
|
.studio-app .studio-asset-tools form div { display: flex; gap: 8px; }
|
||||||
.studio-app .studio-document-tools input,
|
.studio-app .studio-document-tools input,
|
||||||
.studio-app .studio-document-tools select { width: 100%; min-width: 0; min-height: 44px; padding-inline: 12px; border: 1px solid var(--line-strong); border-radius: 5px; background: var(--paper); color: var(--ink); }
|
.studio-app .studio-document-tools select,
|
||||||
.studio-app .studio-document-tools input { flex: 1; }
|
.studio-app .studio-asset-tools input { width: 100%; min-width: 0; min-height: 44px; padding-inline: 12px; border: 1px solid var(--line-strong); border-radius: 5px; background: var(--paper); color: var(--ink); }
|
||||||
|
.studio-app .studio-document-tools input,
|
||||||
|
.studio-app .studio-asset-tools input { flex: 1; }
|
||||||
.studio-app .studio-result-count { margin: 24px 0 10px; color: var(--muted); font-size: 13px; }
|
.studio-app .studio-result-count { margin: 24px 0 10px; color: var(--muted); font-size: 13px; }
|
||||||
.studio-app .studio-document-row { display: grid; grid-template-columns: 105px minmax(240px, 1fr) minmax(360px, 0.9fr); gap: 24px; align-items: start; padding-block: 26px; border-top: 1px solid var(--line); }
|
.studio-app .studio-document-row { display: grid; grid-template-columns: 105px minmax(240px, 1fr) minmax(360px, 0.9fr); gap: 24px; align-items: start; padding-block: 26px; border-top: 1px solid var(--line); }
|
||||||
.studio-app .studio-document-list > :first-child { border-top: 0; }
|
.studio-app .studio-document-list > :first-child { border-top: 0; }
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { test } from "vitest";
|
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 userEvent from "@testing-library/user-event";
|
||||||
|
|
||||||
import {
|
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 });
|
const heading = screen.getByRole("heading", { name: "Asset", level: 1 });
|
||||||
assert.equal(document.activeElement, heading);
|
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