feat(ai-character): 캐릭터 목록 사용성 개선

This commit is contained in:
Yu Sung
2026-08-03 11:54:53 +09:00
parent 3759abf8b7
commit b01fda4400
5 changed files with 231 additions and 34 deletions

View File

@@ -1,35 +1,157 @@
import { navigateTo } from "@/app/browser-location";
import { routePaths } from "@/app/route-paths";
import { CharacterListItem } from "@/features/characters/components/CharacterListItem"; import { CharacterListItem } from "@/features/characters/components/CharacterListItem";
import type { CharacterListItem as CharacterListItemData } from "@/features/characters/model/types"; import type { CharacterListItem as CharacterListItemData } from "@/features/characters/model/types";
import type { PageData } from "@/shared/api/pagination"; import type { PageData } from "@/shared/api/pagination";
import { ResponsiveResourceList } from "@/shared/ui/responsive-resource-list"; import { ResponsiveResourceList } from "@/shared/ui/responsive-resource-list";
import { useModalFocus } from "@/shared/ui/use-modal-focus";
import type { KeyboardEvent, MouseEvent } from "react";
import { useState } from "react";
const previewLength = 14;
type SpeechDialogTarget = {
readonly name: string;
readonly speechPattern: string | null;
readonly speechStyle: string | null;
};
function optionalText(value: string | null): string {
return value === null || value.length === 0 ? "-" : value;
}
function truncatePreview(value: string): string {
const characters = Array.from(value);
return characters.length > previewLength ? `${characters.slice(0, previewLength).join("")}...` : value;
}
function speechText(character: CharacterListItemData): string {
return `${optionalText(character.speechStyle)} · ${optionalText(character.speechPattern)}`;
}
function padDatePart(value: number): string {
return String(value).padStart(2, "0");
}
function formatUtcTimestamp(value: string | null): string {
if (value === null || value.length === 0) {
return "-";
}
const localDate = new Date(`${value.replace(" ", "T")}Z`);
return `${localDate.getFullYear()}-${padDatePart(localDate.getMonth() + 1)}-${padDatePart(localDate.getDate())} ${padDatePart(localDate.getHours())}:${padDatePart(localDate.getMinutes())}`;
}
function SpeechDialog({ onClose, target }: { readonly onClose: () => void; readonly target: SpeechDialogTarget | null }) {
const { dialogRef, trapFocus } = useModalFocus<HTMLElement>(target !== null);
if (target === null) {
return null;
}
function handleKeyDown(event: KeyboardEvent<HTMLElement>) {
if (event.key === "Escape") {
event.stopPropagation();
onClose();
return;
}
trapFocus(event);
}
return (
<div className="fixed inset-0 z-modal grid place-items-center bg-background/80 p-4">
<section aria-label={`${target.name} 말투`} aria-modal="true" className="flex w-full max-w-sm flex-col gap-4 rounded-lg border border-border bg-card p-6" onKeyDown={handleKeyDown} ref={dialogRef} role="dialog">
<div className="flex flex-col gap-2">
<h2 className="text-xl font-semibold">{target.name} </h2>
<dl className="grid gap-3 text-sm">
<div>
<dt className="font-semibold text-muted-foreground"> </dt>
<dd className="mt-1 break-keep break-words">{optionalText(target.speechStyle)}</dd>
</div>
<div>
<dt className="font-semibold text-muted-foreground"> </dt>
<dd className="mt-1 break-keep break-words">{optionalText(target.speechPattern)}</dd>
</div>
</dl>
</div>
<button className="min-h-11 w-fit self-end rounded-md border border-input bg-card px-4 py-2 font-semibold hover:bg-accent" onClick={onClose} type="button"></button>
</section>
</div>
);
}
export function CharacterList({ data }: { readonly data: PageData<CharacterListItemData> }) { export function CharacterList({ data }: { readonly data: PageData<CharacterListItemData> }) {
const [speechDialogTarget, setSpeechDialogTarget] = useState<SpeechDialogTarget | null>(null);
const desktop = ( const desktop = (
<table className="min-w-full border-separate border-spacing-0 text-left text-sm"> <table className="min-w-full border-separate border-spacing-0 text-left text-sm">
<thead className="text-muted-foreground"> <thead className="text-muted-foreground">
<tr> <tr>
<th className="border-b border-border px-4 py-3 font-semibold" scope="col"></th> <th className="border-b border-border px-4 py-3 font-semibold" scope="col"></th>
<th className="border-b border-border px-4 py-3 font-semibold" scope="col"></th> <th className="border-b border-border px-4 py-3 font-semibold" scope="col"></th>
<th className="border-b border-border px-4 py-3 font-semibold" scope="col"></th>
<th className="border-b border-border px-4 py-3 font-semibold" scope="col"></th>
<th className="border-b border-border px-4 py-3 font-semibold" scope="col"></th> <th className="border-b border-border px-4 py-3 font-semibold" scope="col"></th>
<th className="border-b border-border px-4 py-3 font-semibold" scope="col"></th> <th className="border-b border-border px-4 py-3 font-semibold" scope="col"></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{data.items.map((character) => ( {data.items.map((character) => {
<tr key={character.id}> const detailPath = routePaths.aiCharacterDetail(String(character.id));
<td className="border-b border-border px-4 py-3">
<p className="font-semibold">{character.name}</p> function openDetail() {
<p className="mt-1 max-w-xl text-muted-foreground">{character.description}</p> navigateTo(detailPath);
</td> }
<td className="border-b border-border px-4 py-3">{character.region}</td>
<td className="border-b border-border px-4 py-3">{character.tags.join(", ")}</td> function handleDetailLinkClick(event: MouseEvent<HTMLAnchorElement>) {
<td className="border-b border-border px-4 py-3"><CharacterListItem character={character} /></td> event.preventDefault();
</tr> event.stopPropagation();
))} openDetail();
}
return (
<tr className="cursor-pointer hover:bg-accent" key={character.id} onClick={openDetail}>
<td className="border-b border-border px-4 py-3">
<div className="flex min-w-64 items-start gap-3">
{character.imageUrl === null ? (
<span aria-hidden="true" className="grid size-12 shrink-0 place-items-center rounded-md bg-muted font-semibold text-muted-foreground">
{character.name.slice(0, 1)}
</span>
) : (
<img alt="" className="size-12 shrink-0 rounded-md object-cover" height="48" loading="lazy" src={character.imageUrl} width="48" />
)}
<div className="min-w-0">
<a className="font-semibold text-foreground underline-offset-4 hover:underline focus-visible:outline focus-visible:outline-2 focus-visible:outline-ring" href={detailPath} onClick={handleDetailLinkClick}>{character.name}</a>
<p className="mt-1 max-w-xl break-keep break-words text-muted-foreground">{character.description.length > 0 ? character.description : "-"}</p>
<p className="mt-1 text-xs font-semibold text-info">ID {character.id}</p>
</div>
</div>
</td>
<td className="border-b border-border px-4 py-3">{character.region}</td>
<td className="border-b border-border px-4 py-3">{optionalText(character.gender)} · {character.age === null ? "-" : `${character.age}`} · {optionalText(character.mbti)}</td>
<td className="border-b border-border px-4 py-3">
<button
aria-label={`${character.name} 말투 전체 보기`}
className="max-w-44 rounded-md border border-input bg-card px-3 py-2 text-left text-sm font-semibold hover:bg-accent"
onClick={(event) => {
event.stopPropagation();
setSpeechDialogTarget({ name: character.name, speechPattern: character.speechPattern, speechStyle: character.speechStyle });
}}
type="button"
>
{truncatePreview(speechText(character))}
</button>
</td>
<td className="border-b border-border px-4 py-3">{character.tags.length > 0 ? character.tags.join(", ") : "-"}</td>
<td className="border-b border-border px-4 py-3 text-xs text-muted-foreground">{formatUtcTimestamp(character.updatedAt)}</td>
</tr>
);
})}
</tbody> </tbody>
</table> </table>
); );
const mobile = <div className="grid gap-3 p-3">{data.items.map((character) => <CharacterListItem character={character} key={character.id} />)}</div>; const mobile = <div className="grid gap-3 p-3">{data.items.map((character) => <CharacterListItem character={character} key={character.id} />)}</div>;
return <ResponsiveResourceList ariaLabel="AI 캐릭터 목록" desktop={desktop} mobile={mobile} />; return <><ResponsiveResourceList ariaLabel="AI 캐릭터 목록" desktop={desktop} mobile={mobile} /><SpeechDialog onClose={() => setSpeechDialogTarget(null)} target={speechDialogTarget} /></>;
} }

View File

@@ -80,10 +80,10 @@ export function CharacterListPage({ apiClient, routeError }: { readonly apiClien
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<p className="text-xs font-semibold text-info">AI CHARACTER ADMIN</p> <p className="text-xs font-semibold text-info">AI CHARACTER ADMIN</p>
<h1 className="text-2xl font-bold leading-tight" id="ai-characters-title">AI </h1> <h1 className="text-2xl font-bold leading-tight" id="ai-characters-title">AI </h1>
<p className="text-sm text-muted-foreground"> .</p> <p className="break-keep text-sm text-muted-foreground"> .</p>
</div> </div>
<a <a
className="hidden rounded-md border border-input bg-primary px-4 py-2 font-semibold text-primary-foreground hover:bg-[var(--button-bg-hover)] active:bg-[var(--button-bg-active)] md:inline-flex" className="inline-flex min-h-11 self-start items-center rounded-md border border-input bg-primary px-4 py-2 font-semibold text-primary-foreground hover:bg-[var(--button-bg-hover)] active:bg-[var(--button-bg-active)] md:self-auto"
href={routePaths.aiCharacterCreate} href={routePaths.aiCharacterCreate}
onClick={(event) => { onClick={(event) => {
event.preventDefault(); event.preventDefault();

View File

@@ -1,4 +1,4 @@
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { http, HttpResponse } from "msw"; import { http, HttpResponse } from "msw";
import { afterEach, expect, test, vi } from "vitest"; import { afterEach, expect, test, vi } from "vitest";
@@ -15,8 +15,8 @@ const activeCharacter = {
gender: "여성", gender: "여성",
age: 24, age: 24,
mbti: "INFJ", mbti: "INFJ",
speechStyle: "다정함", speechStyle: "부드럽고 공감하는 말투",
speechPattern: "존댓말", speechPattern: "천천히 설명하는 존댓말",
region: "KR", region: "KR",
tags: ["상담", "힐링"], tags: ["상담", "힐링"],
createdAt: "2026-07-28 10:00:00", createdAt: "2026-07-28 10:00:00",
@@ -72,6 +72,62 @@ test("Character list serializes URL search to searchTerm without active filters
expect(screen.getAllByRole("link", { name: /루나 선택/ })[0]).toHaveAttribute("href", "/ai-characters/101"); expect(screen.getAllByRole("link", { name: /루나 선택/ })[0]).toHaveAttribute("href", "/ai-characters/101");
}); });
test("Character list renders desktop comparison metadata without duplicating the mobile card", async () => {
// Given
saveAdminSession();
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
useListResponse();
window.history.pushState({}, "", "/ai-characters");
// When
render(<App />);
const table = await screen.findByRole("table");
const desktop = within(table);
const createLink = screen.getByRole("link", { name: "AI 캐릭터 생성" });
const description = screen.getByText("목록에서 캐릭터를 검색하고 선택해 워크스페이스로 이동합니다.");
// Then
expect(createLink).not.toHaveClass("hidden");
expect(createLink).toHaveClass("min-h-11");
expect(description).toHaveClass("break-keep");
expect(desktop.queryByRole("columnheader", { name: "선택" })).not.toBeInTheDocument();
expect(desktop.getByRole("columnheader", { name: "프로필" })).toBeInTheDocument();
expect(desktop.getByRole("columnheader", { name: "말투" })).toBeInTheDocument();
expect(desktop.getByRole("columnheader", { name: "수정일" })).toBeInTheDocument();
expect(desktop.getByText("ID 101")).toBeInTheDocument();
expect(desktop.getByText("여성 · 24세 · INFJ")).toBeInTheDocument();
expect(desktop.queryByText("부드럽고 공감하는 말투 · 천천히 설명하는 존댓말")).not.toBeInTheDocument();
expect(desktop.getByRole("button", { name: "루나 말투 전체 보기" })).toBeInTheDocument();
expect(desktop.getByText("2026-07-28 20:00")).toBeInTheDocument();
expect(desktop.queryByText("2026-07-28 11:00:00")).not.toBeInTheDocument();
expect(desktop.queryByText("2026-07-28 10:00:00")).not.toBeInTheDocument();
expect(desktop.getByRole("link", { name: "루나" })).toHaveAttribute("href", "/ai-characters/101");
expect(screen.getAllByRole("link", { name: "루나 선택" })).toHaveLength(1);
fireEvent.click(desktop.getByRole("button", { name: "루나 말투 전체 보기" }));
const dialog = screen.getByRole("dialog", { name: "루나 말투" });
expect(dialog).toHaveTextContent("부드럽고 공감하는 말투");
expect(dialog).toHaveTextContent("천천히 설명하는 존댓말");
fireEvent.click(within(dialog).getByRole("button", { name: "닫기" }));
expect(screen.queryByRole("dialog", { name: "루나 말투" })).not.toBeInTheDocument();
});
test("Character list opens detail when the desktop row is clicked", async () => {
// Given
saveAdminSession();
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
useListResponse();
window.history.pushState({}, "", "/ai-characters");
// When
render(<App />);
const row = await screen.findByRole("row", { name: /루나/ });
// Then
fireEvent.click(row);
await waitFor(() => expect(window.location.pathname).toBe("/ai-characters/101"));
});
test("Character list supports loading, empty, error retry, and selection navigation", async () => { test("Character list supports loading, empty, error retry, and selection navigation", async () => {
// Given // Given
saveAdminSession(); saveAdminSession();

View File

@@ -1,4 +1,4 @@
import { fireEvent, render, screen } from "@testing-library/react"; import { fireEvent, render, screen, within } from "@testing-library/react";
import { expect, test, vi } from "vitest"; import { expect, test, vi } from "vitest";
import type { PageData } from "@/shared/api/pagination"; import type { PageData } from "@/shared/api/pagination";
@@ -56,3 +56,18 @@ test("ResourcePagination connects each page size label to a unique select", () =
expect(new Set(selectIds).size).toBe(selectIds.length); expect(new Set(selectIds).size).toBe(selectIds.length);
expect(labels.map((label) => label.control)).toEqual(selects); expect(labels.map((label) => label.control)).toEqual(selects);
}); });
test("ResourcePagination separates page size from an equal-width mobile movement row", () => {
render(<ResourcePagination data={pageData} onPageChange={vi.fn()} onSizeChange={vi.fn()} />);
const sizeControls = screen.getByRole("group", { name: "페이지 크기 설정" });
const movementControls = screen.getByRole("group", { name: "페이지 이동" });
const previous = within(movementControls).getByRole("button", { name: "이전 페이지" });
const next = within(movementControls).getByRole("button", { name: "다음 페이지" });
expect(within(sizeControls).getByLabelText("페이지 크기")).toBeInTheDocument();
expect(sizeControls).not.toContainElement(previous);
expect(movementControls).toHaveClass("grid-cols-2");
expect(previous).toHaveClass("min-h-11", "w-full");
expect(next).toHaveClass("min-h-11", "w-full");
});

View File

@@ -14,21 +14,25 @@ export function ResourcePagination({ data, onPageChange, onSizeChange, sizeOptio
return ( return (
<nav aria-label="페이지" className="flex flex-col gap-3 rounded-lg border border-border bg-card p-4 sm:flex-row sm:items-center sm:justify-between"> <nav aria-label="페이지" className="flex flex-col gap-3 rounded-lg border border-border bg-card p-4 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm font-semibold text-muted-foreground"> {data.totalCount.toLocaleString("ko-KR")} · {data.page + 1}</p> <p className="text-sm font-semibold text-muted-foreground"> {data.totalCount.toLocaleString("ko-KR")} · {data.page + 1}</p>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<label className="text-sm font-semibold" htmlFor={pageSizeId}> <div aria-label="페이지 크기 설정" className="flex items-center gap-2" role="group">
<label className="text-sm font-semibold" htmlFor={pageSizeId}>
</label>
<select className="rounded-md border border-input bg-card px-3 py-2 text-base" id={pageSizeId} onChange={(event) => onSizeChange(Number(event.currentTarget.value))} value={data.size}> </label>
{sizeOptions.map((size) => ( <select className="min-h-11 rounded-md border border-input bg-card px-3 py-2 text-base" id={pageSizeId} onChange={(event) => onSizeChange(Number(event.currentTarget.value))} value={data.size}>
<option key={size} value={size}>{size}</option> {sizeOptions.map((size) => (
))} <option key={size} value={size}>{size}</option>
</select> ))}
<button className="rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent disabled:opacity-60" disabled={data.page <= 0} onClick={() => onPageChange(data.page - 1)} type="button"> </select>
</div>
</button> <div aria-label="페이지 이동" className="grid grid-cols-2 gap-2 sm:flex" role="group">
<button className="rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent disabled:opacity-60" disabled={!data.hasNext} onClick={() => onPageChange(data.page + 1)} type="button"> <button className="min-h-11 w-full rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent disabled:opacity-60 sm:w-auto" disabled={data.page <= 0} onClick={() => onPageChange(data.page - 1)} type="button">
</button> </button>
<button className="min-h-11 w-full rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent disabled:opacity-60 sm:w-auto" disabled={!data.hasNext} onClick={() => onPageChange(data.page + 1)} type="button">
</button>
</div>
</div> </div>
</nav> </nav>
); );