feat(ai-character): 커뮤니티 생성 미디어 흐름 개선
This commit is contained in:
@@ -29,7 +29,7 @@ test("renders the protected admin shell for an existing ADMIN session", async ()
|
||||
expect(await screen.findByRole("heading", { name: "AI 캐릭터" })).toBeInTheDocument();
|
||||
expect(requests[0]?.url).toContain("size=20");
|
||||
expect(screen.getByRole("link", { name: "본문으로 건너뛰기" })).toHaveAttribute("href", "#app-main");
|
||||
expect(screen.getByRole("banner")).toBeInTheDocument();
|
||||
expect(screen.getByRole("banner")).toHaveClass("flex-wrap", "py-2");
|
||||
expect(screen.getByRole("button", { name: "모바일 메뉴 열기" })).toHaveClass("whitespace-nowrap");
|
||||
expect(screen.getByRole("navigation", { name: "브레드크럼" })).toHaveClass("whitespace-nowrap");
|
||||
expect(screen.getByRole("button", { name: "로그아웃" })).toHaveClass("whitespace-nowrap");
|
||||
|
||||
@@ -167,7 +167,7 @@ export function ProtectedAdminShell({ apiClient, apiMode, routeError }: { readon
|
||||
</nav>
|
||||
</aside>
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<header className="flex min-h-14 items-center justify-between gap-3 border-b border-border bg-card px-4" role="banner">
|
||||
<header className="flex min-h-14 flex-wrap items-center justify-between gap-3 border-b border-border bg-card px-4 py-2" role="banner">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<button
|
||||
aria-expanded={isMobileMenuOpen}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { routePaths } from "@/app/route-paths";
|
||||
import { createAudioContentCreateBody, deactivateAudioContent, updateAudioContent } from "@/features/audio-contents/api/audio-content-api";
|
||||
import { uploadAudioContent } from "@/features/audio-contents/api/upload-audio-content";
|
||||
import type { UploadAudioContentOptions, UploadAuthDependencies } from "@/features/audio-contents/api/upload-audio-content";
|
||||
import { audioErrorMessage, coverErrorMessage, createInitialPrice, defaultAudioContentCreateSettings, formatPrice, isFutureLocalDateTime, parsePrice, toCreateRequest, toUpdateRequest } from "@/features/audio-contents/components/audio-content-form-helpers";
|
||||
import { audioErrorMessage, coverErrorMessage, createInitialPrice, defaultAudioContentCreateSettings, 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 { AudioContentThemeSelect } from "@/features/audio-contents/components/AudioContentThemeSelect";
|
||||
@@ -21,6 +21,7 @@ import type { CropRenderRequest } from "@/shared/lib/crop-image";
|
||||
import { focusFirstInvalidControl } from "@/shared/lib/focus-first-invalid-control";
|
||||
import { formatSeoulDateTime } from "@/shared/lib/formatters";
|
||||
import { ConfirmDeactivateDialog } from "@/shared/ui/confirm-deactivate-dialog";
|
||||
import { CanPriceField } from "@/shared/ui/can-price-field";
|
||||
import { FileField } from "@/shared/ui/file-field";
|
||||
import { ImageCropDialog } from "@/shared/ui/image-crop-dialog";
|
||||
import type { CropSourceImage } from "@/shared/ui/image-crop-dialog";
|
||||
@@ -80,9 +81,8 @@ export function AudioContentForm({ apiClient, audio, characterId, createCropSour
|
||||
useEffect(() => () => cropSource?.release?.(), [cropSource]);
|
||||
|
||||
function changePrice(value: string) {
|
||||
const nextPrice = formatPrice(value);
|
||||
setPrice(nextPrice);
|
||||
if (mode === "create" && nextPrice === "0") {
|
||||
setPrice(value);
|
||||
if (mode === "create" && parsePrice(value) === 0) {
|
||||
setCreateSettings((current) => ({ ...current, purchaseOption: "BOTH", isGeneratePreview: false, isPointAvailable: false, previewStartTime: null, previewEndTime: null }));
|
||||
}
|
||||
}
|
||||
@@ -236,8 +236,7 @@ export function AudioContentForm({ apiClient, audio, characterId, createCropSour
|
||||
{errors.detail === undefined ? null : <p className="text-sm font-semibold text-destructive" id={errorIds.detail} role="alert">{errors.detail}</p>}
|
||||
<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>}
|
||||
<CanPriceField error={errors.price} errorId={errorIds.price} onChange={changePrice} value={price} />
|
||||
{mode === "create" ? <AudioContentCreateOptions isPaid={isPaid} onChange={setCreateSettings} value={createSettings} /> : null}
|
||||
{mode === "create" ? <ReleaseScheduleField error={errors.releaseDate} errorId={errorIds.releaseDate} onChange={setReleaseSchedule} value={releaseSchedule} /> : <p className="rounded-lg border border-border bg-muted p-3 text-sm font-semibold text-muted-foreground">공개일: {audio?.releaseDate == null ? "즉시 공개" : formatSeoulDateTime(audio.releaseDate)} (수정 불가)</p>}
|
||||
<p className="rounded-lg border border-border bg-muted p-3 text-sm font-semibold text-muted-foreground">시리즈: 현재 수정 화면에서는 변경할 수 없습니다.</p>
|
||||
|
||||
@@ -13,14 +13,6 @@ export function parsePrice(value: string): number | null {
|
||||
return parseCanPriceInput(value);
|
||||
}
|
||||
|
||||
export function formatPrice(value: string): string {
|
||||
if (value.startsWith("-")) {
|
||||
return "0";
|
||||
}
|
||||
|
||||
return value.replace(/\D/g, "");
|
||||
}
|
||||
|
||||
export const selectionCardClassName = "flex min-h-11 cursor-pointer items-center gap-3 rounded-md border border-border bg-card px-3 py-2 text-sm font-semibold text-foreground hover:bg-accent hover:text-accent-foreground focus-within:outline-none focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 has-[:checked]:border-input has-[:checked]:bg-accent has-[:checked]:text-accent-foreground";
|
||||
|
||||
const localDateTimePattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/;
|
||||
|
||||
@@ -67,10 +67,10 @@ test("AudioContentFormPage commits tag chips with Enter and comma without submit
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ value: "-1", expected: "0" },
|
||||
{ value: "1.5", expected: "15" },
|
||||
{ value: "100000", expected: "100000" },
|
||||
])("AudioContentFormPage keeps only digits in the price input for $value", async ({ value, expected }) => {
|
||||
"-1",
|
||||
"1.5",
|
||||
"100000",
|
||||
])("AudioContentFormPage preserves the raw price input %s", async (value) => {
|
||||
// Given
|
||||
renderCreateForm();
|
||||
const priceInput = await screen.findByLabelText("가격");
|
||||
@@ -83,7 +83,7 @@ test.each([
|
||||
fireEvent.change(priceInput, { target: { value } });
|
||||
|
||||
// Then
|
||||
expect(priceInput).toHaveValue(Number(expected));
|
||||
expect(priceInput).toHaveValue(Number(value));
|
||||
});
|
||||
|
||||
test("AudioContentFormPage shows paid options only for a positive price", async () => {
|
||||
@@ -110,7 +110,7 @@ test("AudioContentFormPage shows paid options only for a positive price", async
|
||||
expect(screen.getByLabelText("포인트 사용")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("AudioContentFormPage resets paid settings and retains removed-control defaults when price returns to zero", async () => {
|
||||
test("AudioContentFormPage resets paid settings and retains removed-control defaults when price is numeric zero", async () => {
|
||||
// Given
|
||||
const uploadedBodies: XMLHttpRequestBodyInit[] = [];
|
||||
renderCreateForm(uploadedBodies);
|
||||
@@ -124,7 +124,7 @@ test("AudioContentFormPage resets paid settings and retains removed-control defa
|
||||
fireEvent.change(screen.getByLabelText("미리듣기 종료"), { target: { value: "01:00:05" } });
|
||||
|
||||
// When
|
||||
fireEvent.change(priceInput, { target: { value: "0" } });
|
||||
fireEvent.change(priceInput, { target: { value: "00" } });
|
||||
|
||||
// Then
|
||||
expect(screen.queryByRole("group", { name: "구매 옵션" })).not.toBeInTheDocument();
|
||||
|
||||
@@ -26,6 +26,38 @@ function createInactiveFormClient(requests: CapturedRequest[]): ApiClient {
|
||||
};
|
||||
}
|
||||
|
||||
test.each(["-1", "1.5", "100000"])("AudioContentFormPage rejects invalid edit price %s before update", async (value) => {
|
||||
// Given
|
||||
const requests: CapturedRequest[] = [];
|
||||
window.history.pushState({}, "", "/ai-characters/101/audio-contents/9001/edit");
|
||||
render(<AudioContentFormPage apiClient={createFormClient(requests)} characterId="101" contentId="9001" />);
|
||||
|
||||
// When
|
||||
const priceInput = await screen.findByLabelText("가격");
|
||||
fireEvent.change(priceInput, { target: { value } });
|
||||
fireEvent.submit(screen.getByRole("form", { name: "오디오 콘텐츠 수정 입력 화면" }));
|
||||
|
||||
// Then
|
||||
expect(priceInput).toHaveValue(Number(value));
|
||||
expect(await screen.findByText("가격은 0 이상 99,999 이하 정수 캔으로 입력하세요.")).toBeInTheDocument();
|
||||
expect(requests.filter((request) => request.method === "PUT")).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("AudioContentFormPage allows the maximum edit price", async () => {
|
||||
// Given
|
||||
const requests: CapturedRequest[] = [];
|
||||
window.history.pushState({}, "", "/ai-characters/101/audio-contents/9001/edit");
|
||||
render(<AudioContentFormPage apiClient={createFormClient(requests)} characterId="101" contentId="9001" />);
|
||||
|
||||
// When
|
||||
fireEvent.change(await screen.findByLabelText("가격"), { target: { value: "99999" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "저장" }));
|
||||
|
||||
// Then
|
||||
await waitFor(() => expect(requests.filter((request) => request.method === "PUT")).toHaveLength(1));
|
||||
expect(await readJsonPart(requireFormData(requests.at(-1)?.body).get("request"))).toMatchObject({ price: 99999 });
|
||||
});
|
||||
|
||||
test("AudioContentFormPage update omits unsupported controls and soft delete navigates to the audio list", async () => {
|
||||
// Given
|
||||
const requests: CapturedRequest[] = [];
|
||||
|
||||
@@ -161,9 +161,10 @@ test("AudioContentFormPage serializes supported create settings with removed-con
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ label: "음수 기호", value: "-1", expected: "0" },
|
||||
{ label: "소수점", value: "1.5", expected: "15" },
|
||||
])("AudioContentFormPage removes non-digits from price input for %s", async ({ label, value, expected }) => {
|
||||
"-1",
|
||||
"1.5",
|
||||
"100000",
|
||||
])("AudioContentFormPage preserves and rejects invalid price input %s before upload", async (value) => {
|
||||
// Given
|
||||
const requests: CapturedRequest[] = [];
|
||||
const uploadedBodies: XMLHttpRequestBodyInit[] = [];
|
||||
@@ -171,17 +172,21 @@ test.each([
|
||||
render(<AudioContentFormPage apiClient={createFormClient(requests)} characterId="101" createCropSource={(file) => Promise.resolve({ file, height: 800, previewUrl: "blob:cover", width: 800 })} renderCrop={(request) => Promise.resolve(request.file)} uploadAudioContentRequest={createSuccessfulUpload(uploadedBodies)} />);
|
||||
|
||||
// When
|
||||
fireEvent.change(await screen.findByLabelText("제목"), { target: { value: label } });
|
||||
fireEvent.change(await screen.findByLabelText("제목"), { 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: "7" } });
|
||||
fireEvent.change(screen.getByLabelText("오디오 파일"), { target: { files: [fileWithSize("voice.mp3", "audio/mpeg", 10)] } });
|
||||
fireEvent.change(screen.getByLabelText("커버 이미지"), { target: { files: [new File(["cover"], "cover.png", { type: "image/png" })] } });
|
||||
fireEvent.click(await screen.findByRole("button", { name: "적용" }));
|
||||
await waitFor(() => expect(screen.getByText("cover.png")).toBeInTheDocument());
|
||||
fireEvent.change(screen.getByLabelText("가격"), { target: { value } });
|
||||
fireEvent.submit(screen.getByRole("form", { name: "오디오 콘텐츠 생성 입력 화면" }));
|
||||
|
||||
// Then
|
||||
expect(screen.getByLabelText("가격")).toHaveValue(Number(expected));
|
||||
expect(screen.getByLabelText("가격")).toHaveValue(Number(value));
|
||||
expect(await screen.findByText("가격은 0 이상 99,999 이하 정수 캔으로 입력하세요.")).toBeInTheDocument();
|
||||
expect(uploadedBodies).toHaveLength(0);
|
||||
});
|
||||
|
||||
@@ -237,32 +242,6 @@ test("AudioContentFormPage validates audio boundary and serializes scheduled Asi
|
||||
expect(request).not.toHaveProperty("timezone");
|
||||
});
|
||||
|
||||
test("AudioContentFormPage blocks prices outside the CAN range before upload", async () => {
|
||||
// Given
|
||||
const requests: CapturedRequest[] = [];
|
||||
const uploadedBodies: XMLHttpRequestBodyInit[] = [];
|
||||
window.history.pushState({}, "", "/ai-characters/101/audio-contents/new");
|
||||
render(<AudioContentFormPage apiClient={createFormClient(requests)} characterId="101" createCropSource={(file) => Promise.resolve({ file, height: 800, previewUrl: "blob:cover", width: 800 })} renderCrop={(request) => Promise.resolve(request.file)} uploadAudioContentRequest={createSuccessfulUpload(uploadedBodies)} />);
|
||||
|
||||
// When
|
||||
fireEvent.change(await screen.findByLabelText("제목"), { 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: "7" } });
|
||||
fireEvent.change(screen.getByLabelText("오디오 파일"), { target: { files: [fileWithSize("voice.mp3", "audio/mpeg", 10)] } });
|
||||
fireEvent.change(screen.getByLabelText("커버 이미지"), { target: { files: [new File(["cover"], "cover.png", { type: "image/png" })] } });
|
||||
fireEvent.click(await screen.findByRole("button", { name: "적용" }));
|
||||
await waitFor(() => expect(screen.getByText("cover.png")).toBeInTheDocument());
|
||||
fireEvent.change(screen.getByLabelText("가격"), { target: { value: "100000" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "생성" }));
|
||||
|
||||
// Then
|
||||
expect(screen.getByLabelText("가격")).toHaveValue(100000);
|
||||
expect(await screen.findByText("가격은 0 이상 99,999 이하 정수 캔으로 입력하세요.")).toBeInTheDocument();
|
||||
expect(uploadedBodies).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("AudioContentFormPage links validation errors and focuses the first invalid control", async () => {
|
||||
// Given
|
||||
const requests: CapturedRequest[] = [];
|
||||
@@ -288,7 +267,7 @@ test("AudioContentFormPage links validation errors and focuses the first invalid
|
||||
expect(screen.getByLabelText("상세 설명")).toHaveAttribute("aria-describedby", "audio-content-detail-error");
|
||||
expect(screen.getByLabelText("태그").getAttribute("aria-describedby")?.split(" ")).toContain("audio-content-tags-error");
|
||||
expect(screen.getByLabelText("태그")).toHaveAccessibleDescription("쉼표 또는 Enter로 태그를 추가하세요. 태그를 입력하세요.");
|
||||
expect(screen.getByLabelText("가격")).not.toHaveAttribute("aria-describedby");
|
||||
expect(screen.getByLabelText("가격")).toHaveAccessibleDescription("단위: 캔");
|
||||
expect(screen.getByLabelText("오디오 테마")).toHaveAttribute("aria-describedby", "audio-content-theme-error");
|
||||
expect(titleInput).toHaveAttribute("aria-invalid", "true");
|
||||
expect(coverInput).toHaveAttribute("aria-invalid", "true");
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { createCommunityPost } from "@/features/community-posts/api/community-post-api";
|
||||
import { communityPostAudioErrorMessage, formatCommunityPostPrice, hasCommunityPostFormErrors, parseCommunityPostPrice } from "@/features/community-posts/components/community-post-form-helpers";
|
||||
import { communityPostAudioErrorMessage, hasCommunityPostFormErrors, parseCommunityPostPrice } from "@/features/community-posts/components/community-post-form-helpers";
|
||||
import type { CommunityPostFormErrors } from "@/features/community-posts/components/community-post-form-helpers";
|
||||
import { COMMUNITY_POST_IMAGE_POLICY, prepareCommunityPostImage } from "@/features/community-posts/validation/community-post-media-policy";
|
||||
import { ApiError } from "@/shared/api/api-error";
|
||||
import type { ApiClient } from "@/shared/api/client";
|
||||
import type { CropRenderRequest } from "@/shared/lib/crop-image";
|
||||
import { focusFirstInvalidControl } from "@/shared/lib/focus-first-invalid-control";
|
||||
import { CanPriceField } from "@/shared/ui/can-price-field";
|
||||
import { FileField } from "@/shared/ui/file-field";
|
||||
import { ImageCropDialog } from "@/shared/ui/image-crop-dialog";
|
||||
import type { CropSourceImage } from "@/shared/ui/image-crop-dialog";
|
||||
@@ -27,12 +28,6 @@ const errorIds = {
|
||||
price: "community-post-price-error",
|
||||
} as const;
|
||||
|
||||
function buildCreateRequest(content: string, isAdult: boolean, isCommentAvailable: boolean, price: number | null) {
|
||||
const base = { content, isAdult, isCommentAvailable };
|
||||
|
||||
return price === null ? base : { ...base, price };
|
||||
}
|
||||
|
||||
export function CommunityPostForm({ apiClient, characterId, createCropSource, onCreated, renderCrop }: CommunityPostFormProps) {
|
||||
const [audioFile, setAudioFile] = useState<File | null>(null);
|
||||
const [content, setContent] = useState("");
|
||||
@@ -43,7 +38,7 @@ export function CommunityPostForm({ apiClient, characterId, createCropSource, on
|
||||
const [isImagePreparing, setIsImagePreparing] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [postImage, setPostImage] = useState<File | null>(null);
|
||||
const [price, setPrice] = useState("");
|
||||
const [price, setPrice] = useState("0");
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const imageSelectionId = useRef(0);
|
||||
const isSavingRef = useRef(false);
|
||||
@@ -53,9 +48,11 @@ export function CommunityPostForm({ apiClient, characterId, createCropSource, on
|
||||
async function selectPostImage(file: File | null) {
|
||||
imageSelectionId.current += 1;
|
||||
const currentSelectionId = imageSelectionId.current;
|
||||
setAudioFile(null);
|
||||
setErrors((current) => ({ ...current, audio: undefined }));
|
||||
setPostImage(null);
|
||||
setCropSource(null);
|
||||
if (file === null) {
|
||||
setPostImage(null);
|
||||
setCropSource(null);
|
||||
setIsImagePreparing(false);
|
||||
setErrors((current) => ({ ...current, image: undefined }));
|
||||
return;
|
||||
@@ -110,13 +107,12 @@ export function CommunityPostForm({ apiClient, characterId, createCropSource, on
|
||||
|
||||
function validateForm(): CommunityPostFormErrors {
|
||||
const parsedPrice = parseCommunityPostPrice(price);
|
||||
const hasPriceInput = price.trim().length > 0;
|
||||
|
||||
return {
|
||||
audio: communityPostAudioErrorMessage(audioFile),
|
||||
content: content.trim().length === 0 ? "내용을 입력하세요." : undefined,
|
||||
image: isImagePreparing || cropSource !== null ? "이미지 처리가 끝난 뒤 저장하세요." : errors.image,
|
||||
price: (hasPriceInput && parsedPrice === null) || (parsedPrice !== null && (!Number.isInteger(parsedPrice) || parsedPrice < 0 || parsedPrice > CAN_PRICE_MAX)) ? "가격은 0 이상 99,999 이하 정수 캔으로 입력하세요." : undefined,
|
||||
price: parsedPrice === null || !Number.isInteger(parsedPrice) || parsedPrice < 0 || parsedPrice > CAN_PRICE_MAX ? "가격은 0 이상 99,999 이하 정수 캔으로 입력하세요." : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -131,6 +127,10 @@ export function CommunityPostForm({ apiClient, characterId, createCropSource, on
|
||||
queueMicrotask(() => focusFirstInvalidControl(formRef.current));
|
||||
return;
|
||||
}
|
||||
const parsedPrice = parseCommunityPostPrice(price);
|
||||
if (parsedPrice === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
isSavingRef.current = true;
|
||||
setIsSaving(true);
|
||||
@@ -138,7 +138,7 @@ export function CommunityPostForm({ apiClient, characterId, createCropSource, on
|
||||
await createCommunityPost(apiClient, characterId, {
|
||||
audioFile: audioFile ?? undefined,
|
||||
postImage: postImage ?? undefined,
|
||||
request: buildCreateRequest(content.trim(), isAdult, isCommentAvailable, parseCommunityPostPrice(price)),
|
||||
request: { content: content.trim(), isAdult, isCommentAvailable, price: parsedPrice },
|
||||
});
|
||||
onCreated();
|
||||
} catch (error: unknown) {
|
||||
@@ -154,17 +154,16 @@ export function CommunityPostForm({ apiClient, characterId, createCropSource, on
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-xs font-semibold text-info">COMMUNITY</p>
|
||||
<h2 className="text-2xl font-bold leading-tight" id="community-post-form-title">커뮤니티 게시글 생성</h2>
|
||||
<p className="text-sm text-muted-foreground">내용과 첨부 파일은 저장 전 운영 기준에 맞게 검토하세요. GIF는 crop 없이 원본을 보존합니다.</p>
|
||||
<p className="break-keep text-sm text-muted-foreground">내용과 첨부 파일은 저장 전 운영 기준에 맞게 검토하세요. GIF는 crop 없이 원본을 보존합니다.</p>
|
||||
</div>
|
||||
<form aria-label="커뮤니티 게시글 생성 입력 화면" 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,image/gif" acceptDescription="JPEG 또는 PNG는 자유 ratio crop 후 최대 800px로 전송합니다. GIF는 원본 width 800px 이하만 crop 없이 전송합니다." error={errors.image} label="게시글 이미지" onChange={(file) => void selectPostImage(file)} value={postImage} />
|
||||
{postImage === null ? null : <FileField accept={AUDIO_FILE_POLICY.allowedMimeTypes.join(",")} acceptDescription="MP3, AAC, M4A, 최대 1,024,000,000 bytes. WAV는 지원하지 않습니다." error={errors.audio} label="오디오 파일" onChange={setAudioFile} value={audioFile} />}
|
||||
<label className="flex flex-col gap-2 text-sm font-semibold">내용<textarea aria-describedby={errors.content === undefined ? undefined : errorIds.content} aria-invalid={errors.content === undefined ? undefined : true} className="min-h-32 rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground" onChange={(event) => setContent(event.currentTarget.value)} value={content} /></label>
|
||||
{errors.content === undefined ? null : <p className="text-sm font-semibold text-destructive" id={errorIds.content} role="alert">{errors.content}</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" onChange={(event) => setPrice(formatCommunityPostPrice(event.currentTarget.value))} value={price} /></label>
|
||||
{errors.price === undefined ? null : <p className="text-sm font-semibold text-destructive" id={errorIds.price} role="alert">{errors.price}</p>}
|
||||
<CanPriceField error={errors.price} errorId={errorIds.price} onChange={setPrice} value={price} />
|
||||
<label className="flex items-center gap-2 text-sm font-semibold"><input checked={isCommentAvailable} onChange={(event) => setIsCommentAvailable(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>
|
||||
<FileField accept={AUDIO_FILE_POLICY.allowedMimeTypes.join(",")} acceptDescription="MP3, AAC, M4A, 최대 1,024,000,000 bytes. WAV는 지원하지 않습니다." error={errors.audio} label="오디오 파일" onChange={setAudioFile} value={audioFile} />
|
||||
<FileField accept="image/jpeg,image/png,image/gif" acceptDescription="JPEG 또는 PNG는 자유 ratio crop 후 최대 800px로 전송합니다. GIF는 원본 width 800px 이하만 crop 없이 전송합니다." error={errors.image} label="게시글 이미지" onChange={(file) => void selectPostImage(file)} value={postImage} />
|
||||
{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}
|
||||
<div className="flex justify-end gap-2">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { validateAudioFile } from "@/shared/validation/audio-file-policy";
|
||||
import { formatCanPriceInput, parseCanPriceInput } from "@/shared/validation/can-price";
|
||||
import { parseCanPriceInput } from "@/shared/validation/can-price";
|
||||
|
||||
export type CommunityPostFormErrors = {
|
||||
readonly audio?: string;
|
||||
@@ -13,10 +13,6 @@ export function parseCommunityPostPrice(value: string): number | null {
|
||||
return parseCanPriceInput(value);
|
||||
}
|
||||
|
||||
export function formatCommunityPostPrice(value: string): string {
|
||||
return formatCanPriceInput(value);
|
||||
}
|
||||
|
||||
export function communityPostAudioErrorMessage(file: File | null): string | undefined {
|
||||
if (file === null) {
|
||||
return undefined;
|
||||
|
||||
@@ -68,6 +68,7 @@ describe("Community post contract", () => {
|
||||
// Then
|
||||
expect(requests).toEqual([{ path: "/api/v2/admin/ai-characters/101/community-posts", method: "POST", body: requests[0]?.body }]);
|
||||
const body = requireFormData(requests[0]?.body);
|
||||
expect([...body.keys()]).toEqual(["audioFile", "postImage", "request"]);
|
||||
expect(body.get("audioFile")).toBe(audioFile);
|
||||
expect(body.get("postImage")).toBe(postImage);
|
||||
const request = await readJsonPart(body.get("request"));
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||
|
||||
import { App } from "@/app/App";
|
||||
import { apiBaseUrl, saveAdminSession, useAiCharacterDetailResponse, useAiCharactersResponse } from "@/app/app-test-support";
|
||||
@@ -71,6 +71,18 @@ function createCropSource(file: File): Promise<CropSourceImage> {
|
||||
return Promise.resolve({ file, height: 600, previewUrl: "blob:gif", width: 800 });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
class TestUrl extends URL {
|
||||
static createObjectURL(blob: Blob): string {
|
||||
return `blob:community-${blob.size}`;
|
||||
}
|
||||
|
||||
static revokeObjectURL(): void {}
|
||||
}
|
||||
|
||||
vi.stubGlobal("URL", TestUrl);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
window.history.replaceState({}, "", "/");
|
||||
@@ -91,6 +103,7 @@ test("Community create route opens from the existing list workspace", async () =
|
||||
|
||||
// Then
|
||||
expect(await screen.findByRole("heading", { name: "커뮤니티 게시글 생성" })).toBeInTheDocument();
|
||||
expect(screen.getByText("내용과 첨부 파일은 저장 전 운영 기준에 맞게 검토하세요. GIF는 crop 없이 원본을 보존합니다.")).toHaveClass("break-keep");
|
||||
expect(window.location.pathname).toBe("/ai-characters/101/community-posts/new");
|
||||
});
|
||||
|
||||
@@ -138,6 +151,8 @@ test("Community create form validates content audio policy and GIF width before
|
||||
|
||||
// When
|
||||
fireEvent.change(screen.getByLabelText("내용"), { target: { value: "GIF와 오디오가 있는 게시글" } });
|
||||
fireEvent.change(screen.getByLabelText("게시글 이미지"), { target: { files: [animatedGif] } });
|
||||
await screen.findByLabelText("오디오 파일");
|
||||
fireEvent.change(screen.getByLabelText("오디오 파일"), { target: { files: [fileWithSize("voice.wav", "audio/wav", 10)] } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "생성" }));
|
||||
|
||||
@@ -145,7 +160,6 @@ test("Community create form validates content audio policy and GIF width before
|
||||
expect(await screen.findByText("MP3, AAC, M4A 파일만 업로드하세요.")).toBeInTheDocument();
|
||||
|
||||
// When
|
||||
fireEvent.change(screen.getByLabelText("오디오 파일"), { target: { files: [audioFile] } });
|
||||
fireEvent.change(screen.getByLabelText("게시글 이미지"), { target: { files: [new File(["gif"], "big.gif", { type: "image/gif" })] } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "생성" }));
|
||||
|
||||
@@ -155,6 +169,9 @@ test("Community create form validates content audio policy and GIF width before
|
||||
|
||||
// When
|
||||
fireEvent.change(screen.getByLabelText("게시글 이미지"), { target: { files: [animatedGif] } });
|
||||
const revealedAudioInput = await screen.findByLabelText("오디오 파일");
|
||||
expect(screen.queryByText("MP3, AAC, M4A 파일만 업로드하세요.")).not.toBeInTheDocument();
|
||||
fireEvent.change(revealedAudioInput, { target: { files: [audioFile] } });
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: "생성" })).not.toBeDisabled());
|
||||
fireEvent.click(screen.getByRole("button", { name: "생성" }));
|
||||
|
||||
@@ -164,7 +181,98 @@ test("Community create form validates content audio policy and GIF width before
|
||||
const body = requireFormData(requests.at(-1)?.body);
|
||||
expect(body.get("audioFile")).toBe(audioFile);
|
||||
expect(body.get("postImage")).toBe(animatedGif);
|
||||
expect(await readJsonPart(body.get("request"))).toEqual({ content: "GIF와 오디오가 있는 게시글", isAdult: false, isCommentAvailable: true });
|
||||
expect(await readJsonPart(body.get("request"))).toEqual({ content: "GIF와 오디오가 있는 게시글", isAdult: false, isCommentAvailable: true, price: 0 });
|
||||
});
|
||||
|
||||
test("Community create form renders final image preview first and reveals audio before content", async () => {
|
||||
// Given
|
||||
window.history.pushState({}, "", "/ai-characters/101/community-posts/new");
|
||||
render(<CommunityPostFormPage apiClient={createCommunityFormClient([])} characterId="101" createCropSource={createCropSource} />);
|
||||
const form = await screen.findByRole("form", { name: "커뮤니티 게시글 생성 입력 화면" });
|
||||
if (!(form instanceof HTMLFormElement)) {
|
||||
throw new Error("expected community create form");
|
||||
}
|
||||
const imageInput = screen.getByLabelText("게시글 이미지");
|
||||
const contentInput = screen.getByLabelText("내용");
|
||||
|
||||
// Then
|
||||
expect(screen.queryByLabelText("오디오 파일")).not.toBeInTheDocument();
|
||||
expect(Array.from(form.elements).indexOf(imageInput)).toBeLessThan(Array.from(form.elements).indexOf(contentInput));
|
||||
|
||||
// When
|
||||
fireEvent.change(imageInput, { target: { files: [new File(["gif"], "post.gif", { type: "image/gif" })] } });
|
||||
|
||||
// Then
|
||||
const preview = await screen.findByRole("img", { name: "게시글 이미지 업로드 미리보기" });
|
||||
const audioInput = screen.getByLabelText("오디오 파일");
|
||||
expect(preview).toHaveAttribute("src", "blob:community-3");
|
||||
expect(Array.from(form.elements).indexOf(imageInput)).toBeLessThan(Array.from(form.elements).indexOf(audioInput));
|
||||
expect(Array.from(form.elements).indexOf(audioInput)).toBeLessThan(Array.from(form.elements).indexOf(contentInput));
|
||||
});
|
||||
|
||||
test("Community create form clears final image and audio as soon as image replacement starts", async () => {
|
||||
// Given
|
||||
let resolveReplacement: (source: CropSourceImage) => void = () => undefined;
|
||||
const replacementReady = new Promise<CropSourceImage>((resolve) => {
|
||||
resolveReplacement = resolve;
|
||||
});
|
||||
const createReplacementCropSource = (file: File) => file.name === "replacement.png" ? replacementReady : createCropSource(file);
|
||||
window.history.pushState({}, "", "/ai-characters/101/community-posts/new");
|
||||
render(<CommunityPostFormPage apiClient={createCommunityFormClient([])} characterId="101" createCropSource={createReplacementCropSource} />);
|
||||
fireEvent.change(await screen.findByLabelText("게시글 이미지"), { target: { files: [new File(["gif"], "ready.gif", { type: "image/gif" })] } });
|
||||
const audioInput = await screen.findByLabelText("오디오 파일");
|
||||
fireEvent.change(audioInput, { target: { files: [new File(["audio"], "voice.m4a", { type: "audio/x-m4a" })] } });
|
||||
|
||||
// When
|
||||
fireEvent.change(screen.getByLabelText("게시글 이미지"), { target: { files: [new File(["image"], "replacement.png", { type: "image/png" })] } });
|
||||
|
||||
// Then
|
||||
expect(screen.queryByLabelText("오디오 파일")).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("img", { name: "게시글 이미지 업로드 미리보기" })).not.toBeInTheDocument();
|
||||
await act(async () => {
|
||||
resolveReplacement({ file: new File(["image"], "replacement.png", { type: "image/png" }), height: 500, previewUrl: "blob:replacement", width: 1000 });
|
||||
await replacementReady;
|
||||
});
|
||||
expect(await screen.findByRole("dialog", { name: "이미지 crop" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("Community create form hides and clears audio when the final image is removed", async () => {
|
||||
// Given
|
||||
const requests: CapturedRequest[] = [];
|
||||
window.history.pushState({}, "", "/ai-characters/101/community-posts/new");
|
||||
render(<CommunityPostFormPage apiClient={createCommunityFormClient(requests)} characterId="101" createCropSource={createCropSource} />);
|
||||
fireEvent.change(await screen.findByLabelText("게시글 이미지"), { target: { files: [new File(["gif"], "ready.gif", { type: "image/gif" })] } });
|
||||
fireEvent.change(await screen.findByLabelText("오디오 파일"), { target: { files: [new File(["audio"], "voice.m4a", { type: "audio/x-m4a" })] } });
|
||||
|
||||
// When
|
||||
fireEvent.click(screen.getByRole("button", { name: "게시글 이미지 선택 취소" }));
|
||||
|
||||
// Then
|
||||
await waitFor(() => expect(screen.queryByLabelText("오디오 파일")).not.toBeInTheDocument());
|
||||
fireEvent.change(screen.getByLabelText("내용"), { target: { value: "이미지 제거 게시글" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "생성" }));
|
||||
await waitFor(() => expect(requests.filter((request) => request.method === "POST")).toHaveLength(1));
|
||||
const body = requireFormData(requests.at(-1)?.body);
|
||||
expect(body.has("audioFile")).toBe(false);
|
||||
expect(body.has("postImage")).toBe(false);
|
||||
});
|
||||
|
||||
test("Community create form defaults price to zero and rejects blank price", async () => {
|
||||
// Given
|
||||
const requests: CapturedRequest[] = [];
|
||||
window.history.pushState({}, "", "/ai-characters/101/community-posts/new");
|
||||
render(<CommunityPostFormPage apiClient={createCommunityFormClient(requests)} characterId="101" createCropSource={createCropSource} />);
|
||||
const priceInput = await screen.findByLabelText("가격");
|
||||
expect(priceInput).toHaveValue(0);
|
||||
|
||||
// When
|
||||
fireEvent.change(screen.getByLabelText("내용"), { target: { value: "빈 가격 게시글" } });
|
||||
fireEvent.change(priceInput, { target: { value: "" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "생성" }));
|
||||
|
||||
// Then
|
||||
expect(await screen.findByText("가격은 0 이상 99,999 이하 정수 캔으로 입력하세요.")).toBeInTheDocument();
|
||||
expect(requests.filter((request) => request.method === "POST")).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("Community create form crops JPEG PNG with free ratio and no upscale", async () => {
|
||||
@@ -177,7 +285,7 @@ test("Community create form crops JPEG PNG with free ratio and no upscale", asyn
|
||||
|
||||
// When
|
||||
fireEvent.change(await screen.findByLabelText("내용"), { target: { value: "PNG crop 게시글" } });
|
||||
fireEvent.change(screen.getByLabelText("가격"), { target: { value: "1,000캔" } });
|
||||
fireEvent.change(screen.getByLabelText("가격"), { target: { value: "1000" } });
|
||||
fireEvent.change(screen.getByLabelText("게시글 이미지"), { target: { files: [new File(["image"], "wide.png", { type: "image/png" })] } });
|
||||
expect(await screen.findByRole("dialog", { name: "이미지 crop" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "생성" })).toBeDisabled();
|
||||
@@ -185,6 +293,7 @@ test("Community create form crops JPEG PNG with free ratio and no upscale", asyn
|
||||
expect(requests.filter((request) => request.method === "POST")).toHaveLength(0);
|
||||
fireEvent.click(await screen.findByRole("button", { name: "적용" }));
|
||||
await waitFor(() => expect(renderCrop).toHaveBeenCalled());
|
||||
expect(await screen.findByRole("img", { name: "게시글 이미지 업로드 미리보기" })).toHaveAttribute("src", "blob:community-7");
|
||||
fireEvent.click(screen.getByRole("button", { name: "생성" }));
|
||||
|
||||
// Then
|
||||
@@ -203,7 +312,7 @@ test("Community create form blocks prices outside the CAN range before submit",
|
||||
|
||||
// When
|
||||
fireEvent.change(await screen.findByLabelText("내용"), { target: { value: "가격 경계 게시글" } });
|
||||
fireEvent.change(screen.getByLabelText("가격"), { target: { value: "100,000캔" } });
|
||||
fireEvent.change(screen.getByLabelText("가격"), { target: { value: "100000" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "생성" }));
|
||||
|
||||
// Then
|
||||
@@ -219,7 +328,7 @@ test("Community create form links validation errors and focuses the first invali
|
||||
|
||||
// When
|
||||
const contentInput = await screen.findByLabelText("내용");
|
||||
fireEvent.change(screen.getByLabelText("가격"), { target: { value: "100,000캔" } });
|
||||
fireEvent.change(screen.getByLabelText("가격"), { target: { value: "100000" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "생성" }));
|
||||
|
||||
// Then
|
||||
@@ -228,7 +337,7 @@ test("Community create form links validation errors and focuses the first invali
|
||||
expect(contentError).toHaveAttribute("id", "community-post-content-error");
|
||||
expect(priceError).toHaveAttribute("id", "community-post-price-error");
|
||||
expect(contentInput).toHaveAttribute("aria-describedby", "community-post-content-error");
|
||||
expect(screen.getByLabelText("가격")).toHaveAttribute("aria-describedby", "community-post-price-error");
|
||||
expect(screen.getByLabelText("가격").getAttribute("aria-describedby")?.split(" ")).toContain("community-post-price-error");
|
||||
expect(contentInput).toHaveAttribute("aria-invalid", "true");
|
||||
expect(screen.getByLabelText("가격")).toHaveAttribute("aria-invalid", "true");
|
||||
await waitFor(() => expect(contentInput).toHaveFocus());
|
||||
@@ -295,7 +404,7 @@ test("Community create form ignores stale image preparation results", async () =
|
||||
expect(submittedImage.name).toBe("fresh.gif");
|
||||
});
|
||||
|
||||
test("Community create form clears pending image preparation when selection is canceled", async () => {
|
||||
test("Community create form clears image replacement when crop is canceled", async () => {
|
||||
let resolveCropSource: (source: CropSourceImage) => void = () => undefined;
|
||||
const cropSourceReady = new Promise<CropSourceImage>((resolve) => {
|
||||
resolveCropSource = resolve;
|
||||
@@ -309,11 +418,11 @@ test("Community create form clears pending image preparation when selection is c
|
||||
fireEvent.change(screen.getByLabelText("게시글 이미지"), { target: { files: [new File(["gif"], "ready.gif", { type: "image/gif" })] } });
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: "생성" })).not.toBeDisabled());
|
||||
fireEvent.change(screen.getByLabelText("게시글 이미지"), { target: { files: [new File(["image"], "slow.png", { type: "image/png" })] } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "선택 취소" }));
|
||||
await act(async () => {
|
||||
resolveCropSource({ file: new File(["image"], "slow.png", { type: "image/png" }), height: 500, previewUrl: "blob:slow", width: 1000 });
|
||||
await cropSourceReady;
|
||||
});
|
||||
fireEvent.click(await screen.findByRole("button", { name: "취소" }));
|
||||
|
||||
expect(screen.getByRole("button", { name: "생성" })).not.toBeDisabled();
|
||||
fireEvent.click(screen.getByRole("button", { name: "생성" }));
|
||||
|
||||
@@ -2,7 +2,7 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { expect, test, vi } from "vitest";
|
||||
|
||||
import { CommunityPostForm } from "@/features/community-posts/components/CommunityPostForm";
|
||||
import { formatCommunityPostPrice, parseCommunityPostPrice } from "@/features/community-posts/components/community-post-form-helpers";
|
||||
import { parseCommunityPostPrice } from "@/features/community-posts/components/community-post-form-helpers";
|
||||
import type { ApiClient, ApiRequestOptions } from "@/shared/api/client";
|
||||
|
||||
type CapturedRequest = {
|
||||
@@ -27,25 +27,25 @@ function renderForm(requests: CapturedRequest[]) {
|
||||
return onCreated;
|
||||
}
|
||||
|
||||
async function submitWithPrice(value: string, requests: CapturedRequest[]) {
|
||||
fireEvent.change(screen.getByLabelText("내용"), { target: { value: "가격 검증 게시글" } });
|
||||
fireEvent.change(screen.getByLabelText("가격"), { target: { value } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "생성" }));
|
||||
await screen.findByText("가격은 0 이상 99,999 이하 정수 캔으로 입력하세요.");
|
||||
expect(requests.filter((request) => request.method === "POST")).toHaveLength(0);
|
||||
}
|
||||
|
||||
test.each(["-1", "1.5"])("Community price keeps invalid raw input %s and blocks submit", async (value) => {
|
||||
test.each([
|
||||
"-1",
|
||||
"1.5",
|
||||
"100000",
|
||||
])("Community price preserves and rejects invalid raw input %s", async (rawValue) => {
|
||||
const requests: CapturedRequest[] = [];
|
||||
const onCreated = renderForm(requests);
|
||||
|
||||
await submitWithPrice(value, requests);
|
||||
fireEvent.change(screen.getByLabelText("내용"), { target: { value: "가격 검증 게시글" } });
|
||||
fireEvent.change(screen.getByLabelText("가격"), { target: { value: rawValue } });
|
||||
fireEvent.submit(screen.getByRole("form", { name: "커뮤니티 게시글 생성 입력 화면" }));
|
||||
|
||||
expect(screen.getByLabelText("가격")).toHaveValue(value);
|
||||
expect(screen.getByLabelText("가격")).toHaveValue(Number(rawValue));
|
||||
expect(await screen.findByText("가격은 0 이상 99,999 이하 정수 캔으로 입력하세요.")).toBeInTheDocument();
|
||||
expect(requests.filter((request) => request.method === "POST")).toHaveLength(0);
|
||||
expect(onCreated).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test.each(["0", "99,999캔"])("Community price allows boundary input %s", async (value) => {
|
||||
test.each(["0", "99999"])("Community price allows boundary input %s", async (value) => {
|
||||
const requests: CapturedRequest[] = [];
|
||||
const onCreated = renderForm(requests);
|
||||
|
||||
@@ -57,9 +57,8 @@ test.each(["0", "99,999캔"])("Community price allows boundary input %s", async
|
||||
expect(requests.filter((request) => request.method === "POST")).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("Community price parser rejects negative and decimal raw input", () => {
|
||||
test("Community price parser rejects blank, negative, and decimal raw input", () => {
|
||||
expect(parseCommunityPostPrice("")).toBeNull();
|
||||
expect(parseCommunityPostPrice("-1")).toBeNull();
|
||||
expect(parseCommunityPostPrice("1.5")).toBeNull();
|
||||
expect(formatCommunityPostPrice("-1")).toBe("-1");
|
||||
expect(formatCommunityPostPrice("1.5")).toBe("1.5");
|
||||
});
|
||||
|
||||
37
src/shared/ui/__tests__/can-price-field.test.tsx
Normal file
37
src/shared/ui/__tests__/can-price-field.test.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { expect, test, vi } from "vitest";
|
||||
|
||||
import { CanPriceField } from "@/shared/ui/can-price-field";
|
||||
|
||||
test("CanPriceField renders the native numeric CAN price contract", () => {
|
||||
render(<CanPriceField error={undefined} errorId="price-error" onChange={() => undefined} value="0" />);
|
||||
|
||||
const input = screen.getByLabelText("가격");
|
||||
expect(input).toHaveAttribute("type", "number");
|
||||
expect(input).toHaveAttribute("inputmode", "numeric");
|
||||
expect(input).toHaveAttribute("min", "0");
|
||||
expect(input).toHaveAttribute("step", "1");
|
||||
expect(input).toHaveAccessibleDescription("단위: 캔");
|
||||
});
|
||||
|
||||
test.each([
|
||||
"-1",
|
||||
"1.5",
|
||||
])("CanPriceField emits the raw input value %s unchanged", (rawValue) => {
|
||||
const onChange = vi.fn<(value: string) => void>();
|
||||
render(<CanPriceField error={undefined} errorId="price-error" onChange={onChange} value="0" />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("가격"), { target: { value: rawValue } });
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(rawValue);
|
||||
});
|
||||
|
||||
test("CanPriceField links the visible error and unit description to the invalid input", () => {
|
||||
render(<CanPriceField error="가격 오류" errorId="price-error" onChange={() => undefined} value="100000" />);
|
||||
|
||||
const input = screen.getByLabelText("가격");
|
||||
expect(input).toHaveAttribute("aria-invalid", "true");
|
||||
expect(input.getAttribute("aria-describedby")?.split(" ")).toContain("price-error");
|
||||
expect(input).toHaveAccessibleDescription("단위: 캔 가격 오류");
|
||||
expect(screen.getByRole("alert")).toHaveAttribute("id", "price-error");
|
||||
});
|
||||
@@ -1,8 +1,36 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { expect, test, vi } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { StrictMode } from "react";
|
||||
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||
|
||||
import { FileField } from "@/shared/ui/file-field";
|
||||
|
||||
const createObjectUrl = vi.fn<(blob: Blob) => string>();
|
||||
const revokeObjectUrl = vi.fn<(url: string) => void>();
|
||||
let originalCreateObjectUrl: PropertyDescriptor | undefined;
|
||||
let originalRevokeObjectUrl: PropertyDescriptor | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
createObjectUrl.mockReset().mockImplementation(() => `blob:preview-${createObjectUrl.mock.calls.length}`);
|
||||
revokeObjectUrl.mockReset();
|
||||
originalCreateObjectUrl = Object.getOwnPropertyDescriptor(URL, "createObjectURL");
|
||||
originalRevokeObjectUrl = Object.getOwnPropertyDescriptor(URL, "revokeObjectURL");
|
||||
Object.defineProperty(URL, "createObjectURL", { configurable: true, value: createObjectUrl });
|
||||
Object.defineProperty(URL, "revokeObjectURL", { configurable: true, value: revokeObjectUrl });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalCreateObjectUrl === undefined) {
|
||||
Reflect.deleteProperty(URL, "createObjectURL");
|
||||
} else {
|
||||
Object.defineProperty(URL, "createObjectURL", originalCreateObjectUrl);
|
||||
}
|
||||
if (originalRevokeObjectUrl === undefined) {
|
||||
Reflect.deleteProperty(URL, "revokeObjectURL");
|
||||
} else {
|
||||
Object.defineProperty(URL, "revokeObjectURL", originalRevokeObjectUrl);
|
||||
}
|
||||
});
|
||||
|
||||
test("FileField exposes label, description, error, accept guidance, keyboard file input, and controlled value", () => {
|
||||
const onChange = vi.fn();
|
||||
const value = new File(["image"], "profile.png", { type: "image/png" });
|
||||
@@ -23,9 +51,11 @@ test("FileField exposes label, description, error, accept guidance, keyboard fil
|
||||
expect(input).toHaveAttribute("accept", "image/png");
|
||||
expect(input).toHaveAttribute("aria-invalid", "true");
|
||||
expect(input).toHaveAccessibleDescription("프로필 이미지를 선택하세요. PNG만 업로드할 수 있습니다. 파일이 너무 큽니다.");
|
||||
expect(screen.getByText("프로필 이미지를 선택하세요.")).toHaveClass("break-keep");
|
||||
expect(screen.getByText("PNG만 업로드할 수 있습니다.")).toHaveClass("break-keep");
|
||||
expect(screen.getByRole("button", { name: "대표 이미지 파일 선택" })).toBeInTheDocument();
|
||||
expect(screen.getByText("profile.png")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "선택 취소" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "대표 이미지 선택 취소" })).toHaveTextContent("선택 취소");
|
||||
});
|
||||
|
||||
test("FileField emits File or null and clear selection without owning upload policy", () => {
|
||||
@@ -37,10 +67,62 @@ test("FileField emits File or null and clear selection without owning upload pol
|
||||
expect(onChange).toHaveBeenCalledWith(selected);
|
||||
|
||||
rerender(<FileField accept="audio/mpeg" acceptDescription="MP3" label="오디오" onChange={onChange} value={selected} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "선택 취소" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "오디오 선택 취소" }));
|
||||
expect(onChange).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
test("FileField clears the native selection when the controlled value is cleared externally", () => {
|
||||
// Given
|
||||
const selected = new File(["audio"], "voice.mp3", { type: "audio/mpeg" });
|
||||
const { rerender } = render(<FileField accept="audio/mpeg" acceptDescription="MP3" label="오디오" onChange={vi.fn()} value={null} />);
|
||||
const input = screen.getByLabelText<HTMLInputElement>("오디오");
|
||||
fireEvent.change(input, { target: { files: [selected] } });
|
||||
let nativeValue = "C:\\fakepath\\voice.mp3";
|
||||
Object.defineProperty(input, "value", { configurable: true, get: () => nativeValue, set: (value: string) => { nativeValue = value; } });
|
||||
rerender(<FileField accept="audio/mpeg" acceptDescription="MP3" label="오디오" onChange={vi.fn()} value={selected} />);
|
||||
expect(input.files?.[0]).toBe(selected);
|
||||
expect(input.value).toBe("C:\\fakepath\\voice.mp3");
|
||||
|
||||
// When
|
||||
rerender(<FileField accept="audio/mpeg" acceptDescription="MP3" label="오디오" onChange={vi.fn()} value={null} />);
|
||||
|
||||
// Then
|
||||
expect(input.value).toBe("");
|
||||
});
|
||||
|
||||
test("FileField clears a rejected native selection when only the error changes", () => {
|
||||
// Given
|
||||
const rejected = new File(["audio"], "voice.wav", { type: "audio/wav" });
|
||||
const { rerender } = render(<FileField accept="audio/mpeg" acceptDescription="MP3" label="오디오" onChange={vi.fn()} value={null} />);
|
||||
const input = screen.getByLabelText<HTMLInputElement>("오디오");
|
||||
fireEvent.change(input, { target: { files: [rejected] } });
|
||||
let nativeValue = "C:\\fakepath\\voice.wav";
|
||||
Object.defineProperty(input, "value", { configurable: true, get: () => nativeValue, set: (value: string) => { nativeValue = value; } });
|
||||
|
||||
// When
|
||||
rerender(<FileField accept="audio/mpeg" acceptDescription="MP3" error="지원하지 않는 파일입니다." label="오디오" onChange={vi.fn()} value={null} />);
|
||||
|
||||
// Then
|
||||
expect(input.value).toBe("");
|
||||
});
|
||||
|
||||
test("FileField clears the native selection when the controlled value is a different File", () => {
|
||||
// Given
|
||||
const selected = new File(["source"], "profile.png", { type: "image/png" });
|
||||
const cropped = new File(["cropped"], "profile.png", { type: "image/png" });
|
||||
const { rerender } = render(<FileField accept="image/png" acceptDescription="PNG" label="대표 이미지" onChange={vi.fn()} value={null} />);
|
||||
const input = screen.getByLabelText<HTMLInputElement>("대표 이미지");
|
||||
fireEvent.change(input, { target: { files: [selected] } });
|
||||
let nativeValue = "C:\\fakepath\\profile.png";
|
||||
Object.defineProperty(input, "value", { configurable: true, get: () => nativeValue, set: (value: string) => { nativeValue = value; } });
|
||||
|
||||
// When
|
||||
rerender(<FileField accept="image/png" acceptDescription="PNG" label="대표 이미지" onChange={vi.fn()} value={cropped} />);
|
||||
|
||||
// Then
|
||||
expect(input.value).toBe("");
|
||||
});
|
||||
|
||||
test("FileField gives same visible file buttons field-specific accessible names", () => {
|
||||
render(
|
||||
<>
|
||||
@@ -66,3 +148,102 @@ test("FileField visible container exposes the focus-within ring contract", () =>
|
||||
expect(input).toHaveFocus();
|
||||
expect(container.firstElementChild).toHaveClass("focus-within:ring-2", "focus-within:ring-ring", "focus-within:ring-offset-2");
|
||||
});
|
||||
|
||||
test("FileField reserves a bounded preview frame for the current image value", async () => {
|
||||
// Given
|
||||
const firstImage = new File(["first"], "first.png", { type: "image/png" });
|
||||
const secondImage = new File(["second"], "second.png", { type: "image/png" });
|
||||
createObjectUrl.mockImplementation((blob) => blob === firstImage ? "blob:first" : "blob:second");
|
||||
const { rerender } = render(<FileField accept="image/png" acceptDescription="PNG" label="대표 이미지" onChange={vi.fn()} value={firstImage} />);
|
||||
|
||||
// When
|
||||
const frame = screen.getByRole("figure", { name: "대표 이미지 업로드 미리보기 영역" });
|
||||
|
||||
// Then
|
||||
expect(frame).toHaveClass("aspect-video", "max-h-64", "w-full", "max-w-md");
|
||||
expect(await screen.findByRole("img", { name: "대표 이미지 업로드 미리보기" })).toHaveAttribute("src", "blob:first");
|
||||
|
||||
// When
|
||||
rerender(<FileField accept="image/png" acceptDescription="PNG" label="대표 이미지" onChange={vi.fn()} value={secondImage} />);
|
||||
|
||||
// Then
|
||||
expect(screen.getByRole("figure", { name: "대표 이미지 업로드 미리보기 영역" })).toBeInTheDocument();
|
||||
await waitFor(() => expect(screen.getByRole("img", { name: "대표 이미지 업로드 미리보기" })).toHaveAttribute("src", "blob:second"));
|
||||
});
|
||||
|
||||
test("FileField revokes every object URL created under StrictMode", async () => {
|
||||
// Given
|
||||
const image = new File(["image"], "profile.png", { type: "image/png" });
|
||||
|
||||
// When
|
||||
const { unmount } = render(
|
||||
<StrictMode>
|
||||
<FileField accept="image/png" acceptDescription="PNG" label="대표 이미지" onChange={vi.fn()} value={image} />
|
||||
</StrictMode>,
|
||||
);
|
||||
await screen.findByRole("img", { name: "대표 이미지 업로드 미리보기" });
|
||||
unmount();
|
||||
|
||||
// Then
|
||||
const createdUrls = createObjectUrl.mock.results.map(({ value }) => value);
|
||||
const revokedUrls = revokeObjectUrl.mock.calls.map(([url]) => url);
|
||||
expect(new Set(revokedUrls)).toEqual(new Set(createdUrls));
|
||||
});
|
||||
|
||||
test("FileField previews an image and revokes each owned object URL when its controlled value changes", async () => {
|
||||
// Given
|
||||
const firstImage = new File(["first"], "first.png", { type: "image/png" });
|
||||
const secondImage = new File(["second"], "second.png", { type: "image/png" });
|
||||
const onChange = vi.fn();
|
||||
const { rerender, unmount } = render(<FileField accept="image/png" acceptDescription="PNG" label="대표 이미지" onChange={onChange} value={firstImage} />);
|
||||
|
||||
// When
|
||||
const preview = await screen.findByRole("img", { name: "대표 이미지 업로드 미리보기" });
|
||||
const firstUrl = preview.getAttribute("src");
|
||||
|
||||
// Then
|
||||
expect(firstUrl).toMatch(/^blob:preview-/);
|
||||
expect(createObjectUrl).toHaveBeenCalledWith(firstImage);
|
||||
expect(preview).toHaveClass("h-full", "w-full", "object-contain");
|
||||
|
||||
// When
|
||||
rerender(<FileField accept="image/png" acceptDescription="PNG" label="대표 이미지" onChange={onChange} value={secondImage} />);
|
||||
|
||||
// Then
|
||||
await waitFor(() => expect(screen.getByRole("img", { name: "대표 이미지 업로드 미리보기" }).getAttribute("src")).not.toBe(firstUrl));
|
||||
const secondUrl = screen.getByRole("img", { name: "대표 이미지 업로드 미리보기" }).getAttribute("src");
|
||||
expect(createObjectUrl).toHaveBeenCalledWith(secondImage);
|
||||
expect(revokeObjectUrl).toHaveBeenCalledWith(firstUrl);
|
||||
|
||||
// When
|
||||
fireEvent.click(screen.getByRole("button", { name: "대표 이미지 선택 취소" }));
|
||||
rerender(<FileField accept="image/png" acceptDescription="PNG" label="대표 이미지" onChange={onChange} value={null} />);
|
||||
|
||||
// Then
|
||||
expect(onChange).toHaveBeenCalledWith(null);
|
||||
expect(screen.queryByRole("img", { name: "대표 이미지 업로드 미리보기" })).not.toBeInTheDocument();
|
||||
await waitFor(() => expect(revokeObjectUrl).toHaveBeenCalledWith(secondUrl));
|
||||
|
||||
// When
|
||||
rerender(<FileField accept="image/png" acceptDescription="PNG" label="대표 이미지" onChange={onChange} value={firstImage} />);
|
||||
const finalPreview = await screen.findByRole("img", { name: "대표 이미지 업로드 미리보기" });
|
||||
const finalUrl = finalPreview.getAttribute("src");
|
||||
unmount();
|
||||
|
||||
// Then
|
||||
expect(revokeObjectUrl).toHaveBeenCalledWith(finalUrl);
|
||||
});
|
||||
|
||||
test("FileField keeps non-image files filename-only without creating object URLs", () => {
|
||||
// Given
|
||||
const audio = new File(["audio"], "voice.mp3", { type: "audio/mpeg" });
|
||||
|
||||
// When
|
||||
render(<FileField accept="audio/mpeg" acceptDescription="MP3" label="오디오" onChange={vi.fn()} value={audio} />);
|
||||
|
||||
// Then
|
||||
expect(screen.getByText("voice.mp3")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("img")).not.toBeInTheDocument();
|
||||
expect(createObjectUrl).not.toHaveBeenCalled();
|
||||
expect(revokeObjectUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -8,6 +8,8 @@ test("PageState exposes accessible loading, empty, error, retry, and content sta
|
||||
const { rerender } = render(<PageState description="자료를 불러오는 중입니다." state="loading" title="불러오는 중" />);
|
||||
|
||||
expect(screen.getByRole("status")).toHaveTextContent("불러오는 중");
|
||||
expect(screen.getByRole("heading", { name: "불러오는 중" })).toHaveClass("break-keep");
|
||||
expect(screen.getByText("자료를 불러오는 중입니다.")).toHaveClass("break-keep");
|
||||
|
||||
rerender(<PageState description="조건에 맞는 자료가 없습니다." state="empty" title="자료 없음" />);
|
||||
|
||||
|
||||
33
src/shared/ui/can-price-field.tsx
Normal file
33
src/shared/ui/can-price-field.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { useId } from "react";
|
||||
|
||||
export type CanPriceFieldProps = {
|
||||
readonly error: string | undefined;
|
||||
readonly errorId: string;
|
||||
readonly onChange: (value: string) => void;
|
||||
readonly value: string;
|
||||
};
|
||||
|
||||
export function CanPriceField({ error, errorId, onChange, value }: CanPriceFieldProps) {
|
||||
const inputId = useId();
|
||||
const unitId = `${inputId}-unit`;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-semibold" htmlFor={inputId}>가격</label>
|
||||
<input
|
||||
aria-describedby={`${unitId}${error === undefined ? "" : ` ${errorId}`}`}
|
||||
aria-invalid={error === undefined ? undefined : true}
|
||||
className="rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground"
|
||||
id={inputId}
|
||||
inputMode="numeric"
|
||||
min="0"
|
||||
onChange={(event) => onChange(event.currentTarget.value)}
|
||||
step="1"
|
||||
type="number"
|
||||
value={value}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground" id={unitId}>단위: 캔</p>
|
||||
{error === undefined ? null : <p className="text-sm font-semibold text-destructive" id={errorId} role="alert">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useId, useRef } from "react";
|
||||
import { useId, useLayoutEffect, useRef } from "react";
|
||||
|
||||
export type FileFieldProps = {
|
||||
readonly accept: string;
|
||||
@@ -16,8 +16,33 @@ export function FileField({ accept, acceptDescription, description, error, label
|
||||
const acceptId = useId();
|
||||
const errorId = useId();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const previewRef = useRef<HTMLImageElement>(null);
|
||||
const isImage = value !== null && value.type.startsWith("image/");
|
||||
const describedBy = [description === undefined ? null : descriptionId, acceptId, error === undefined ? null : errorId].filter((id): id is string => id !== null).join(" ");
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (value === null || !value.type.startsWith("image/")) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const objectUrl = URL.createObjectURL(value);
|
||||
const previewElement = previewRef.current;
|
||||
previewElement?.setAttribute("src", objectUrl);
|
||||
|
||||
return () => {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
previewElement?.removeAttribute("src");
|
||||
};
|
||||
}, [value]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const input = inputRef.current;
|
||||
const selectedFile = input?.files?.[0];
|
||||
if (input !== null && selectedFile !== undefined && selectedFile !== value) {
|
||||
input.value = "";
|
||||
}
|
||||
});
|
||||
|
||||
function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
const files = event.currentTarget.files;
|
||||
onChange(files === null ? null : files[0] ?? null);
|
||||
@@ -33,16 +58,21 @@ export function FileField({ accept, acceptDescription, description, error, label
|
||||
return (
|
||||
<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>
|
||||
{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>
|
||||
{description === undefined ? null : <p className="break-keep text-sm text-muted-foreground" id={descriptionId}>{description}</p>}
|
||||
<p className="break-keep text-sm text-muted-foreground" id={acceptId}>{acceptDescription}</p>
|
||||
<input accept={accept} aria-describedby={describedBy} aria-invalid={error === undefined ? undefined : true} className="sr-only" id={inputId} onChange={handleChange} ref={inputRef} type="file" />
|
||||
<button aria-label={`${label} 파일 선택`} className="w-fit rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent" onClick={() => inputRef.current?.click()} type="button">파일 선택</button>
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span className="text-muted-foreground">{value === null ? "선택된 파일 없음" : value.name}</span>
|
||||
{value === null ? null : (
|
||||
<button className="rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent" onClick={clearSelection} type="button">선택 취소</button>
|
||||
<button aria-label={`${label} 선택 취소`} className="rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent" onClick={clearSelection} type="button">선택 취소</button>
|
||||
)}
|
||||
</div>
|
||||
{isImage ? (
|
||||
<figure aria-label={`${label} 업로드 미리보기 영역`} className="aspect-video max-h-64 w-full max-w-md overflow-hidden rounded-md border border-border bg-muted p-2">
|
||||
<img alt={`${label} 업로드 미리보기`} className="h-full w-full object-contain" ref={previewRef} />
|
||||
</figure>
|
||||
) : null}
|
||||
{error === undefined ? null : <p className="text-sm font-semibold text-destructive" id={errorId} role="alert">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -37,22 +37,22 @@ export function PageState(props: PageStateProps) {
|
||||
case "loading":
|
||||
return (
|
||||
<section className="rounded-lg border border-border bg-card p-6" role="status">
|
||||
<h2 className="text-xl font-semibold">{props.title}</h2>
|
||||
{props.description === undefined ? null : <p className="mt-2 text-sm text-muted-foreground">{props.description}</p>}
|
||||
<h2 className="break-keep text-xl font-semibold">{props.title}</h2>
|
||||
{props.description === undefined ? null : <p className="mt-2 break-keep text-sm text-muted-foreground">{props.description}</p>}
|
||||
</section>
|
||||
);
|
||||
case "empty":
|
||||
return (
|
||||
<section className="rounded-lg border border-border bg-card p-6" role="status">
|
||||
<h2 className="text-xl font-semibold">{props.title}</h2>
|
||||
{props.description === undefined ? null : <p className="mt-2 text-sm text-muted-foreground">{props.description}</p>}
|
||||
<h2 className="break-keep text-xl font-semibold">{props.title}</h2>
|
||||
{props.description === undefined ? null : <p className="mt-2 break-keep text-sm text-muted-foreground">{props.description}</p>}
|
||||
</section>
|
||||
);
|
||||
case "error":
|
||||
return (
|
||||
<section className="rounded-lg border border-destructive bg-card p-6 text-destructive" role="alert">
|
||||
<h2 className="text-xl font-semibold">{props.title}</h2>
|
||||
{props.description === undefined ? null : <p className="mt-2 text-sm">{props.description}</p>}
|
||||
<h2 className="break-keep text-xl font-semibold">{props.title}</h2>
|
||||
{props.description === undefined ? null : <p className="mt-2 break-keep text-sm">{props.description}</p>}
|
||||
{props.onRetry === undefined ? null : (
|
||||
<button className="mt-4 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)]" onClick={props.onRetry} type="button">
|
||||
다시 시도
|
||||
|
||||
Reference in New Issue
Block a user