feat(ai-character): 시리즈 키워드 칩 입력 적용

This commit is contained in:
Yu Sung
2026-08-04 15:20:09 +09:00
parent d030d2eebd
commit bd37e05d82
9 changed files with 91 additions and 16 deletions

View File

@@ -8,7 +8,6 @@ import type { UploadAudioContentOptions, UploadAuthDependencies } from "@/featur
import { audioErrorMessage, coverErrorMessage, createInitialPrice, defaultAudioContentCreateSettings, formatPrice, isFutureLocalDateTime, parsePrice, toCreateRequest, toUpdateRequest } from "@/features/audio-contents/components/audio-content-form-helpers";
import type { AudioContentCreateSettings } from "@/features/audio-contents/components/audio-content-form-helpers";
import { AudioContentCreateOptions } from "@/features/audio-contents/components/AudioContentCreateOptions";
import { AudioTagInput } from "@/features/audio-contents/components/AudioTagInput";
import { AudioContentThemeSelect } from "@/features/audio-contents/components/AudioContentThemeSelect";
import { ReleaseScheduleField } from "@/features/audio-contents/components/ReleaseScheduleField";
import type { ReleaseScheduleValue } from "@/features/audio-contents/components/ReleaseScheduleField";
@@ -25,6 +24,7 @@ import { ConfirmDeactivateDialog } from "@/shared/ui/confirm-deactivate-dialog";
import { FileField } from "@/shared/ui/file-field";
import { ImageCropDialog } from "@/shared/ui/image-crop-dialog";
import type { CropSourceImage } from "@/shared/ui/image-crop-dialog";
import { TagInput } from "@/shared/ui/tag-input";
import { CAN_PRICE_MAX } from "@/shared/validation/can-price";
import { AUDIO_FILE_POLICY } from "@/shared/validation/audio-file-policy";
import { UnsavedChangesGuard } from "@/shared/ui/unsaved-changes-guard";
@@ -234,7 +234,7 @@ export function AudioContentForm({ apiClient, audio, characterId, createCropSour
{errors.title === undefined ? null : <p className="text-sm font-semibold text-destructive" id={errorIds.title} role="alert">{errors.title}</p>}
<label className="flex flex-col gap-2 text-sm font-semibold"> <textarea aria-describedby={errors.detail === undefined ? undefined : errorIds.detail} aria-invalid={errors.detail === undefined ? undefined : true} className="min-h-28 rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground" onChange={(event) => setDetail(event.currentTarget.value)} value={detail} /></label>
{errors.detail === undefined ? null : <p className="text-sm font-semibold text-destructive" id={errorIds.detail} role="alert">{errors.detail}</p>}
<AudioTagInput error={errors.tags} errorId={errorIds.tags} onChange={setTags} value={tags} />
<TagInput error={errors.tags} errorId={errorIds.tags} label="태그" onChange={setTags} value={tags} />
{mode === "create" ? <AudioContentThemeSelect error={errors.theme} errorId={errorIds.theme} onChange={setThemeId} themes={themes} value={themeId} /> : <p className="rounded-lg border border-border bg-muted p-3 text-sm font-semibold text-muted-foreground">: {audio?.themeStr ?? "-"} ( )</p>}
<label className="flex flex-col gap-2 text-sm font-semibold"><input aria-describedby={errors.price === undefined ? undefined : errorIds.price} aria-invalid={errors.price === undefined ? undefined : true} className="rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground" inputMode="numeric" min="0" onChange={(event) => changePrice(event.currentTarget.value)} step="1" type="number" value={price} /></label><p className="text-sm text-muted-foreground">단위: </p>
{errors.price === undefined ? null : <p className="text-sm font-semibold text-destructive" id={errorIds.price} role="alert">{errors.price}</p>}

View File

@@ -16,6 +16,7 @@ import { ConfirmDeactivateDialog } from "@/shared/ui/confirm-deactivate-dialog";
import { FileField } from "@/shared/ui/file-field";
import { ImageCropDialog } from "@/shared/ui/image-crop-dialog";
import type { CropSourceImage } from "@/shared/ui/image-crop-dialog";
import { TagInput } from "@/shared/ui/tag-input";
import { UnsavedChangesGuard } from "@/shared/ui/unsaved-changes-guard";
type FieldErrors = {
@@ -221,8 +222,7 @@ export function SeriesForm({ apiClient, characterId, createCropSource, genres, m
{errors.title === undefined ? null : <p className="text-sm font-semibold text-destructive" id={errorIds.title} role="alert">{errors.title}</p>}
<label className="flex flex-col gap-2 text-sm font-semibold"><textarea aria-describedby={errors.introduction === undefined ? undefined : errorIds.introduction} aria-invalid={errors.introduction === undefined ? undefined : true} className="min-h-28 rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground" onChange={(event) => setIntroduction(event.currentTarget.value)} value={introduction} /></label>
{errors.introduction === undefined ? null : <p className="text-sm font-semibold text-destructive" id={errorIds.introduction} role="alert">{errors.introduction}</p>}
{mode === "create" ? <label className="flex flex-col gap-2 text-sm font-semibold"><input aria-describedby={errors.keyword === undefined ? undefined : errorIds.keyword} aria-invalid={errors.keyword === undefined ? undefined : true} className="rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground" onChange={(event) => setKeyword(event.currentTarget.value)} value={keyword} /></label> : null}
{errors.keyword === undefined ? null : <p className="text-sm font-semibold text-destructive" id={errorIds.keyword} role="alert">{errors.keyword}</p>}
{mode === "create" ? <TagInput error={errors.keyword} errorId={errorIds.keyword} label="키워드" onChange={setKeyword} value={keyword} /> : null}
<GenreCombobox error={errors.genre} errorId={errorIds.genre} genres={genres} onChange={setGenreId} value={genreId} />
<PublishedDaysField error={errors.days} errorId={errorIds.days} onChange={setPublishedDays} value={publishedDays} />
{mode === "edit" ? <label className="flex flex-col gap-2 text-sm font-semibold"><select aria-label="상태" className="rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground" onChange={(event) => setState(event.currentTarget.value as SeriesState)} value={state}>{stateOptions.map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></label> : null}

View File

@@ -135,6 +135,9 @@ test("SeriesForm creates with required image request and navigates to list notif
fireEvent.change(screen.getByLabelText("제목"), { target: { value: "새 시리즈" } });
fireEvent.change(screen.getByLabelText("소개"), { target: { value: "새 시리즈 소개" } });
fireEvent.change(screen.getByLabelText("키워드"), { target: { value: "달빛" } });
fireEvent.keyDown(screen.getByLabelText("키워드"), { key: "Enter" });
fireEvent.change(screen.getByLabelText("키워드"), { target: { value: "상담" } });
fireEvent.keyDown(screen.getByLabelText("키워드"), { key: "," });
fireEvent.click(screen.getByRole("checkbox", { name: "월요일" }));
fireEvent.change(screen.getByLabelText("장르"), { target: { value: "77" } });
fireEvent.change(screen.getByLabelText("작가"), { target: { value: "작가" } });
@@ -149,7 +152,7 @@ test("SeriesForm creates with required image request and navigates to list notif
await waitFor(() => expect(requests.some((request) => request.method === "POST")).toBe(true));
const requestBody = requests.find((request) => request.method === "POST")?.body as FormData;
expect(requestBody.get("image")).toBeInstanceOf(File);
await expect((requestBody.get("request") as Blob).text()).resolves.toBe(JSON.stringify({ title: "새 시리즈", introduction: "새 시리즈 소개", publishedDaysOfWeek: ["MON"], keyword: "달빛", genreId: 77, isAdult: false, writer: "작가", studio: "스튜디오" }));
await expect((requestBody.get("request") as Blob).text()).resolves.toBe(JSON.stringify({ title: "새 시리즈", introduction: "새 시리즈 소개", publishedDaysOfWeek: ["MON"], keyword: "달빛,상담", genreId: 77, isAdult: false, writer: "작가", studio: "스튜디오" }));
expect(window.location.pathname).toBe("/ai-characters/101/series");
expect(window.history.state.successNotification).toBe("시리즈를 생성했습니다.");
});
@@ -292,7 +295,7 @@ test("SeriesForm links validation errors and focuses the first invalid control",
expect(daysError).toHaveAttribute("id", "series-days-error");
expect(titleInput).toHaveAttribute("aria-describedby", "series-title-error");
expect(screen.getByLabelText("소개")).toHaveAttribute("aria-describedby", "series-introduction-error");
expect(screen.getByLabelText("키워드")).toHaveAttribute("aria-describedby", "series-keyword-error");
expect(screen.getByLabelText("키워드").getAttribute("aria-describedby")?.split(" ")).toContain("series-keyword-error");
expect(screen.getByLabelText("장르")).toHaveAttribute("aria-describedby", "series-genre-error");
expect(screen.getByRole("checkbox", { name: "월요일" })).toHaveAttribute("aria-describedby", "series-days-error");
expect(titleInput).toHaveAttribute("aria-invalid", "true");

View File

@@ -0,0 +1,36 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { expect, test, vi } from "vitest";
import { TagInput } from "@/shared/ui/tag-input";
test("TagInput creates no chip from blank drafts", () => {
// Given
const onChange = vi.fn();
render(<TagInput error={undefined} errorId="keyword-error" label="키워드" onChange={onChange} value="" />);
// When
fireEvent.change(screen.getByLabelText("키워드"), { target: { value: " " } });
fireEvent.keyDown(screen.getByLabelText("키워드"), { key: "Enter" });
fireEvent.change(screen.getByLabelText("키워드"), { target: { value: "\t" } });
fireEvent.keyDown(screen.getByLabelText("키워드"), { key: "," });
// Then
expect(screen.queryByRole("button", { name: /삭제/ })).not.toBeInTheDocument();
expect(onChange).not.toHaveBeenCalled();
});
test("TagInput emits only the remaining comma-separated value when removing a committed chip", () => {
// Given
const onChange = vi.fn();
render(<TagInput error={undefined} errorId="keyword-error" label="키워드" onChange={onChange} value="달빛,상담" />);
const removeButton = screen.getByRole("button", { name: "키워드 달빛 삭제" });
// When
fireEvent.click(removeButton);
// Then
expect(removeButton.querySelector("svg[aria-hidden='true']")).toBeInTheDocument();
expect(removeButton).not.toHaveTextContent("삭제");
expect(onChange).toHaveBeenCalledOnce();
expect(onChange).toHaveBeenCalledWith("상담");
});

View File

@@ -1,13 +1,14 @@
import { useId, useState } from "react";
type AudioTagInputProps = {
export type TagInputProps = {
readonly error: string | undefined;
readonly errorId: string;
readonly label: string;
readonly onChange: (value: string) => void;
readonly value: string;
};
export function AudioTagInput({ error, errorId, onChange, value }: AudioTagInputProps) {
export function TagInput({ error, errorId, label, onChange, value }: TagInputProps) {
const [draft, setDraft] = useState("");
const inputId = useId();
const helpId = `${inputId}-help`;
@@ -37,17 +38,21 @@ export function AudioTagInput({ error, errorId, onChange, value }: AudioTagInput
return (
<div className="flex flex-col gap-2">
<label className="text-sm font-semibold" htmlFor={inputId}></label>
<label className="text-sm font-semibold" htmlFor={inputId}>{label}</label>
<div className={`flex min-h-11 flex-wrap items-center gap-2 rounded-md border bg-card px-2 py-1 text-foreground focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 ${error === undefined ? "border-input" : "border-destructive"}`}>
{tags.map((tag, index) => (
<span className="flex min-h-11 items-center rounded-md bg-muted pl-3 text-sm font-semibold" key={`${tag}-${index}`}>
{tag}
<button aria-label={`태그 ${tag} 삭제`} className="min-h-11 rounded-md px-3 text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" onClick={() => onChange(tags.filter((_, tagIndex) => tagIndex !== index).join(","))} type="button"></button>
<button aria-label={`${label} ${tag} 삭제`} className="inline-flex min-h-11 min-w-11 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" onClick={() => onChange(tags.filter((_, tagIndex) => tagIndex !== index).join(","))} type="button">
<svg aria-hidden="true" className="size-4" fill="none" viewBox="0 0 16 16">
<path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" strokeLinecap="round" strokeWidth="1.8" />
</svg>
</button>
</span>
))}
<input aria-describedby={`${helpId}${error === undefined ? "" : ` ${errorId}`}`} aria-invalid={error === undefined ? undefined : true} className="min-h-11 min-w-32 flex-1 bg-transparent px-2 text-base font-normal text-foreground outline-none" id={inputId} onChange={(event) => changeDraft(event.currentTarget.value)} onKeyDown={(event) => { if (event.key === "Enter" || event.key === ",") { event.preventDefault(); commitDraft(); } }} value={draft} />
</div>
<p className="text-sm text-muted-foreground" id={helpId}> Enter로 .</p>
<p className="text-sm text-muted-foreground" id={helpId}> Enter로 {label} .</p>
{error === undefined ? null : <p className="text-sm font-semibold text-destructive" id={errorId} role="alert">{error}</p>}
</div>
);