feat(ai-character): 시리즈 키워드 칩 입력 적용
This commit is contained in:
@@ -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>}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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");
|
||||
|
||||
36
src/shared/ui/__tests__/tag-input.test.tsx
Normal file
36
src/shared/ui/__tests__/tag-input.test.tsx
Normal 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("상담");
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
Reference in New Issue
Block a user