feat(ai-character): 캐릭터 목록 사용성 개선
This commit is contained in:
@@ -1,35 +1,157 @@
|
||||
import { navigateTo } from "@/app/browser-location";
|
||||
import { routePaths } from "@/app/route-paths";
|
||||
import { CharacterListItem } from "@/features/characters/components/CharacterListItem";
|
||||
import type { CharacterListItem as CharacterListItemData } from "@/features/characters/model/types";
|
||||
import type { PageData } from "@/shared/api/pagination";
|
||||
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> }) {
|
||||
const [speechDialogTarget, setSpeechDialogTarget] = useState<SpeechDialogTarget | null>(null);
|
||||
|
||||
const desktop = (
|
||||
<table className="min-w-full border-separate border-spacing-0 text-left text-sm">
|
||||
<thead className="text-muted-foreground">
|
||||
<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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.items.map((character) => (
|
||||
<tr key={character.id}>
|
||||
{data.items.map((character) => {
|
||||
const detailPath = routePaths.aiCharacterDetail(String(character.id));
|
||||
|
||||
function openDetail() {
|
||||
navigateTo(detailPath);
|
||||
}
|
||||
|
||||
function handleDetailLinkClick(event: MouseEvent<HTMLAnchorElement>) {
|
||||
event.preventDefault();
|
||||
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">
|
||||
<p className="font-semibold">{character.name}</p>
|
||||
<p className="mt-1 max-w-xl text-muted-foreground">{character.description}</p>
|
||||
<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">{character.tags.join(", ")}</td>
|
||||
<td className="border-b border-border px-4 py-3"><CharacterListItem character={character} /></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>
|
||||
</table>
|
||||
);
|
||||
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} /></>;
|
||||
}
|
||||
|
||||
@@ -80,10 +80,10 @@ export function CharacterListPage({ apiClient, routeError }: { readonly apiClien
|
||||
<div className="flex flex-col gap-2">
|
||||
<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>
|
||||
<p className="text-sm text-muted-foreground">목록에서 캐릭터를 검색하고 선택해 워크스페이스로 이동합니다.</p>
|
||||
<p className="break-keep text-sm text-muted-foreground">목록에서 캐릭터를 검색하고 선택해 워크스페이스로 이동합니다.</p>
|
||||
</div>
|
||||
<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}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -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 { afterEach, expect, test, vi } from "vitest";
|
||||
|
||||
@@ -15,8 +15,8 @@ const activeCharacter = {
|
||||
gender: "여성",
|
||||
age: 24,
|
||||
mbti: "INFJ",
|
||||
speechStyle: "다정함",
|
||||
speechPattern: "존댓말",
|
||||
speechStyle: "부드럽고 공감하는 말투",
|
||||
speechPattern: "천천히 설명하는 존댓말",
|
||||
region: "KR",
|
||||
tags: ["상담", "힐링"],
|
||||
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");
|
||||
});
|
||||
|
||||
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 () => {
|
||||
// Given
|
||||
saveAdminSession();
|
||||
|
||||
@@ -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 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(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");
|
||||
});
|
||||
|
||||
@@ -14,22 +14,26 @@ export function ResourcePagination({ data, onPageChange, onSizeChange, sizeOptio
|
||||
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">
|
||||
<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">
|
||||
<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}>
|
||||
<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}>
|
||||
{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">
|
||||
</div>
|
||||
<div aria-label="페이지 이동" className="grid grid-cols-2 gap-2 sm:flex" role="group">
|
||||
<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 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.hasNext} onClick={() => onPageChange(data.page + 1)} type="button">
|
||||
다음 페이지
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user