diff --git a/src/features/tech-log/presentation/studio/components/asset-library.tsx b/src/features/tech-log/presentation/studio/components/asset-library.tsx index a795c8e..1f656fc 100644 --- a/src/features/tech-log/presentation/studio/components/asset-library.tsx +++ b/src/features/tech-log/presentation/studio/components/asset-library.tsx @@ -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 { StudioAssetGateway } from "../../../application/ports/studio-asset-gateway.ts"; import { isStudioGatewayError } from "../../../application/ports/studio-gateway-error.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_IN_USE`를 던지므로 화면은 시도 자체를 막아 왕복을 줄인다. @@ -26,32 +29,61 @@ export function AssetLibrary(props: Readonly<{ gateway: StudioAssetGateway }>) { const [listStatus, setListStatus] = useState<"LOADING" | "ERROR" | "READY">( "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(null); + const [morePending, setMorePending] = useState(false); const [selected, setSelected] = useState(null); const [pending, setPending] = useState(false); 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(null); const detailHeadingRef = useRef(null); const pageHeadingRef = useRef(null); + const rowRefs = useRef(new Map()); const openAssetId = useRef(null); + const searchId = useId(); useEffect(() => { 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"); props.gateway - .listAssets({ limit: 50 }, { signal: controller.signal }) + .listAssets( + { ...(q ? { q } : {}), limit: PAGE_SIZE }, + { signal: controller.signal }, + ) .then((page) => { + if (!active) return; setAssets(page.items); + setNextCursor(page.nextCursor); setListStatus("READY"); }) .catch((error: unknown) => { - if (isAbortError(error)) return; + if (!active || isAbortError(error)) return; // The `role="alert"` paragraph below is the sole announcement for a // load failure -- also routing it through `notice`'s `role="status"` // paragraph would announce the same sentence twice. setListStatus("ERROR"); }); - return () => controller.abort(); - }, [props.gateway]); + return () => { + active = false; + controller.abort(); + }; + }, [props.gateway, q]); // A freshly opened detail panel should take focus so keyboard and screen // 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; }, [selected]); + // Alignment follow-up, item 1. "더 보기" disappears once the server reports + // no further cursor, so keeping focus on it would strand focus on `` + // 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) { triggerRef.current = trigger; 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 // that opened it is still on screen, so restore focus there. function closeDetail() { @@ -146,6 +237,22 @@ export function AssetLibrary(props: Readonly<{ gateway: StudioAssetGateway }>) {

+
+
+ +
+ setSearchDraft(event.target.value)} + /> + +
+
+
+

{notice}

@@ -160,10 +267,24 @@ export function AssetLibrary(props: Readonly<{ gateway: StudioAssetGateway }>) { Asset 목록을 불러오지 못했습니다.

) : null} + {/* The list changing is otherwise silent: a search replaces every row + and "더 보기" appends without moving anything the reader can see. */} +

+ {listStatus === "READY" ? `${assets.length}개의 Asset` : ""} +

{listStatus === "READY" && assets.length === 0 ? (
-

등록된 Asset이 없습니다

-

Case 편집 화면에서 Asset을 업로드하면 여기에서 관리할 수 있습니다.

+ {q ? ( + <> +

조건에 맞는 Asset이 없습니다

+

검색어를 바꾸거나 비우면 전체 Asset을 다시 볼 수 있습니다.

+ + ) : ( + <> +

등록된 Asset이 없습니다

+

Case 편집 화면에서 Asset을 업로드하면 여기에서 관리할 수 있습니다.

+ + )}
) : null} @@ -173,6 +294,11 @@ export function AssetLibrary(props: Readonly<{ gateway: StudioAssetGateway }>) {
  • + ) : null} + {selected ? (

    diff --git a/src/features/tech-log/presentation/styles/studio.css b/src/features/tech-log/presentation/styles/studio.css index 31d0d9e..e809ab4 100644 --- a/src/features/tech-log/presentation/styles/studio.css +++ b/src/features/tech-log/presentation/styles/studio.css @@ -51,6 +51,7 @@ .studio-app .studio-primary-button, .studio-app .studio-secondary-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-primary-action, .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-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 label { display: grid; gap: 8px; color: var(--muted); font-size: 12px; font-weight: 650; } -.studio-app .studio-document-tools form div { display: flex; gap: 8px; } +.studio-app .studio-document-tools label, +.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 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 input { flex: 1; } +.studio-app .studio-document-tools select, +.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-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; } diff --git a/tests/features/tech-log/asset-library.test.tsx b/tests/features/tech-log/asset-library.test.tsx index 2910981..a0bf8b6 100644 --- a/tests/features/tech-log/asset-library.test.tsx +++ b/tests/features/tech-log/asset-library.test.tsx @@ -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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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((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(); + + 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(); + + 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); +});