feat(ai-character): 시리즈 이미지 입력 순서 조정
This commit is contained in:
@@ -216,6 +216,7 @@ export function SeriesForm({ apiClient, characterId, createCropSource, genres, m
|
|||||||
<p className="text-sm text-muted-foreground">제목, 소개, 장르, 연재 요일과 이미지를 저장 전 운영 기준에 맞게 검토하세요.</p>
|
<p className="text-sm text-muted-foreground">제목, 소개, 장르, 연재 요일과 이미지를 저장 전 운영 기준에 맞게 검토하세요.</p>
|
||||||
</div>
|
</div>
|
||||||
<form aria-label={mode === "create" ? "시리즈 생성 입력 화면" : "시리즈 수정 입력 화면"} className="flex flex-col gap-4 rounded-lg border border-border bg-card p-4" onSubmit={(event) => void submit(event)} ref={formRef}>
|
<form aria-label={mode === "create" ? "시리즈 생성 입력 화면" : "시리즈 수정 입력 화면"} className="flex flex-col gap-4 rounded-lg border border-border bg-card p-4" onSubmit={(event) => void submit(event)} ref={formRef}>
|
||||||
|
<FileField accept="image/jpeg,image/png" acceptDescription="JPEG 또는 PNG, 10MB 이하, 210:297 crop 후 최대 폭 1,000px로 전송합니다." error={errors.image} label="시리즈 이미지" onChange={(file) => void selectImage(file)} value={image} />
|
||||||
<label className="flex flex-col gap-2 text-sm font-semibold">제목<input aria-describedby={errors.title === undefined ? undefined : errorIds.title} aria-invalid={errors.title === undefined ? undefined : true} className="rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground" onChange={(event) => setTitle(event.currentTarget.value)} value={title} /></label>
|
<label className="flex flex-col gap-2 text-sm font-semibold">제목<input aria-describedby={errors.title === undefined ? undefined : errorIds.title} aria-invalid={errors.title === undefined ? undefined : true} className="rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground" onChange={(event) => setTitle(event.currentTarget.value)} value={title} /></label>
|
||||||
{errors.title === undefined ? null : <p className="text-sm font-semibold text-destructive" id={errorIds.title} role="alert">{errors.title}</p>}
|
{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>
|
<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>
|
||||||
@@ -228,7 +229,6 @@ export function SeriesForm({ apiClient, characterId, createCropSource, genres, m
|
|||||||
<label className="flex items-center gap-2 text-sm font-semibold"><input checked={isAdult} onChange={(event) => setIsAdult(event.currentTarget.checked)} type="checkbox" />성인 콘텐츠</label>
|
<label className="flex items-center gap-2 text-sm font-semibold"><input checked={isAdult} onChange={(event) => setIsAdult(event.currentTarget.checked)} type="checkbox" />성인 콘텐츠</label>
|
||||||
<label className="flex flex-col gap-2 text-sm font-semibold">작가<input className="rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground" onChange={(event) => setWriter(event.currentTarget.value)} value={writer} /></label>
|
<label className="flex flex-col gap-2 text-sm font-semibold">작가<input className="rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground" onChange={(event) => setWriter(event.currentTarget.value)} value={writer} /></label>
|
||||||
<label className="flex flex-col gap-2 text-sm font-semibold">스튜디오<input className="rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground" onChange={(event) => setStudio(event.currentTarget.value)} value={studio} /></label>
|
<label className="flex flex-col gap-2 text-sm font-semibold">스튜디오<input className="rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground" onChange={(event) => setStudio(event.currentTarget.value)} value={studio} /></label>
|
||||||
<FileField accept="image/jpeg,image/png" acceptDescription="JPEG 또는 PNG, 10MB 이하, 210:297 crop 후 최대 폭 1,000px로 전송합니다." error={errors.image} label="시리즈 이미지" onChange={(file) => void selectImage(file)} value={image} />
|
|
||||||
{errors.form === undefined ? null : <p className="text-sm font-semibold text-destructive" role="alert">{errors.form}</p>}
|
{errors.form === undefined ? null : <p className="text-sm font-semibold text-destructive" role="alert">{errors.form}</p>}
|
||||||
{isSaving ? <p className="rounded-md border border-border bg-muted p-3 text-sm font-semibold" role="status">시리즈를 저장하는 중</p> : null}
|
{isSaving ? <p className="rounded-md border border-border bg-muted p-3 text-sm font-semibold" role="status">시리즈를 저장하는 중</p> : null}
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
|
|||||||
@@ -111,6 +111,20 @@ async function renderCrop(request: CropRenderRequest): Promise<File> {
|
|||||||
return new File([String(request.outputWidth)], "cropped-series.png", { type: "image/png" });
|
return new File([String(request.outputWidth)], "cropped-series.png", { type: "image/png" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test("SeriesForm renders the required image before the title when creating", () => {
|
||||||
|
// Given
|
||||||
|
const requests: CapturedRequest[] = [];
|
||||||
|
window.history.pushState({}, "", "/ai-characters/101/series/new");
|
||||||
|
render(<SeriesForm apiClient={createClient(requests)} characterId="101" createCropSource={createCropSource} genres={genres} mode="create" renderCrop={renderCrop} />);
|
||||||
|
|
||||||
|
// When
|
||||||
|
const imageInput = screen.getByLabelText("시리즈 이미지");
|
||||||
|
const titleInput = screen.getByLabelText("제목");
|
||||||
|
|
||||||
|
// Then
|
||||||
|
expect(imageInput.compareDocumentPosition(titleInput)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
|
||||||
|
});
|
||||||
|
|
||||||
test("SeriesForm creates with required image request and navigates to list notification", async () => {
|
test("SeriesForm creates with required image request and navigates to list notification", async () => {
|
||||||
// Given
|
// Given
|
||||||
const requests: CapturedRequest[] = [];
|
const requests: CapturedRequest[] = [];
|
||||||
@@ -261,6 +275,7 @@ test("SeriesForm links validation errors and focuses the first invalid control",
|
|||||||
render(<SeriesForm apiClient={createClient(requests)} characterId="101" createCropSource={createCropSource} genres={genres} mode="create" renderCrop={renderCrop} />);
|
render(<SeriesForm apiClient={createClient(requests)} characterId="101" createCropSource={createCropSource} genres={genres} mode="create" renderCrop={renderCrop} />);
|
||||||
|
|
||||||
// When
|
// When
|
||||||
|
const imageInput = screen.getByLabelText("시리즈 이미지");
|
||||||
const titleInput = screen.getByLabelText("제목");
|
const titleInput = screen.getByLabelText("제목");
|
||||||
fireEvent.submit(screen.getByRole("form", { name: "시리즈 생성 입력 화면" }));
|
fireEvent.submit(screen.getByRole("form", { name: "시리즈 생성 입력 화면" }));
|
||||||
|
|
||||||
@@ -281,6 +296,6 @@ test("SeriesForm links validation errors and focuses the first invalid control",
|
|||||||
expect(screen.getByLabelText("장르")).toHaveAttribute("aria-describedby", "series-genre-error");
|
expect(screen.getByLabelText("장르")).toHaveAttribute("aria-describedby", "series-genre-error");
|
||||||
expect(screen.getByRole("checkbox", { name: "월요일" })).toHaveAttribute("aria-describedby", "series-days-error");
|
expect(screen.getByRole("checkbox", { name: "월요일" })).toHaveAttribute("aria-describedby", "series-days-error");
|
||||||
expect(titleInput).toHaveAttribute("aria-invalid", "true");
|
expect(titleInput).toHaveAttribute("aria-invalid", "true");
|
||||||
await waitFor(() => expect(titleInput).toHaveFocus());
|
await waitFor(() => expect(imageInput).toHaveFocus());
|
||||||
expect(requests.filter((request) => request.method === "POST")).toHaveLength(0);
|
expect(requests.filter((request) => request.method === "POST")).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -53,3 +53,16 @@ test("FileField gives same visible file buttons field-specific accessible names"
|
|||||||
expect(screen.getByRole("button", { name: "오디오 파일 파일 선택" })).toBeInTheDocument();
|
expect(screen.getByRole("button", { name: "오디오 파일 파일 선택" })).toBeInTheDocument();
|
||||||
expect(screen.getByRole("button", { name: "커버 이미지 파일 선택" })).toBeInTheDocument();
|
expect(screen.getByRole("button", { name: "커버 이미지 파일 선택" })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("FileField visible container exposes the focus-within ring contract", () => {
|
||||||
|
// Given
|
||||||
|
const { container } = render(<FileField accept="image/png" acceptDescription="PNG" label="대표 이미지" onChange={vi.fn()} value={null} />);
|
||||||
|
const input = screen.getByLabelText("대표 이미지");
|
||||||
|
|
||||||
|
// When
|
||||||
|
input.focus();
|
||||||
|
|
||||||
|
// Then
|
||||||
|
expect(input).toHaveFocus();
|
||||||
|
expect(container.firstElementChild).toHaveClass("focus-within:ring-2", "focus-within:ring-ring", "focus-within:ring-offset-2");
|
||||||
|
});
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export function FileField({ accept, acceptDescription, description, error, label
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2 rounded-lg border border-border bg-card p-4">
|
<div className="flex flex-col gap-2 rounded-lg border border-border bg-card p-4 focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2">
|
||||||
<label className="text-sm font-semibold" htmlFor={inputId}>{label}</label>
|
<label className="text-sm font-semibold" htmlFor={inputId}>{label}</label>
|
||||||
{description === undefined ? null : <p className="text-sm text-muted-foreground" id={descriptionId}>{description}</p>}
|
{description === undefined ? null : <p className="text-sm text-muted-foreground" id={descriptionId}>{description}</p>}
|
||||||
<p className="text-sm text-muted-foreground" id={acceptId}>{acceptDescription}</p>
|
<p className="text-sm text-muted-foreground" id={acceptId}>{acceptDescription}</p>
|
||||||
|
|||||||
Reference in New Issue
Block a user