diff --git a/docs/features/curation-program-schema-form/design.md b/docs/features/curation-program-schema-form/design.md new file mode 100644 index 0000000..8a1942d --- /dev/null +++ b/docs/features/curation-program-schema-form/design.md @@ -0,0 +1,137 @@ +# Program Schema-Driven Curation Form Design + +## UI Summary + +기존 큐레이션 작성 화면의 `DetailPageHeader`, 기본 정보 카드, 이미지 업로드, +섹션 카드를 유지한다. `PROGRAM` payload는 행사 정보 카드와 프로그램 목록 카드로 +나누며, 프로그램 배열의 각 항목은 번호가 있는 중첩 카드로 표시한다. + +requestSpec이 제공하는 필드 순서, 표시 이름, 타입, 필수 여부, 개수 제한을 화면에 +반영한다. 스펙에 앱 렌더링 정보가 없으므로 오른쪽 앱 미리보기 패널은 만들지 않고 +입력 영역을 전체 너비로 사용한다. + +## Navigation + +- Route: + - 모든 스펙의 공통 생성 라우트: + `/dashboard/curations/specs/:specCode/new` + - `PROGRAM` 진입: + `/dashboard/curations/specs/PROGRAM/new` + - 수정은 기존 `/dashboard/curations/:id`를 유지하고 상세 응답의 + `spec.code === PROGRAM`일 때 프로그램 수정 폼을 렌더링한다. + - 기존 `/dashboard/curations/tasting-events/new`, + `/dashboard/curations/general/new`, `/dashboard/curations/pairings/new`는 + 동일한 공통 생성 라우트로 redirect하는 호환 경로만 유지한다. +- Sidebar/menu placement: + - 기존 `큐레이션` 메뉴와 `/dashboard/curations` 목록을 유지한다. + - 별도 사이드바 항목은 추가하지 않는다. +- Entry actions: + - `/dashboard/curations/new`의 모든 활성 스펙 카드 전체와 `작성하기` 버튼이 + 각 spec code를 포함한 공통 생성 라우트로 이동한다. + - 프로그램 카드는 행사형 큐레이션임을 보여주는 기존 lucide 아이콘과 디자인 + 시스템 색상을 사용한다. + - 앱 미리보기 계약이 없으므로 `PROGRAM` 카드의 미리보기 액션은 표시하지 않는다. + +## List View + +- Columns: + - 기존 큐레이션 목록 컬럼을 변경하지 않는다. + - 스펙 표시는 기존 스펙 이름/코드 formatter를 통해 `프로그램`으로 표시한다. +- Filters/search: + - 기존 스펙 필터는 API 스펙 목록 기반이므로 `PROGRAM`을 자동 표시한다. +- Row actions: + - 기존 상세 이동을 유지한다. +- Empty/loading/error states: + - 기존 큐레이션 목록 상태를 유지한다. + +## Detail/Create/Edit View + +- Sections/cards: + - Header: + - 생성: `프로그램 작성` + - 수정: `프로그램 수정` + - 보조 문구는 생성 시 스펙 이름, 수정 시 큐레이션 ID를 표시한다. + - `기본 정보`: + - 기존 `CurationBasicInfoSection`을 사용한다. + - 큐레이션명, 설명, 이미지, 노출 기간, 노출 순서, 활성 상태를 유지한다. + - `행사 정보`: + - `programs`를 제외한 requestSpec 루트 필드를 스펙 순서로 렌더링한다. + - 날짜/숫자/짧은 문자열은 2열, 주소·URL·enum 다중 선택은 전체 너비를 사용한다. + - `프로그램`: + - 섹션 제목에 현재 개수와 스펙의 1~20개 제한을 표시한다. + - 빈 상태에 `프로그램을 추가해주세요.`와 `프로그램 추가` 버튼을 표시한다. + - 각 항목은 `프로그램 1`, `프로그램 2` 형태의 카드이며 우측 상단에 `삭제`가 있다. + - 프로그램 필드는 requestSpec의 item properties 순서로 표시한다. + - `시음 위스키`는 해당 프로그램 카드 안의 하위 영역에 기존 위스키 카드 목록 + UI를 재사용한다. +- Fields: + - string/date/time/number는 기존 `Input`, `Textarea`를 사용한다. + - 단일 enum은 `Select`로 표시한다. + - enum 배열은 체크박스 목록으로 표시하며 선택 개수 제한을 안내한다. + - `alcohol-card-list`는 DB 검색/직접 입력/테이스팅 태그/이미지 입력을 포함한 + 기존 위스키 카드 UI를 사용한다. + - optional 필드는 required marker를 표시하지 않는다. +- Validation messages: + - 기존 필드 메시지 형식인 `<필드명>은/는 필수입니다.`를 유지한다. + - 배열 최소/최대: + `<필드명>을 최소 N개 이상 추가해주세요.`, + `<필드명>은 최대 N개까지 추가할 수 있습니다.` + - 행사 날짜: + `행사 종료일은 행사 시작일보다 빠를 수 없습니다.` + - 프로그램 시간: + `종료 시간은 시작 시간보다 빠를 수 없습니다.` + - enum: + `<필드명>을 선택해주세요.` +- Primary/secondary actions: + - Primary: `저장` 또는 `수정` + - Secondary: `목록` + - 저장 중 또는 이미지 업로드 중에는 primary action을 비활성화한다. + +## State and Feedback + +- Loading: + - 모든 생성 유형의 스펙 목록/상세 로딩은 단일 작성 페이지의 로딩 카드를 사용한다. +- Empty: + - 스펙이 없거나 비활성이면 기존 `큐레이션 스펙을 찾을 수 없습니다.` 상태를 + 표시한다. + - 프로그램 배열이 비어 있으면 카드 안에 dashed empty state를 표시한다. +- Error: + - 스펙 목록/상세 조회 실패는 기존 retry/back blocking state를 사용한다. + - 지원하지 않는 requestSpec 구조는 `이 스펙은 아직 자동 폼에서 지원하지 +않습니다.` 오류 카드와 목록 이동을 표시한다. +- Success: + - 생성: `<스펙 이름> 큐레이션이 등록되었습니다.` + - 수정: 기존 `큐레이션이 수정되었습니다.` 토스트를 유지한다. + - 성공 후 큐레이션 목록으로 이동한다. +- Destructive confirmation: + - 아직 저장되지 않은 프로그램/위스키 항목 삭제는 즉시 반영한다. + - 서버 레코드 삭제는 이 기능에 포함하지 않는다. + +## Design System Usage + +- Components: + - `DetailPageHeader`, `CurationBasicInfoSection`, `CurationSectionCard`, + `CurationFormFieldRenderer`, `CurationWhiskyCardListField`, `FormField` + - shadcn `Card`, `Button`, `Input`, `Textarea`, `Select`, `Checkbox`, `Badge` +- Tokens/classes: + - `border-border`, `bg-card`, `bg-muted/20`, `text-muted-foreground`, + `text-destructive`, `ring-ring` 등 기존 토큰을 사용한다. + - 페이지 주요 영역은 `grid`, `minmax(0,1fr)`, `md:grid-cols-2`를 사용하고 + 고정 픽셀 열 너비를 추가하지 않는다. +- Existing pages to mirror: + - 공통 기본 정보와 저장: `WhiskyTastingEventForm.tsx` + - 공통 스펙 조회 상태: `CurationCreate.tsx` + - 반복 카드와 위스키 입력: `CurationWhiskyCardListField.tsx` + - 일반 payload 상세 fallback: `CurationDetail.tsx` + +## Manual UI Review Points + +- [ ] 생성 유형 화면에서 프로그램 카드가 다른 활성 스펙과 자연스럽게 정렬되는지 확인한다. +- [ ] `/dashboard/curations/specs/PROGRAM/new` 직접 진입 시 스펙 로딩 후 폼이 표시되는지 확인한다. +- [ ] 모바일/태블릿/데스크톱에서 행사 정보의 1열/2열 전환과 중첩 프로그램 카드가 + 가로 스크롤 없이 표시되는지 확인한다. +- [ ] 프로그램 추가/삭제 후 번호와 개수 badge가 즉시 갱신되는지 확인한다. +- [ ] 두 프로그램 각각의 시음 위스키가 서로 섞이지 않고 독립적으로 추가/삭제되는지 확인한다. +- [ ] enum 단일/다중 선택의 키보드 접근성과 required/error 표시를 확인한다. +- [ ] 저장 요청 payload의 프로그램 및 위스키 배열 순서가 화면 순서와 같은지 확인한다. +- [ ] 기존 시음회/추천/페어링 생성·수정 화면에 시각적 회귀가 없는지 확인한다. diff --git a/docs/features/curation-program-schema-form/plan.md b/docs/features/curation-program-schema-form/plan.md new file mode 100644 index 0000000..a7bbfea --- /dev/null +++ b/docs/features/curation-program-schema-form/plan.md @@ -0,0 +1,186 @@ +# Program Schema-Driven Curation Form Implementation Plan + +## Summary + +Add `PROGRAM` as a known curation spec and use one spec-code create route/page +for every curation type. The page resolves the list/detail spec once, then +selects a form-model/renderer strategy from the resolved spec. Extend the +shared curation schema vocabulary with enum select, enum multi-select, and +object array fields. Refactor the shared whisky card list to use its model key +as a React Hook Form path so each program can own an independent nested whisky +list. Preserve the specialized form renderers where their interaction model is +not expressible by the generic renderer, but remove page-level routing and +spec-query ownership from them. + +## Inputs + +- Spec: `docs/features/curation-program-schema-form/spec.md` +- Design: `docs/features/curation-program-schema-form/design.md` +- API source: + - Published admin API docs for curation spec list/detail and curation + create/update endpoints. + - Backend `main` resource + `bottlenote-mono/src/main/resources/openapi/curation/program.json`, + introduced by backend commit `d7944c51`. + - The development API could not be authenticated through direct probing, and + the published docs do not yet include a `PROGRAM` example. + +## Data and API Mapping + +- `CurationSpecCode.PROGRAM` -> known code used by entry routing and detail form + dispatch. +- `/dashboard/curations/specs/:specCode/new` -> + `useCurationSpecByCode(specCode)` -> `useCurationSpec(specId)` -> dynamic form + model/renderer strategy. +- Known create strategies: + - `WHISKY_TASTING_EVENT` -> tasting-event form model and form renderer. + - `RECOMMENDED_WHISKY`, `WHISKY_PAIRING` -> whisky-curation form model and + form renderer. + - object requestSpec, including `PROGRAM` -> generic schema-driven form model + and renderer. +- `requestSpec.properties` -> ordered root payload field models. +- `string enum` -> single select field with enum values as options. +- `array` with `items.enum` -> multi-select checkbox field. +- `array` with `items.type === object` -> object array field with recursively + parsed item property models. +- `x-field-style: alcohol-card-list` -> shared whisky card list field whose + `key` may be a nested path such as `programs.0.whiskies`. +- `required`, `nullable`, `minLength`, `maxLength`, `minimum`, `maximum`, + `minItems`, `maxItems` -> initial values and Zod validation. +- Common curation fields -> existing `CurationBasicInfoSection`. +- Create form -> `POST /admin/api/v2/curations` with `specId`, common fields, + and object `payload`. +- Edit form -> `PUT /admin/api/v2/curations/{curationId}` with the same shape. +- `CurationV2Detail.payload` -> recursively normalized edit form values. + +## Implementation Steps + +1. Extend the curation API/code vocabulary. + - Add `PROGRAM` to `CurationSpecCode` in + `src/types/api/curation.api.ts`. + - Update `src/pages/curation/curation-display-utils.ts` to show `프로그램` + for the known code. +2. Extend the shared schema-driven field model. + - In `src/pages/curation/curation-form-model.ts`, add: + - single enum select model, + - enum multi-select model, + - recursively parsed object array model. + - Resolve supported array/object shapes explicitly. Throw a descriptive + error for unsupported schema structures instead of falling back to text. + - Preserve property order from `requestSpec.properties`. + - Add a helper that prefixes a field model key recursively for nested React + Hook Form paths. +3. Extend shared rendering and validation. + - Add single enum and multi-enum inputs to + `src/pages/curation/components/CurationFormFieldRenderer.tsx`. + - Add + `src/pages/curation/components/CurationObjectArrayField.tsx` for + add/remove and nested item rendering. + - Extend `src/pages/curation/curation-form-schema.ts` with recursive Zod + generation for all supported field kinds and shared whisky-card item + validation. +4. Make the shared whisky list path-aware. + - Refactor + `src/pages/curation/components/CurationWhiskyCardListField.tsx` so + `useFieldArray`, `useWatch`, `getValues`, `setValue`, `clearErrors`, + error lookup, and nested registration paths derive from + `fieldModel.key`. + - Keep current root `alcohols` behavior unchanged for tasting events and + whisky curations. +5. Add the generic object-payload form pipeline under + `src/pages/curation/schema-driven/`. + - `schema-driven-curation.form-model.ts`: create root sections from + requestSpec; separate object arrays into their own sections. + - `schema-driven-curation.schema.ts`: combine common curation validation + with recursive payload validation and add the `PROGRAM` date/time + cross-field checks. + - `schema-driven-curation.mapper.ts`: create defaults, hydrate edit values, + and recursively serialize trimmed payload values while preserving array + order. + - `SchemaDrivenCurationForm.tsx`: render common info and generated sections, + submit create/update mutations, and handle upload/submission states. + - `CurationCreate.tsx`: read `specCode` from route params, resolve active + spec list/detail once, handle unsupported schema errors, and select/render + the correct form strategy from the resolved spec. + - `SchemaDrivenCurationEditPage.tsx`: build and render the same form from a + loaded `CurationV2Detail`. +6. Integrate navigation and detail dispatch. + - Render `CurationCreatePage` only at + `/dashboard/curations/specs/:specCode/new` with the existing curation + roles. + - Keep the three prior type-specific paths as redirects to the shared route; + they must not render or query a separate page. + - Add `PROGRAM` UI metadata to `CurationEntry.tsx`. + - Route every active card to the shared spec-code path without per-card + href configuration. + - Hide preview action for specs without a supported app preview, including + `PROGRAM`. + - Dispatch `PROGRAM` detail to `SchemaDrivenCurationEditPage` in + `CurationDetail.tsx`. +7. Add tests first for each behavior before its implementation. + - Extend `src/pages/curation/__tests__/CurationEntry.test.tsx` for the + program card, no preview action, and shared route navigation for all + known and unknown active specs. + - Add + `src/pages/curation/schema-driven/__tests__/schema-driven-curation.form-model.test.ts` + for primitive/enum/enum-array/object-array/nested whisky parsing and + unsupported structure errors. + - Add + `src/pages/curation/schema-driven/__tests__/schema-driven-curation.schema.test.ts` + for required/limits/date/time validation. + - Add + `src/pages/curation/schema-driven/__tests__/SchemaDrivenCurationPages.test.tsx` + for create rendering, nested program whisky isolation, create payload, + edit hydration, and update payload. + - Add a shared create-page dispatch test proving that the same page renders + the tasting-event, whisky-curation, and generic strategies after one spec + lookup. + - Retain and run existing curation form/page tests as regressions. + +## Edge Cases + +- An inactive or missing URL spec code shows the existing missing-spec state. +- A malformed or unsupported schema shows a blocking unsupported-spec state; + it does not render an incorrect generic text field. +- Optional enum arrays start empty and stop accepting values at `maxItems`. +- Programs start empty so the `minItems` error is observable on submit. +- Removing a program removes only its nested whisky list and React Hook Form + errors. +- Nested field paths remain stable after removing an earlier program. +- Optional empty strings/arrays are excluded from serialized payload objects; + required empty values remain available for validation. +- Nullable API values hydrate as empty UI values. +- Nested DB/manual whisky mapping follows the existing shared card contract and + removes UI-only stats only when they are absent. +- Existing root `alcohols` pages continue using the same component behavior. +- Program app preview is intentionally absent until a response/display + vocabulary is defined. + +## Verification Checklist + +- [x] `pnpm test:run` +- [ ] `pnpm lint` +- [x] Changed-file ESLint and Prettier checks +- [x] `pnpm build` +- [ ] Manual UI: open `/dashboard/curations/new` and confirm the `PROGRAM` + card enters `/dashboard/curations/specs/PROGRAM/new`. +- [ ] Manual UI: add two programs and different whisky lists, then confirm + independent state, responsive layout, and submitted array order. +- [ ] Manual UI: open an existing `PROGRAM` curation and confirm edit hydration. + +## Implementation Notes + +- Do not duplicate list/detail spec queries inside specialized create gates. +- Specialized form components may remain as renderer strategies, but all + runtime create navigation and page-level state must be owned by + `CurationCreatePage`. +- The dynamic engine supports the schema vocabulary listed in the feature spec, + not arbitrary JSON Schema. +- Do not add static `PROGRAM` payload TypeScript interfaces that duplicate + `requestSpec`; form state remains a recursive record/array shape. +- Do not hardcode `programs.0.whiskies` in the whisky component. All paths must + derive from field models. +- Do not add program-specific enum label maps. Use a generic readable enum + formatter until the backend publishes enum label metadata. +- Do not edit `src/components/ui`. +- New React components must each live in their own file. diff --git a/docs/features/curation-program-schema-form/spec.md b/docs/features/curation-program-schema-form/spec.md new file mode 100644 index 0000000..f8a83d6 --- /dev/null +++ b/docs/features/curation-program-schema-form/spec.md @@ -0,0 +1,136 @@ +# Program Schema-Driven Curation Form Spec + +## Summary + +큐레이션 V2 스펙에 추가된 `PROGRAM`을 어드민에서 생성하고 수정할 수 있게 한다. +작성 화면은 스펙 code를 받는 동적 라우트로 진입하고, 스펙 상세의 `requestSpec`을 +해석해 행사 정보, 프로그램 목록, 프로그램별 시음 위스키 목록을 렌더링한다. + +`PROGRAM` 한 종류만을 위한 필드 하드코딩을 추가하지 않고, 현재 큐레이션 폼의 +JSON Schema 파서/필드 모델/렌더러를 객체 배열과 enum까지 확장한다. 다만 +requestSpec에 없는 화면 문구, 섹션 의미, 앱 미리보기 구성은 자동 추론하지 않는다. + +## Source Inputs + +- API docs/response shape: + - Published admin docs: + `GET /admin/api/v2/curation-specs`, + `GET /admin/api/v2/curation-specs/{specId}`, + `POST /admin/api/v2/curations`, + `PUT /admin/api/v2/curations/{curationId}`. + - Backend `main` resource: + `bottlenote-mono/src/main/resources/openapi/curation/program.json` + (`feat: 프로그램 큐레이션 스펙 추가`, backend commit `d7944c51`). + - Published admin docs do not yet include a `PROGRAM` example. The backend + resource is the current contract source for this feature. +- Existing UI/code references: + - `src/pages/curation/CurationEntry.tsx` + - `src/pages/curation/curation-form-model.ts` + - `src/pages/curation/components/CurationFormFieldRenderer.tsx` + - `src/pages/curation/components/CurationWhiskyCardListField.tsx` + - `src/pages/curation/whisky-tasting-event/*` + - `src/pages/curation/whisky-curation/*` + - `src/routes/index.tsx` +- User request: + - 큐레이션 스펙에 추가된 프로그램의 새 화면 라우트와 렌더링을 추가한다. + - 가능한 범위에서 스펙 기반 동적 생성을 사용한다. + +## Admin Workflow + +- 관리자는 큐레이션 생성 유형 선택 화면에서 활성 스펙 카드를 확인한다. +- 모든 스펙 카드의 작성하기는 동일한 spec code 기반 작성 라우트로 이동한다. +- 단일 작성 페이지는 URL의 spec code로 대상 스펙과 상세 `requestSpec`을 조회한다. +- 단일 작성 페이지는 조회한 스펙의 form style/code에 맞는 폼 모델과 렌더러를 + 선택한다. 스펙 조회, loading/error/missing 상태와 목록 복귀는 이 페이지가 + 공통으로 소유한다. +- 관리자는 공통 큐레이션 기본 정보와 행사 정보를 입력한다. +- 관리자는 프로그램을 1개 이상 추가하고 각 프로그램의 정보와 선택적인 시음 + 위스키 목록을 입력한다. +- 저장 시 폼 값을 `PROGRAM` requestSpec의 object payload로 직렬화해 기존 + 큐레이션 V2 생성 API로 전송한다. +- 기존 `PROGRAM` 큐레이션 상세에 진입하면 같은 스펙 기반 폼을 수정 모드로 + 렌더링하고, 저장 시 기존 큐레이션 V2 수정 API로 전송한다. + +## Data Requirements + +- Spec list item: + - `code`: `PROGRAM` + - `name`, `description`, `version`, `isActive`는 생성 유형 카드와 스펙 선택에 사용한다. +- Spec detail: + - `requestSpec.type`: `object` + - 행사 필수 필드: + `eventStartDate`, `eventEndDate`, `placeName`, `address`, `programs` + - 행사 선택 필드: + `detailLocation`, `organizer`, `sponsor`, `entryFee`, `officialUrl`, + `registrationUrl`, `programTags` + - `programTags`: enum string array, 최대 6개 + - `programs`: object array, 최소 1개/최대 20개 + - 프로그램 필수 필드: + `name`, `type`, `programDate`, `startTime`, `description` + - 프로그램 선택 필드: + `endTime`, `venue`, `host`, `applicationUrl`, `whiskies` + - `type`: enum string + - `whiskies`: `x-field-style: alcohol-card-list`, 최대 10개 +- Schema vocabulary used by the UI: + - primitive `string`, `integer`, `number`, `boolean` + - `format: date|time` + - `enum` + - array of enum strings + - array of objects + - nested `x-field-style: alcohol-card-list` + - `required`, `nullable`, `minLength`, `maxLength`, `minimum`, `maximum`, + `minItems`, `maxItems`, `x-display-name`, `x-placeholder` +- Mutation payload: + - Common fields keep the existing `CurationV2CreateRequest` / + `CurationV2UpdateRequest` contract. + - `payload` is a single object because the `PROGRAM` container is `object`. + - Optional empty strings and empty optional arrays are omitted or normalized + consistently before submission; required values are retained. + +## Acceptance Criteria + +- [ ] 활성 `PROGRAM` 스펙이 생성 유형 선택 화면에 표시된다. +- [ ] 모든 활성 스펙 카드가 `/dashboard/curations/specs/{specCode}/new` 형식의 + 단일 작성 페이지로 이동한다. +- [ ] `PROGRAM` 작성하기가 준비중 토스트가 아니라 동적 생성 라우트로 이동한다. +- [ ] 동적 라우트가 URL의 spec code로 활성 스펙 상세를 조회한다. +- [ ] 행사 필드의 입력 종류, 필수 여부, 길이/최솟값/최댓값이 requestSpec에서 생성된다. +- [ ] `programTags`와 `type` enum을 선택할 수 있다. +- [ ] 프로그램을 1~20개 추가/삭제할 수 있고 배열 순서가 저장 순서가 된다. +- [ ] 각 프로그램 안에서 시음 위스키를 최대 10개까지 추가/삭제할 수 있다. +- [ ] 생성 요청의 `specId`와 object `payload`가 스펙 계약에 맞게 전송된다. +- [ ] 기존 `PROGRAM` 상세는 읽기 전용 fallback 대신 수정 폼으로 렌더링된다. +- [ ] 종료일은 시작일보다 빠를 수 없고, 프로그램 종료 시간은 시작 시간보다 + 빠를 수 없다. +- [ ] 기존 시음회/추천 위스키/페어링 생성·수정 동작이 유지된다. +- [ ] 지원하지 않는 스키마 구조는 잘못된 text input으로 조용히 렌더링하지 않고 + 명시적인 지원 불가 상태를 표시한다. + +## In Scope + +- `PROGRAM` spec code 타입 추가. +- spec code 기반 동적 생성 라우트. +- 기존 생성 라우트의 단일 작성 페이지 통합. +- object requestSpec의 공통 스키마 기반 폼. +- primitive, enum, enum array, object array, 중첩 alcohol card list 렌더링. +- `PROGRAM` 생성과 수정. +- 생성 유형 카드의 프로그램 표시와 진입. +- 기존 시음회/추천 위스키/페어링 생성 화면의 공통 동적 라우트 통합. +- 스키마 파서, 폼 모델, validation, mapper, 페이지 테스트. + +## Out of Scope + +- 백엔드 `program.json` 변경. +- Product 앱의 프로그램 큐레이션 화면. +- requestSpec에 없는 프로그램 전용 앱 미리보기 자동 생성. +- 모든 JSON Schema 키워드와 임의 깊이의 재귀 구조 지원. +- 프로그램 배열의 드래그 앤 드롭 정렬. 이번 범위에서는 추가 순서를 저장 순서로 + 유지한다. + +## Open Questions + +- `PROGRAM`의 enum 값은 스펙에 별도 한글 라벨 메타데이터가 없다. 이번 구현은 + enum code를 읽기 좋은 일반 표시 문자열로 변환한다. 정확한 한글 라벨이 필요하면 + 백엔드 스펙에 enum 라벨 메타데이터 vocabulary를 추가해야 한다. +- 개발 API는 직접 인증 조회가 불가능해 배포된 응답 대신 backend `main`의 + `program.json`을 계약 소스로 사용한다. diff --git a/src/pages/curation/CurationCreate.tsx b/src/pages/curation/CurationCreate.tsx new file mode 100644 index 0000000..99af2d4 --- /dev/null +++ b/src/pages/curation/CurationCreate.tsx @@ -0,0 +1,158 @@ +import { AlertCircle, Loader2 } from 'lucide-react'; +import { useNavigate, useParams } from 'react-router'; + +import { DetailPageHeader } from '@/components/common/DetailPageHeader'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent } from '@/components/ui/card'; +import { useCurationSpec, useCurationSpecByCode } from '@/hooks/useCurations'; +import { + CurationSpecCode, + type CurationV2Spec, + type CurationV2SpecCode, +} from '@/types/api'; + +import { SchemaDrivenCurationForm } from './schema-driven/SchemaDrivenCurationForm'; +import { + createSchemaDrivenCurationFormModel, + type SchemaDrivenCurationFormModel, +} from './schema-driven/schema-driven-curation.form-model'; +import { WhiskyCurationForm } from './whisky-curation/WhiskyCurationForm'; +import { + createWhiskyCurationFormModel, + type WhiskyCurationFormModel, +} from './whisky-curation/whisky-curation.schema'; +import { WhiskyTastingEventForm } from './whisky-tasting-event/WhiskyTastingEventForm'; +import { + createWhiskyTastingEventFormModel, + type WhiskyTastingEventFormModel, +} from './whisky-tasting-event/whisky-tasting-event.form-model'; + +type CurationCreateStrategy = + | { kind: 'tasting-event'; formModel: WhiskyTastingEventFormModel } + | { kind: 'whisky-curation'; formModel: WhiskyCurationFormModel } + | { kind: 'schema-driven'; formModel: SchemaDrivenCurationFormModel }; + +export function CurationCreatePage() { + const navigate = useNavigate(); + const { specCode } = useParams<{ specCode: CurationV2SpecCode }>(); + const specsQuery = useCurationSpecByCode(specCode); + const targetSpec = specsQuery.data?.isActive ? specsQuery.data : null; + const specDetailQuery = useCurationSpec(targetSpec?.id, { showErrorToast: false }); + const handleBack = () => navigate('/dashboard/curations'); + let strategy: CurationCreateStrategy | null = null; + let schemaError: Error | null = null; + + if (specDetailQuery.data) { + try { + strategy = resolveCurationCreateStrategy(specDetailQuery.data); + } catch (error) { + schemaError = error instanceof Error ? error : new Error('스펙을 해석하지 못했습니다.'); + } + } + + if (strategy) { + switch (strategy.kind) { + case 'tasting-event': + return ( + + ); + case 'whisky-curation': + return ( + + ); + case 'schema-driven': + return ; + } + } + + const isLoading = specsQuery.isLoading || (Boolean(targetSpec) && specDetailQuery.isLoading); + const isError = specsQuery.isError || specDetailQuery.isError; + const title = targetSpec?.name ? `${targetSpec.name} 작성` : '큐레이션 작성'; + + return ( +
+ + 목록 + + } + /> + + + + {isLoading ? ( +

+

+ ) : ( +
+
+
+ {isError && ( + + )} +
+ )} +
+
+
+ ); +} + +function resolveCurationCreateStrategy(spec: CurationV2Spec): CurationCreateStrategy { + switch (spec.code) { + case CurationSpecCode.WHISKY_TASTING_EVENT: + return { + kind: 'tasting-event', + formModel: createWhiskyTastingEventFormModel(spec), + }; + case CurationSpecCode.RECOMMENDED_WHISKY: + case CurationSpecCode.WHISKY_PAIRING: + return { + kind: 'whisky-curation', + formModel: createWhiskyCurationFormModel(spec), + }; + default: + return { + kind: 'schema-driven', + formModel: createSchemaDrivenCurationFormModel(spec), + }; + } +} diff --git a/src/pages/curation/CurationDetail.tsx b/src/pages/curation/CurationDetail.tsx index 3e181a9..d9ef6c5 100644 --- a/src/pages/curation/CurationDetail.tsx +++ b/src/pages/curation/CurationDetail.tsx @@ -10,6 +10,7 @@ import { CurationSpecCode, type CurationV2Detail } from '@/types/api'; import { CurationWhiskyTastingEventEditPage } from './whisky-tasting-event/CurationWhiskyTastingEventEdit'; import { WhiskyCurationEditPage } from './whisky-curation/WhiskyCurationEditPage'; +import { SchemaDrivenCurationEditPage } from './schema-driven/SchemaDrivenCurationEditPage'; import { formatCurationDateTime, formatCurationExposurePeriod, @@ -37,6 +38,10 @@ export function CurationDetailPage() { return ; } + if (!isLoading && !isError && curation?.spec.code === CurationSpecCode.PROGRAM) { + return ; + } + return (
= { WHISKY_TASTING_EVENT: { - href: '/dashboard/curations/tasting-events/new', + hasPreview: true, icon: CalendarDays, order: 1, label: '시음회', @@ -66,7 +66,7 @@ const CURATION_SPEC_UI_CONFIG: Record( CurationSpecCode.WHISKY_TASTING_EVENT ); - const { showToast } = useToast(); const { data: specs = [], isError, isLoading, refetch } = useCurationSpecs(); const activeSpecs = specs.filter((spec) => spec.isActive).sort(compareCurationSpecs); @@ -174,15 +185,7 @@ export function CurationEntryPage() { const example = config?.example ?? spec.name; const fields = config?.fields ?? ['이름', '설명', '커버', 'payload']; const handleStart = () => { - if (config) { - navigate(config.href); - return; - } - - showToast({ - type: 'info', - message: '준비중입니다', - }); + navigate(`/dashboard/curations/specs/${encodeURIComponent(spec.code)}/new`); }; return ( @@ -212,22 +215,23 @@ export function CurationEntryPage() {

{label}

- + }} + > + 미리보기 + + )} diff --git a/src/pages/curation/__tests__/CurationCreate.test.tsx b/src/pages/curation/__tests__/CurationCreate.test.tsx new file mode 100644 index 0000000..eac1d8d --- /dev/null +++ b/src/pages/curation/__tests__/CurationCreate.test.tsx @@ -0,0 +1,108 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { screen } from '@testing-library/react'; +import { http, HttpResponse } from 'msw'; + +import { server } from '@/test/mocks/server'; +import { wrapApiResponse } from '@/test/mocks/data'; +import { render } from '@/test/test-utils'; +import type { CurationV2Spec } from '@/types/api'; + +import { CurationCreatePage } from '../CurationCreate'; + +const SPEC_BASE = '/admin/api/v2/curation-specs'; +const routeState = vi.hoisted(() => ({ specCode: 'PROGRAM' })); + +vi.mock('react-router', async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...actual, + useNavigate: () => vi.fn(), + useParams: () => ({ specCode: routeState.specCode }), + }; +}); + +vi.mock('../whisky-tasting-event/WhiskyTastingEventForm', () => ({ + WhiskyTastingEventForm: () =>
시음회 전략 렌더러
, +})); + +vi.mock('../whisky-tasting-event/whisky-tasting-event.form-model', () => ({ + createWhiskyTastingEventFormModel: () => ({ kind: 'tasting-event' }), +})); + +vi.mock('../whisky-curation/WhiskyCurationForm', () => ({ + WhiskyCurationForm: () =>
위스키 큐레이션 전략 렌더러
, +})); + +vi.mock('../whisky-curation/whisky-curation.schema', () => ({ + createWhiskyCurationFormModel: () => ({ kind: 'whisky-curation' }), +})); + +vi.mock('../schema-driven/SchemaDrivenCurationForm', () => ({ + SchemaDrivenCurationForm: () =>
schema-driven 전략 렌더러
, +})); + +vi.mock('../schema-driven/schema-driven-curation.form-model', () => ({ + createSchemaDrivenCurationFormModel: () => ({ kind: 'schema-driven' }), +})); + +describe('CurationCreatePage', () => { + beforeEach(() => { + routeState.specCode = 'PROGRAM'; + }); + + it.each([ + ['WHISKY_TASTING_EVENT', '시음회 전략 렌더러'], + ['RECOMMENDED_WHISKY', '위스키 큐레이션 전략 렌더러'], + ['WHISKY_PAIRING', '위스키 큐레이션 전략 렌더러'], + ['PROGRAM', 'schema-driven 전략 렌더러'], + ])('같은 페이지가 %s 스펙에 맞는 렌더러를 선택한다', async (specCode, expectedText) => { + routeState.specCode = specCode; + mockSpec(specCode); + + render(); + + expect(await screen.findByText(expectedText)).toBeInTheDocument(); + }); + + it('URL의 spec code에 해당하는 활성 스펙이 없으면 missing 상태를 표시한다', async () => { + routeState.specCode = 'INACTIVE'; + mockSpec('INACTIVE', false); + + render(); + + expect(await screen.findByText('큐레이션 스펙을 찾을 수 없습니다.')).toBeInTheDocument(); + }); +}); + +function mockSpec(code: string, isActive = true) { + const spec: CurationV2Spec = { + id: 10, + code, + name: code, + description: null, + hydratorKey: null, + version: 1, + isActive, + requestSpec: { type: 'object', properties: {} }, + responseSpec: { type: 'object' }, + }; + + server.use( + http.get(SPEC_BASE, () => + HttpResponse.json( + wrapApiResponse([ + { + id: spec.id, + code: spec.code, + name: spec.name, + description: spec.description, + version: spec.version, + isActive: spec.isActive, + }, + ]) + ) + ), + http.get(`${SPEC_BASE}/:specId`, () => HttpResponse.json(wrapApiResponse(spec))) + ); +} diff --git a/src/pages/curation/__tests__/CurationDetail.test.tsx b/src/pages/curation/__tests__/CurationDetail.test.tsx index 30d9abb..3e3cbfa 100644 --- a/src/pages/curation/__tests__/CurationDetail.test.tsx +++ b/src/pages/curation/__tests__/CurationDetail.test.tsx @@ -9,6 +9,7 @@ import { render } from '@/test/test-utils'; import type { CurationV2Spec, CurationV2UpdateRequest } from '@/types/api'; import { CurationDetailPage } from '../CurationDetail'; +import { programSpec } from '../schema-driven/__tests__/program-spec.fixture'; vi.mock('react-router', async () => { const actual = await vi.importActual('react-router'); @@ -140,6 +141,49 @@ const tastingEventSpec: CurationV2Spec = { }; describe('CurationDetailPage', () => { + it('PROGRAM 큐레이션은 스펙 기반 수정 폼으로 렌더링한다', async () => { + server.use( + http.get(`${CURATION_BASE}/:curationId`, () => + HttpResponse.json( + wrapApiResponse({ + id: 10, + name: '2026 바쇼', + description: '행사 소개', + coverImageUrl: null, + imageUrls: [], + exposureStartDate: '2026-07-01', + exposureEndDate: '2026-07-31', + displayOrder: 0, + isActive: true, + createdAt: '2026-07-01T00:00:00', + modifiedAt: '2026-07-01T00:00:00', + spec: programSpec, + payload: { + eventStartDate: '2026-07-24', + eventEndDate: '2026-07-26', + placeName: '코엑스', + address: '서울 강남구 영동대로 513', + programs: [ + { + name: '마스터클래스', + type: 'MASTER_CLASS', + programDate: '2026-07-24', + startTime: '14:00', + description: '행사 설명', + }, + ], + }, + }) + ) + ) + ); + + render(); + + expect(await screen.findByRole('heading', { name: '프로그램 수정' })).toBeInTheDocument(); + expect(screen.getByLabelText('1번 프로그램 프로그램명')).toHaveValue('마스터클래스'); + }); + it('시음회 큐레이션은 작성 폼에 기존 값을 채우고 수정 API로 저장한다', async () => { const user = userEvent.setup(); let capturedBody: CurationV2UpdateRequest | null = null; @@ -368,5 +412,4 @@ describe('CurationDetailPage', () => { expect(screen.getByText('channel')).toBeInTheDocument(); expect(screen.getByText('home')).toBeInTheDocument(); }); - }); diff --git a/src/pages/curation/__tests__/CurationEntry.test.tsx b/src/pages/curation/__tests__/CurationEntry.test.tsx index 807d068..3fce1a1 100644 --- a/src/pages/curation/__tests__/CurationEntry.test.tsx +++ b/src/pages/curation/__tests__/CurationEntry.test.tsx @@ -11,6 +11,16 @@ import { CurationEntryPage } from '../CurationEntry'; const SPEC_BASE = '/admin/api/v2/curation-specs'; const mockShowToast = vi.hoisted(() => vi.fn()); +const mockNavigate = vi.hoisted(() => vi.fn()); + +vi.mock('react-router', async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...actual, + useNavigate: () => mockNavigate, + }; +}); vi.mock('@/hooks/useToast', async (importOriginal) => { const actual = await importOriginal(); @@ -53,6 +63,14 @@ const mockSpecs: CurationV2SpecListItem[] = [ }, { id: 4, + code: 'PROGRAM', + name: '프로그램', + description: '여러 프로그램과 프로그램별 위스키 라인업을 발행합니다.', + version: 1, + isActive: true, + }, + { + id: 5, code: 'INACTIVE_SPEC', name: '비활성 스펙', description: '비활성 스펙', @@ -72,6 +90,7 @@ function mockSpecList(specs: CurationV2SpecListItem[] = mockSpecs) { describe('CurationEntryPage', () => { beforeEach(() => { mockShowToast.mockClear(); + mockNavigate.mockClear(); }); const getFirstPreviewButton = async () => { @@ -97,6 +116,7 @@ describe('CurationEntryPage', () => { '시음회', '일반 큐레이션', '페어링 · 위스키 → 음식', + '프로그램', ]); expect( screen.getByText('특정 바에서 특정 기간 동안 위스키를 시음하는 모집을 만듭니다.') @@ -111,16 +131,48 @@ describe('CurationEntryPage', () => { render(); - expect(await screen.findAllByRole('button', { name: /작성하기/ })).toHaveLength(3); + expect(await screen.findAllByRole('button', { name: /작성하기/ })).toHaveLength(4); expect(screen.getAllByRole('button', { name: /미리보기/ })).toHaveLength(3); }); - it('준비되지 않은 활성 스펙의 작성하기를 누르면 준비중 안내를 표시한다', async () => { + it('PROGRAM 작성하기는 spec code 기반 동적 라우트로 이동하고 미리보기는 표시하지 않는다', async () => { + const user = userEvent.setup(); + mockSpecList(); + + render(); + + await user.click(await screen.findByRole('button', { name: '프로그램 작성하기' })); + + expect(mockNavigate).toHaveBeenCalledWith('/dashboard/curations/specs/PROGRAM/new'); + expect(screen.queryByRole('button', { name: '프로그램 미리보기' })).not.toBeInTheDocument(); + }); + + it('기존 전용 스펙 카드도 동일한 spec code 작성 라우트로 이동한다', async () => { + const user = userEvent.setup(); + mockSpecList(); + + render(); + + await user.click(await screen.findByRole('button', { name: '시음회 작성하기' })); + expect(mockNavigate).toHaveBeenLastCalledWith( + '/dashboard/curations/specs/WHISKY_TASTING_EVENT/new' + ); + + await user.click(screen.getByRole('button', { name: '일반 큐레이션 작성하기' })); + expect(mockNavigate).toHaveBeenLastCalledWith( + '/dashboard/curations/specs/RECOMMENDED_WHISKY/new' + ); + + await user.click(screen.getByRole('button', { name: '페어링 · 위스키 → 음식 작성하기' })); + expect(mockNavigate).toHaveBeenLastCalledWith('/dashboard/curations/specs/WHISKY_PAIRING/new'); + }); + + it('UI 설정이 없는 활성 스펙도 동적 작성 라우트로 이동한다', async () => { const user = userEvent.setup(); mockSpecList([ ...mockSpecs, { - id: 5, + id: 6, code: 'SEASONAL_PICK', name: '시즌 큐레이션', description: '아직 작성 화면이 준비되지 않은 스펙', @@ -135,10 +187,8 @@ describe('CurationEntryPage', () => { await user.click(screen.getByRole('button', { name: '시즌 큐레이션 작성하기' })); - expect(mockShowToast).toHaveBeenCalledWith({ - type: 'info', - message: '준비중입니다', - }); + expect(mockNavigate).toHaveBeenCalledWith('/dashboard/curations/specs/SEASONAL_PICK/new'); + expect(mockShowToast).not.toHaveBeenCalled(); }); it('미리보기 패널은 기본 시음회 앱 프레임을 표시한다', async () => { diff --git a/src/pages/curation/components/CurationFormFieldRenderer.tsx b/src/pages/curation/components/CurationFormFieldRenderer.tsx index 8fecdbd..36ce000 100644 --- a/src/pages/curation/components/CurationFormFieldRenderer.tsx +++ b/src/pages/curation/components/CurationFormFieldRenderer.tsx @@ -8,15 +8,25 @@ import { Checkbox } from '@/components/ui/checkbox'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; import { Textarea } from '@/components/ui/textarea'; import type { CurationBooleanRadioFieldModel, CurationBasicFieldModel, CurationFieldModel, + CurationMultiSelectFieldModel, CurationNumberFieldModel, + CurationSelectFieldModel, CurationTextFieldModel, } from '../curation-form-model'; +import { CurationObjectArrayField } from './CurationObjectArrayField'; import { CurationWhiskyCardListField, type CurationWhiskyCardListFieldOptions, @@ -52,6 +62,8 @@ const STANDARD_FIELD_RENDERER_REGISTRY: Record< textarea: renderTextAreaInput, number: renderNumberInput, 'boolean-radio': renderBooleanRadioInput, + select: renderSelectInput, + 'multi-select': renderMultiSelectInput, }; // 4. renderer registry 레이어: field model의 kind에 맞는 입력 컴포넌트를 선택해 렌더링합니다. @@ -73,6 +85,16 @@ export function CurationFormFieldRenderer({ ); } + if (field.kind === 'object-array') { + return ( + + ); + } + return ; } @@ -95,6 +117,18 @@ function StandardCurationFormFieldRenderer({ ); } +function renderSelectInput({ field, form, name }: StandardFieldRendererProps) { + if (field.kind !== 'select') return null; + + return ; +} + +function renderMultiSelectInput({ field, form, name }: StandardFieldRendererProps) { + if (field.kind !== 'multi-select') return null; + + return ; +} + // 일반 입력 필드의 세부 kind별 실제 input 요소를 생성합니다. function renderStandardInput( field: CurationBasicFieldModel, @@ -109,7 +143,7 @@ function renderStandardInput( function renderTextInput({ field, form, name }: StandardFieldRendererProps) { if (field.kind !== 'text' && field.kind !== 'date' && field.kind !== 'time') return null; - if (field.kind === 'text' && field.key === 'placeName') { + if (field.kind === 'text' && field.usePlaceSearch) { return ; } @@ -156,7 +190,7 @@ function TextInputField({ }) { return ( >; + name: string; +}) { + const value = useWatch({ control: form.control, name }); + + return ( + + ); +} + +function MultiSelectField({ + field, + form, + name, +}: { + field: CurationMultiSelectFieldModel; + form: ReturnType>; + name: string; +}) { + const watchedValue = useWatch({ control: form.control, name }); + const values = Array.isArray(watchedValue) + ? watchedValue.filter((value): value is string => typeof value === 'string') + : []; + const maxReached = typeof field.maxItems === 'number' && values.length >= field.maxItems; + + return ( +
+ {field.options.map((option) => { + const checked = values.includes(option.value); + const id = `${name}-${option.value}`; + + return ( +
+ { + const nextValues = + nextChecked === true + ? [...values, option.value] + : values.filter((value) => value !== option.value); + form.setValue(name, nextValues, { + shouldDirty: true, + shouldValidate: true, + }); + }} + /> + +
+ ); + })} +
+ ); +} + // address field model을 장소 검색 입력 컴포넌트로 렌더링합니다. function AddressField({ field, @@ -177,7 +295,7 @@ function AddressField({ }) { return ( void; + sectionHeader?: { + stepNumber?: number; + description?: string; + }; +} + +export function CurationObjectArrayField({ + fieldModel, + onImageUploadingChange, + sectionHeader, +}: CurationObjectArrayFieldProps) { + const form = useFormContext(); + const fieldArray = useFieldArray({ + control: form.control, + name: fieldModel.key, + }); + const watchedItems = useWatch({ + control: form.control, + name: fieldModel.key, + }); + const items = Array.isArray(watchedItems) ? watchedItems : []; + const isMaxReached = + typeof fieldModel.maxItems === 'number' && items.length >= fieldModel.maxItems; + const fieldError = form.getFieldState(fieldModel.key, form.formState).error; + const limitDescription = + typeof fieldModel.maxItems === 'number' + ? `${fieldModel.minItems}-${fieldModel.maxItems}개까지 등록할 수 있습니다.` + : `${fieldModel.minItems}개 이상 등록할 수 있습니다.`; + + return ( + + {fieldModel.label} + {fieldModel.required && *} + + } + description={ + <> + {sectionHeader?.description && {sectionHeader.description}} + {limitDescription} + + } + titleSuffix={items.length > 0 ? {items.length} : null} + contentClassName="space-y-4" + > + {fieldError?.message &&

{fieldError.message}

} + + {fieldArray.fields.length === 0 ? ( +
+ {fieldModel.label}을 추가해주세요. +
+ ) : ( +
+ {fieldArray.fields.map((arrayField, index) => ( +
+
+

+ {fieldModel.label} {index + 1} +

+ +
+ +
+ {fieldModel.itemFields.map((itemField) => { + const nestedField = createNestedFieldModel(fieldModel, itemField, index); + + return ( + + ); + })} +
+
+ ))} +
+ )} + + +
+ ); +} + +function createNestedFieldModel( + parent: CurationObjectArrayFieldModel, + field: CurationFieldModel, + index: number +): CurationFieldModel { + const nestedField = prefixCurationFieldModelKey(field, `${parent.key}.${index}`); + + return { + ...nestedField, + ariaLabel: `${index + 1}번 ${parent.label} ${field.label}`, + }; +} diff --git a/src/pages/curation/components/CurationWhiskyCardListField.tsx b/src/pages/curation/components/CurationWhiskyCardListField.tsx index 414e3fc..6ae133e 100644 --- a/src/pages/curation/components/CurationWhiskyCardListField.tsx +++ b/src/pages/curation/components/CurationWhiskyCardListField.tsx @@ -7,7 +7,7 @@ import { type PointerEvent, type ReactNode, } from 'react'; -import { useFieldArray, useFormContext, useWatch } from 'react-hook-form'; +import { useFieldArray, useFormContext, useWatch, type FieldValues } from 'react-hook-form'; import { GripVertical, Loader2, Plus, Upload, X } from 'lucide-react'; import { FormField } from '@/components/common/FormField'; @@ -35,7 +35,6 @@ import { } from '../curation-whisky-card-list.mapper'; import type { CurationWhiskyCardListFieldModel, - CurationWhiskyCardListFormValues, CurationWhiskyCardValue, CurationWhiskyMirror, CurationWhiskyStats, @@ -79,7 +78,8 @@ export function CurationWhiskyCardListField({ showCommentField = true, renderItemExtra, }: CurationWhiskyCardListFieldProps) { - const form = useFormContext(); + const form = useFormContext(); + const fieldPath = fieldModel.key; const fetchAlcoholDetail = useAdminAlcoholDetailLookup(); const { data: categoryReferenceData } = useCategoryReferences(); const { data: regionData } = useRegionList({ size: 200, sortOrder: 'ASC' }); @@ -101,13 +101,12 @@ export function CurationWhiskyCardListField({ const localManualImageUrlsRef = useRef>(new Set()); const alcoholFieldArray = useFieldArray({ control: form.control, - name: 'alcohols', + name: fieldPath, }); - const watchedAlcohols = - useWatch({ - control: form.control, - name: 'alcohols', - }) ?? []; + const watchedAlcohols = (useWatch({ + control: form.control, + name: fieldPath, + }) ?? []) as CurationWhiskyCardValue[]; const selectedAlcoholIds = watchedAlcohols .map((item) => item.alcohol.alcoholId) .filter((id): id is number => typeof id === 'number'); @@ -142,7 +141,7 @@ export function CurationWhiskyCardListField({ const handleAddEmptyWhisky = () => { if (isAddDisabled) return; - form.clearErrors('alcohols'); + form.clearErrors(fieldPath); alcoholFieldArray.append(createEmptyItem(), { shouldFocus: false }); }; @@ -158,13 +157,13 @@ export function CurationWhiskyCardListField({ 0, fieldModel.selectedTags.maxItems ); - form.setValue(`alcohols.${index}` as const, transformItem(curationWhiskyItem), { + form.setValue(`${fieldPath}.${index}`, transformItem(curationWhiskyItem), { shouldDirty: true, shouldValidate: true, }); } catch { form.setValue( - `alcohols.${index}` as const, + `${fieldPath}.${index}`, transformItem(createBottleNoteCurationWhiskyItem(whisky)), { shouldDirty: true, @@ -181,10 +180,12 @@ export function CurationWhiskyCardListField({ }; const handleUseManualInput = (fieldId: string, index: number) => { - const currentItem = form.getValues(`alcohols.${index}` as const); + const currentItem = form.getValues(`${fieldPath}.${index}`) as + | CurationWhiskyCardValue + | undefined; if (!currentItem || currentItem.source !== 'MANUAL') { - form.setValue(`alcohols.${index}` as const, createEmptyItem(), { + form.setValue(`${fieldPath}.${index}`, createEmptyItem(), { shouldDirty: true, shouldValidate: true, }); @@ -205,8 +206,8 @@ export function CurationWhiskyCardListField({ const trimmedValue = value.trim(); if (!trimmedValue) return false; - const path = `alcohols.${index}.alcohol.selectedTags` as const; - const currentTags = form.getValues(path) ?? []; + const path = `${fieldPath}.${index}.alcohol.selectedTags`; + const currentTags = (form.getValues(path) ?? []) as string[]; if ( currentTags.includes(trimmedValue) || currentTags.length >= fieldModel.selectedTags.maxItems @@ -223,8 +224,8 @@ export function CurationWhiskyCardListField({ }; const handleRemoveTag = (index: number, tag: string) => { - const path = `alcohols.${index}.alcohol.selectedTags` as const; - const currentTags = form.getValues(path); + const path = `${fieldPath}.${index}.alcohol.selectedTags`; + const currentTags = (form.getValues(path) ?? []) as string[]; form.setValue( path, currentTags.filter((item) => item !== tag), @@ -236,11 +237,14 @@ export function CurationWhiskyCardListField({ }; const handleHideWhiskyStatsItem = (index: number, key: RemovableWhiskyStatsKey) => { - const currentStats = form.getValues(`alcohols.${index}.stats` as const); + const currentStats = form.getValues(`${fieldPath}.${index}.stats`) as + | CurationWhiskyStats + | null + | undefined; if (!currentStats) return; form.setValue( - `alcohols.${index}.stats` as const, + `${fieldPath}.${index}.stats`, { ...currentStats, [key]: null, @@ -288,7 +292,7 @@ export function CurationWhiskyCardListField({ return; } - const path = `alcohols.${index}.alcohol.imageUrl` as const; + const path = `${fieldPath}.${index}.alcohol.imageUrl`; const previewUrl = URL.createObjectURL(file); localManualImageUrlsRef.current.add(previewUrl); form.setValue(path, previewUrl, { @@ -317,7 +321,7 @@ export function CurationWhiskyCardListField({ }; const handleRemoveManualWhiskyImage = (index: number) => { - const path = `alcohols.${index}.alcohol.imageUrl` as const; + const path = `${fieldPath}.${index}.alcohol.imageUrl`; const currentImageUrl = form.getValues(path); if (currentImageUrl) { revokeLocalManualImageUrl(currentImageUrl); @@ -329,7 +333,7 @@ export function CurationWhiskyCardListField({ }; const handleRemoveWhisky = (fieldId: string, index: number) => { - form.clearErrors('alcohols'); + form.clearErrors(fieldPath); alcoholFieldArray.remove(index); resetWhiskyDragState(); setManualInputFieldIds((prev) => { @@ -403,7 +407,7 @@ export function CurationWhiskyCardListField({ sourceIndex !== targetIndex ) { alcoholFieldArray.move(sourceIndex, targetIndex); - void form.trigger('alcohols'); + void form.trigger(fieldPath); } resetWhiskyDragState(); @@ -443,8 +447,10 @@ export function CurationWhiskyCardListField({

)} - {form.formState.errors.alcohols?.message && ( -

{form.formState.errors.alcohols.message}

+ {form.getFieldState(fieldPath, form.formState).error?.message && ( +

+ {form.getFieldState(fieldPath, form.formState).error?.message} +

)} {watchedAlcohols.length === 0 ? ( @@ -455,7 +461,15 @@ export function CurationWhiskyCardListField({
{alcoholFieldArray.fields.map((field, index) => { const item = watchedAlcohols[index]; - const itemError = form.formState.errors.alcohols?.[index]; + const itemError = form.getFieldState(`${fieldPath}.${index}`, form.formState).error as + | { + alcohol?: { + korName?: { message?: string }; + selectedTags?: { message?: string }; + }; + comment?: { message?: string }; + } + | undefined; const tags = item?.alcohol.selectedTags ?? []; const tagInput = tagInputs[field.id] ?? ''; const itemName = @@ -616,14 +630,14 @@ export function CurationWhiskyCardListField({ > @@ -679,21 +693,21 @@ export function CurationWhiskyCardListField({ @@ -702,14 +716,10 @@ export function CurationWhiskyCardListField({ ariaLabel={`${manualFieldPrefix} 지역`} value={manualRegionValue} onChange={(value) => - form.setValue( - `alcohols.${index}.alcohol.regionName` as const, - value, - { - shouldDirty: true, - shouldValidate: true, - } - ) + form.setValue(`${fieldPath}.${index}.alcohol.regionName`, value, { + shouldDirty: true, + shouldValidate: true, + }) } options={manualRegionOptionsForValue} placeholder="지역 선택" @@ -723,7 +733,7 @@ export function CurationWhiskyCardListField({ value={manualCategoryValue} onChange={(value) => form.setValue( - `alcohols.${index}.alcohol.korCategory` as const, + `${fieldPath}.${index}.alcohol.korCategory`, value, { shouldDirty: true, @@ -803,7 +813,7 @@ export function CurationWhiskyCardListField({ aria-label={`${itemName} ${fieldModel.comment.label}`} rows={5} maxLength={fieldModel.comment.maxLength} - {...form.register(`alcohols.${index}.comment` as const)} + {...form.register(`${fieldPath}.${index}.comment`)} placeholder={`${formatCurationFieldObject(fieldModel.comment.label)} 작성해주세요.`} className="min-h-40 resize-none rounded-[10px] border-border" /> @@ -825,7 +835,7 @@ export function CurationWhiskyCardListField({ className="h-14 w-full rounded-[10px] text-base font-semibold" onClick={handleAddEmptyWhisky} disabled={isAddDisabled} - aria-label={`${fieldModel.label} 추가`} + aria-label={`${fieldModel.ariaLabel ?? fieldModel.label} 추가`} > 추가 diff --git a/src/pages/curation/curation-display-utils.ts b/src/pages/curation/curation-display-utils.ts index 9a2e10a..e802c9e 100644 --- a/src/pages/curation/curation-display-utils.ts +++ b/src/pages/curation/curation-display-utils.ts @@ -10,6 +10,8 @@ export function formatCurationSpecCode(code: string): string { return '추천 위스키'; case 'WHISKY_PAIRING': return '위스키 페어링'; + case 'PROGRAM': + return '프로그램'; default: return code; } diff --git a/src/pages/curation/curation-form-model.ts b/src/pages/curation/curation-form-model.ts index 601aa07..9137ebf 100644 --- a/src/pages/curation/curation-form-model.ts +++ b/src/pages/curation/curation-form-model.ts @@ -10,7 +10,10 @@ export type CurationFieldKind = | 'address' | 'number' | 'boolean-radio' - | 'alcohol-card-list'; + | 'select' + | 'multi-select' + | 'alcohol-card-list' + | 'object-array'; export interface CurationBaseFieldModel { key: string; @@ -21,11 +24,15 @@ export interface CurationBaseFieldModel { fieldStyle?: string; placeholder?: string; defaultValue?: unknown; + nullable?: boolean; + ariaLabel?: string; } export interface CurationTextFieldModel extends CurationBaseFieldModel { kind: 'text' | 'textarea' | 'date' | 'time' | 'address'; + minLength?: number; maxLength?: number; + usePlaceSearch?: boolean; } export interface CurationNumberFieldModel extends CurationBaseFieldModel { @@ -47,12 +54,41 @@ export interface CurationBooleanRadioFieldModel extends CurationBaseFieldModel { falseLabel: string; } +export interface CurationSelectOption { + value: string; + label: string; +} + +export interface CurationSelectFieldModel extends CurationBaseFieldModel { + kind: 'select'; + options: CurationSelectOption[]; +} + +export interface CurationMultiSelectFieldModel extends CurationBaseFieldModel { + kind: 'multi-select'; + options: CurationSelectOption[]; + minItems: number; + maxItems?: number; +} + export type CurationBasicFieldModel = | CurationTextFieldModel | CurationNumberFieldModel - | CurationBooleanRadioFieldModel; + | CurationBooleanRadioFieldModel + | CurationSelectFieldModel + | CurationMultiSelectFieldModel; -export type CurationFieldModel = CurationBasicFieldModel | CurationWhiskyCardListFieldModel; +export interface CurationObjectArrayFieldModel extends CurationBaseFieldModel { + kind: 'object-array'; + minItems: number; + maxItems?: number; + itemFields: CurationFieldModel[]; +} + +export type CurationFieldModel = + | CurationBasicFieldModel + | CurationWhiskyCardListFieldModel + | CurationObjectArrayFieldModel; export interface CurationFieldVisibilityCondition { fieldKey: string; @@ -98,34 +134,66 @@ export function createCurationFormModelFromRequestSpec( requestSpec: JsonSchemaNode, options: CreateCurationFormModelOptions ): CurationFormModel { - const payloadFields = Object.keys(getSchemaProperties(requestSpec)).map((key) => { - const fieldSchema = getSchemaProperty(requestSpec, key); - const kind = getSchemaFieldKind(key, fieldSchema); - const customField = options.createCustomField?.({ requestSpec, fieldSchema, key, kind }); + const payloadFields = Object.keys(getSchemaProperties(requestSpec)).map((key) => + createCurationFieldModel(requestSpec, key, options) + ); + + return { + payloadFields, + sections: options.createSections(payloadFields), + }; +} + +function createCurationFieldModel( + requestSpec: JsonSchemaNode, + key: string, + options: Pick = {} +): CurationFieldModel { + const fieldSchema = getSchemaProperty(requestSpec, key); + const kind = getSchemaFieldKind(key, fieldSchema); + const customField = options.createCustomField?.({ requestSpec, fieldSchema, key, kind }); - if (customField) { - return customField; + if (customField) { + return customField; + } + + if (kind === 'alcohol-card-list') { + if (!fieldSchema.items) { + throw new Error(`지원하지 않는 큐레이션 스키마 필드: ${key}의 items가 없습니다.`); } - if (kind === 'alcohol-card-list') { - return createAlcoholCardListFieldModel({ - key, - label: getSchemaDisplayLabel(fieldSchema), - required: isSchemaPropertyRequired(requestSpec, key), - minItems: fieldSchema.minItems ?? 1, - maxItems: typeof fieldSchema.maxItems === 'number' ? fieldSchema.maxItems : undefined, - itemSchema: fieldSchema.items!, - }); + return createAlcoholCardListFieldModel({ + key, + label: getSchemaDisplayLabel(fieldSchema), + required: isSchemaPropertyRequired(requestSpec, key), + minItems: fieldSchema.minItems ?? (isSchemaPropertyRequired(requestSpec, key) ? 1 : 0), + maxItems: typeof fieldSchema.maxItems === 'number' ? fieldSchema.maxItems : undefined, + itemSchema: fieldSchema.items, + }); + } + + if (kind === 'object-array') { + const itemSchema = fieldSchema.items; + if (!itemSchema || itemSchema.type !== 'object') { + throw new Error(`지원하지 않는 큐레이션 스키마 필드: ${key}의 object items가 없습니다.`); } - const basicField = createCurationBasicFieldModel(requestSpec, key); - return options.overrideField?.(basicField) ?? basicField; - }); + return { + key, + kind, + label: getSchemaDisplayLabel(fieldSchema) || key, + required: isSchemaPropertyRequired(requestSpec, key), + nullable: fieldSchema.nullable, + minItems: fieldSchema.minItems ?? 0, + maxItems: typeof fieldSchema.maxItems === 'number' ? fieldSchema.maxItems : undefined, + itemFields: Object.keys(getSchemaProperties(itemSchema)).map((itemKey) => + createCurationFieldModel(itemSchema, itemKey) + ), + }; + } - return { - payloadFields, - sections: options.createSections(payloadFields), - }; + const basicField = createCurationBasicFieldModel(requestSpec, key); + return options.overrideField?.(basicField) ?? basicField; } export function getSchemaProperties(schema: JsonSchemaNode): Record { @@ -183,6 +251,15 @@ function resolveSchemaKind( if (key.toLowerCase().includes('time')) return 'time'; if (schema.type === 'integer' || schema.type === 'number') return 'number'; if (schema.type === 'boolean') return 'boolean-radio'; + if (schema.type === 'array' && schema.items?.enum) return 'multi-select'; + if (schema.type === 'array' && schema.items?.type === 'object') return 'object-array'; + if (schema.type === 'array') { + throw new Error(`지원하지 않는 큐레이션 스키마 필드: ${key} 배열 형식`); + } + if (schema.type === 'object') { + throw new Error(`지원하지 않는 큐레이션 스키마 필드: ${key} 객체 형식`); + } + if (schema.enum) return 'select'; if (schema.maxLength && schema.maxLength > 500) return 'textarea'; return 'text'; @@ -202,6 +279,7 @@ export function createCurationBasicFieldModel( fieldStyle: getSchemaFieldStyle(fieldSchema), placeholder: getSchemaPlaceholder(fieldSchema), defaultValue: fieldSchema.default, + nullable: fieldSchema.nullable, }; const kind = getSchemaFieldKind(key, fieldSchema); @@ -221,6 +299,20 @@ export function createCurationBasicFieldModel( trueLabel: '네', falseLabel: '아니요', }; + case 'select': + return { + ...base, + kind, + options: createEnumOptions(key, fieldSchema.enum), + }; + case 'multi-select': + return { + ...base, + kind, + options: createEnumOptions(key, fieldSchema.items?.enum), + minItems: fieldSchema.minItems ?? 0, + maxItems: typeof fieldSchema.maxItems === 'number' ? fieldSchema.maxItems : undefined, + }; case 'textarea': case 'date': case 'time': @@ -229,11 +321,46 @@ export function createCurationBasicFieldModel( return { ...base, kind, + minLength: fieldSchema.minLength, maxLength: fieldSchema.maxLength, }; case 'alcohol-card-list': throw new Error('alcohol-card-list는 createAlcoholCardListFieldModel로 생성해야 합니다.'); + case 'object-array': + throw new Error('object-array는 createCurationFieldModel로 생성해야 합니다.'); + } +} + +function createEnumOptions( + key: string, + enumValues: JsonSchemaNode['enum'] +): CurationSelectOption[] { + if (!enumValues || enumValues.some((value) => typeof value !== 'string')) { + throw new Error(`지원하지 않는 큐레이션 스키마 필드: ${key} enum은 문자열이어야 합니다.`); } + + return enumValues.map((value) => ({ + value: value as string, + label: formatSchemaEnumLabel(value as string), + })); +} + +export function formatSchemaEnumLabel(value: string): string { + return value + .toLowerCase() + .split('_') + .filter(Boolean) + .map((word) => `${word.charAt(0).toUpperCase()}${word.slice(1)}`) + .join(' '); +} + +export function prefixCurationFieldModelKey( + field: CurationFieldModel, + prefix: string +): CurationFieldModel { + const key = `${prefix}.${field.key}`; + + return { ...field, key }; } export interface AlcoholCardListFieldParams { diff --git a/src/pages/curation/curation-form-schema.ts b/src/pages/curation/curation-form-schema.ts index 8094ee7..8a93132 100644 --- a/src/pages/curation/curation-form-schema.ts +++ b/src/pages/curation/curation-form-schema.ts @@ -1,10 +1,14 @@ import { z } from 'zod'; import type { - CurationBasicFieldModel, + CurationFieldModel, + CurationMultiSelectFieldModel, CurationNumberFieldModel, + CurationObjectArrayFieldModel, + CurationSelectFieldModel, CurationTextFieldModel, } from './curation-form-model'; +import type { CurationWhiskyCardListFieldModel } from './curation-whisky-card-list.types'; function getTopicParticle(label: string): '은' | '는' { const trimmedLabel = label.trim(); @@ -45,8 +49,15 @@ export function formatCurationFieldObject(label: string): string { function createTextFieldValueSchema(field: CurationTextFieldModel): z.ZodType { let schema = z.string(); - if (field.required) { + const minimumLength = field.required ? Math.max(field.minLength ?? 0, 1) : field.minLength; + + if (field.required && minimumLength === 1) { schema = schema.min(1, `${formatCurationFieldTopic(field.label)} 필수입니다.`); + } else if (minimumLength) { + schema = schema.refine( + (value) => (!field.required && value.length === 0) || value.length >= minimumLength, + `${formatCurationFieldTopic(field.label)} 최소 ${minimumLength}자 이상 입력해주세요.` + ); } if (field.maxLength) { @@ -59,6 +70,143 @@ function createTextFieldValueSchema(field: CurationTextFieldModel): z.ZodType { + const allowedValues = new Set(field.options.map((option) => option.value)); + let schema = z.string(); + + if (field.required) { + schema = schema.min(1, `${formatCurationFieldObject(field.label)} 선택해주세요.`); + } + + return schema.refine( + (value) => (!field.required && value === '') || allowedValues.has(value), + `${formatCurationFieldObject(field.label)} 올바르게 선택해주세요.` + ); +} + +function createMultiSelectFieldValueSchema( + field: CurationMultiSelectFieldModel +): z.ZodType { + const allowedValues = new Set(field.options.map((option) => option.value)); + let schema = z.array( + z.string().refine((value) => allowedValues.has(value), { + message: `${formatCurationFieldObject(field.label)} 올바르게 선택해주세요.`, + }) + ); + + const minimumItems = field.required ? Math.max(field.minItems, 1) : field.minItems; + if (minimumItems > 0) { + schema = schema.min( + minimumItems, + `${formatCurationFieldObject(field.label)} 최소 ${minimumItems}개 이상 선택해주세요.` + ); + } + + if (typeof field.maxItems === 'number') { + schema = schema.max( + field.maxItems, + `${formatCurationFieldTopic(field.label)} 최대 ${field.maxItems}개까지 선택할 수 있습니다.` + ); + } + + return schema; +} + +export function createCurationWhiskyCardValueSchema(field: CurationWhiskyCardListFieldModel) { + return z.object({ + source: z.enum(['BOTTLE_NOTE', 'MANUAL']), + alcohol: z.object({ + alcoholId: z.number().nullable(), + korName: z.string().min(1, '위스키 한글명은 필수입니다.'), + engName: z.string().optional(), + imageUrl: z.string().nullable().optional(), + abv: z.string().optional(), + cask: z.string().optional(), + volume: z.string().optional(), + regionName: z.string().optional(), + korCategory: z.string().optional(), + selectedTags: z + .array(z.string().min(1, `${field.selectedTags.label}를 입력해주세요.`)) + .min( + field.selectedTags.minItems, + `${field.selectedTags.label}를 최소 ${field.selectedTags.minItems}개 이상 추가해주세요.` + ) + .max( + field.selectedTags.maxItems, + `${field.selectedTags.label}는 최대 ${field.selectedTags.maxItems}개까지 추가할 수 있습니다.` + ), + }), + stats: z + .object({ + rating: z.number().nullable().optional(), + totalRatingsCount: z.number().nullable().optional(), + reviewCount: z.number().nullable().optional(), + totalPickCount: z.number().nullable().optional(), + }) + .nullable() + .optional(), + comment: z + .string() + .max( + field.comment.maxLength, + `${field.comment.label}은 최대 ${field.comment.maxLength}자까지 입력할 수 있습니다.` + ) + .optional() + .nullable(), + }); +} + +function createWhiskyCardListValueSchema( + field: CurationWhiskyCardListFieldModel +): z.ZodType { + let schema = z.array(createCurationWhiskyCardValueSchema(field)); + const minimumItems = field.required ? Math.max(field.minItems, 1) : field.minItems; + + if (minimumItems > 0) { + schema = schema.min( + minimumItems, + `${formatCurationFieldObject(field.label)} 최소 ${minimumItems}개 이상 추가해주세요.` + ); + } + + if (typeof field.maxItems === 'number') { + schema = schema.max( + field.maxItems, + `${formatCurationFieldTopic(field.label)} 최대 ${field.maxItems}개까지 추가할 수 있습니다.` + ); + } + + return schema; +} + +function createObjectArrayValueSchema(field: CurationObjectArrayFieldModel): z.ZodType { + const itemShape = field.itemFields.reduce>>( + (shape, itemField) => { + shape[itemField.key] = createCurationFieldValueSchema(itemField); + return shape; + }, + {} + ); + let schema = z.array(z.object(itemShape)); + const minimumItems = field.required ? Math.max(field.minItems, 1) : field.minItems; + + if (minimumItems > 0) { + schema = schema.min( + minimumItems, + `${formatCurationFieldObject(field.label)} 최소 ${minimumItems}개 이상 추가해주세요.` + ); + } + + if (typeof field.maxItems === 'number') { + schema = schema.max( + field.maxItems, + `${formatCurationFieldTopic(field.label)} 최대 ${field.maxItems}개까지 추가할 수 있습니다.` + ); + } + + return schema; +} + // number/integer field model을 숫자 Zod schema로 변환합니다. function createNumberFieldValueSchema(field: CurationNumberFieldModel): z.ZodType { let schema = z.number(); @@ -85,12 +233,20 @@ function createNumberFieldValueSchema(field: CurationNumberFieldModel): z.ZodTyp } // field model의 kind에 맞는 Zod value schema를 생성합니다. -export function createCurationFieldValueSchema(field: CurationBasicFieldModel): z.ZodType { +export function createCurationFieldValueSchema(field: CurationFieldModel): z.ZodType { switch (field.kind) { case 'number': return createNumberFieldValueSchema(field); case 'boolean-radio': return z.boolean(); + case 'select': + return createSelectFieldValueSchema(field); + case 'multi-select': + return createMultiSelectFieldValueSchema(field); + case 'alcohol-card-list': + return createWhiskyCardListValueSchema(field); + case 'object-array': + return createObjectArrayValueSchema(field); case 'text': case 'textarea': case 'date': @@ -99,3 +255,27 @@ export function createCurationFieldValueSchema(field: CurationBasicFieldModel): return createTextFieldValueSchema(field); } } + +export function createDefaultCurationFieldValue(field: CurationFieldModel): unknown { + if ('defaultValue' in field && field.defaultValue !== undefined) { + return field.defaultValue; + } + + switch (field.kind) { + case 'alcohol-card-list': + case 'multi-select': + case 'object-array': + return []; + case 'boolean-radio': + return false; + case 'number': + return field.minimum ?? 0; + case 'select': + case 'date': + case 'time': + case 'textarea': + case 'text': + case 'address': + return ''; + } +} diff --git a/src/pages/curation/curation-whisky-card-list.types.ts b/src/pages/curation/curation-whisky-card-list.types.ts index 6a51eb8..c093f3f 100644 --- a/src/pages/curation/curation-whisky-card-list.types.ts +++ b/src/pages/curation/curation-whisky-card-list.types.ts @@ -33,6 +33,7 @@ export interface CurationWhiskyCardListFormValues { export interface CurationWhiskyCardListFieldModel { key: string; + ariaLabel?: string; kind: 'alcohol-card-list'; label: string; required: boolean; diff --git a/src/pages/curation/schema-driven/SchemaDrivenCurationEditPage.tsx b/src/pages/curation/schema-driven/SchemaDrivenCurationEditPage.tsx new file mode 100644 index 0000000..b5d113d --- /dev/null +++ b/src/pages/curation/schema-driven/SchemaDrivenCurationEditPage.tsx @@ -0,0 +1,45 @@ +import { AlertCircle } from 'lucide-react'; +import { useNavigate } from 'react-router'; + +import { Button } from '@/components/ui/button'; +import { Card, CardContent } from '@/components/ui/card'; +import type { CurationV2Detail } from '@/types/api'; + +import { SchemaDrivenCurationForm } from './SchemaDrivenCurationForm'; +import { createSchemaDrivenCurationFormModel } from './schema-driven-curation.form-model'; + +export function SchemaDrivenCurationEditPage({ curation }: { curation: CurationV2Detail }) { + const navigate = useNavigate(); + const handleBack = () => navigate('/dashboard/curations'); + let formModel = null; + let schemaError: Error | null = null; + + try { + formModel = createSchemaDrivenCurationFormModel(curation.spec); + } catch (error) { + schemaError = error instanceof Error ? error : new Error('스펙을 해석하지 못했습니다.'); + } + + if (formModel) { + return ( + + ); + } + + return ( + + +
+
+ +
+
+ ); +} diff --git a/src/pages/curation/schema-driven/SchemaDrivenCurationForm.tsx b/src/pages/curation/schema-driven/SchemaDrivenCurationForm.tsx new file mode 100644 index 0000000..ab16c66 --- /dev/null +++ b/src/pages/curation/schema-driven/SchemaDrivenCurationForm.tsx @@ -0,0 +1,145 @@ +import { useState } from 'react'; +import { FormProvider, useForm, type FieldErrors, type Resolver } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { Save } from 'lucide-react'; +import { useNavigate } from 'react-router'; + +import { DetailPageHeader } from '@/components/common/DetailPageHeader'; +import { Button } from '@/components/ui/button'; +import { useCurationCreate, useCurationUpdate } from '@/hooks/useCurations'; +import { useToast } from '@/hooks/useToast'; +import { useAuthStore } from '@/stores/auth'; +import type { + CurationV2CreateRequest, + CurationV2Detail, + CurationV2UpdateRequest, +} from '@/types/api'; + +import { CurationBasicInfoSection } from '../components/CurationBasicInfoSection'; +import { CurationFormErrorFocusProvider } from '../components/CurationFormErrorFocusProvider'; +import { CurationFormSection } from '../components/CurationFormSection'; +import { useCurationFormErrorFocus } from '../form-error-focus'; +import { + buildSchemaDrivenCurationPayload, + createSchemaDrivenCurationFormStateFromCuration, +} from './schema-driven-curation.mapper'; +import type { SchemaDrivenCurationFormModel } from './schema-driven-curation.form-model'; +import { + createDefaultSchemaDrivenCurationFormState, + createSchemaDrivenCurationFormSchema, + type SchemaDrivenCurationFormState, +} from './schema-driven-curation.schema'; + +interface SchemaDrivenCurationFormProps { + formModel: SchemaDrivenCurationFormModel; + curation?: CurationV2Detail; + onBack: () => void; +} + +export function SchemaDrivenCurationForm({ + formModel, + curation, + onBack, +}: SchemaDrivenCurationFormProps) { + const navigate = useNavigate(); + const isRootAdmin = useAuthStore((state) => state.hasRole('ROOT_ADMIN')); + const { showToast } = useToast(); + const [isCurationImageUploading, setIsCurationImageUploading] = useState(false); + const [isWhiskyImageUploading, setIsWhiskyImageUploading] = useState(false); + const isEditMode = Boolean(curation); + const formSchema = createSchemaDrivenCurationFormSchema(formModel, { + mode: isEditMode ? 'edit' : 'create', + }); + const defaultValues = curation + ? createSchemaDrivenCurationFormStateFromCuration(curation, formModel) + : createDefaultSchemaDrivenCurationFormState(formModel); + const errorFocus = useCurationFormErrorFocus(); + const form = useForm({ + resolver: zodResolver( + formSchema as never + ) as unknown as Resolver, + defaultValues, + mode: 'onSubmit', + shouldFocusError: false, + }); + const createMutation = useCurationCreate({ + successMessage: `${formModel.spec.name} 큐레이션이 등록되었습니다.`, + onSuccess: () => navigate('/dashboard/curations'), + }); + const updateMutation = useCurationUpdate({ + onSuccess: () => navigate('/dashboard/curations'), + }); + const isSubmitting = createMutation.isPending || updateMutation.isPending; + const isImageUploading = isCurationImageUploading || isWhiskyImageUploading; + + const handleSubmit = form.handleSubmit( + (values) => { + const request: CurationV2CreateRequest | CurationV2UpdateRequest = { + specId: formModel.spec.id, + name: values.name.trim(), + description: values.description.trim() || null, + imageUrls: values.imageUrls, + exposureStartDate: values.exposureStartDate || null, + exposureEndDate: values.exposureEndDate || null, + displayOrder: values.displayOrder, + isActive: values.isActive, + payload: buildSchemaDrivenCurationPayload(values, formModel), + }; + + if (curation) { + updateMutation.mutate({ curationId: curation.id, data: request }); + } else { + createMutation.mutate(request); + } + }, + (errors: FieldErrors) => { + showToast({ type: 'warning', message: '입력 정보를 확인해주세요.' }); + errorFocus.focusFirstError(errors); + } + ); + + return ( +
+ + + + + } + /> + + + +
+ + {formModel.sections.map((section) => ( + + ))} +
+
+
+
+ ); +} diff --git a/src/pages/curation/schema-driven/__tests__/SchemaDrivenCurationPages.test.tsx b/src/pages/curation/schema-driven/__tests__/SchemaDrivenCurationPages.test.tsx new file mode 100644 index 0000000..32b3221 --- /dev/null +++ b/src/pages/curation/schema-driven/__tests__/SchemaDrivenCurationPages.test.tsx @@ -0,0 +1,220 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { fireEvent, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { http, HttpResponse } from 'msw'; + +import { server } from '@/test/mocks/server'; +import { wrapApiResponse } from '@/test/mocks/data'; +import { render } from '@/test/test-utils'; +import type { CurationV2CreateRequest, CurationV2Detail } from '@/types/api'; + +import { SchemaDrivenCurationEditPage } from '../SchemaDrivenCurationEditPage'; +import { CurationCreatePage } from '../../CurationCreate'; +import { programSpec } from './program-spec.fixture'; + +const SPEC_BASE = '/admin/api/v2/curation-specs'; +const CURATION_BASE = '/admin/api/v2/curations'; +const mockNavigate = vi.hoisted(() => vi.fn()); + +vi.mock('react-router', async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...actual, + useNavigate: () => mockNavigate, + useParams: () => ({ specCode: 'PROGRAM' }), + }; +}); + +function mockProgramSpec() { + server.use( + http.get(SPEC_BASE, () => + HttpResponse.json( + wrapApiResponse([ + { + id: programSpec.id, + code: programSpec.code, + name: programSpec.name, + description: programSpec.description, + version: programSpec.version, + isActive: programSpec.isActive, + }, + ]) + ) + ), + http.get(`${SPEC_BASE}/:specId`, () => HttpResponse.json(wrapApiResponse(programSpec))) + ); +} + +describe('schema driven curation pages', () => { + beforeEach(() => { + mockNavigate.mockClear(); + }); + + it('PROGRAM requestSpec으로 행사 필드와 프로그램 object array를 렌더링한다', async () => { + const user = userEvent.setup(); + mockProgramSpec(); + + render(); + + expect(await screen.findByLabelText('행사 시작일')).toBeInTheDocument(); + expect(screen.getByLabelText('행사 종료일')).toBeInTheDocument(); + expect(screen.getByLabelText('프로그램 태그')).toBeInTheDocument(); + expect(screen.getByText('프로그램을 추가해주세요.')).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: '프로그램 추가' })); + + expect(screen.getByRole('heading', { name: '프로그램 1' })).toBeInTheDocument(); + expect(screen.getByLabelText('1번 프로그램 프로그램명')).toBeInTheDocument(); + expect(screen.getByLabelText('1번 프로그램 프로그램 유형')).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: '1번 프로그램 시음 위스키 추가' }) + ).toBeInTheDocument(); + }); + + it('입력한 프로그램 배열 순서와 object payload로 생성 요청을 전송한다', async () => { + const user = userEvent.setup(); + let capturedBody: CurationV2CreateRequest | null = null; + mockProgramSpec(); + server.use( + http.post(CURATION_BASE, async ({ request }) => { + capturedBody = (await request.json()) as CurationV2CreateRequest; + return HttpResponse.json(wrapApiResponse({ targetId: 100 })); + }) + ); + + render(); + + await screen.findByLabelText('큐레이션명'); + fillCommonFields(); + fireEvent.change(screen.getByLabelText('행사 시작일'), { + target: { value: '2026-07-24' }, + }); + fireEvent.change(screen.getByLabelText('행사 종료일'), { + target: { value: '2026-07-26' }, + }); + fireEvent.change(screen.getByLabelText('장소명'), { target: { value: '코엑스' } }); + fireEvent.change(screen.getByLabelText('장소 및 주소'), { + target: { value: '서울 강남구 영동대로 513' }, + }); + + await user.click(screen.getByRole('button', { name: '프로그램 추가' })); + fillProgram(1, '마스터클래스'); + + await user.click(screen.getByRole('button', { name: '프로그램 추가' })); + fillProgram(2, '테이스팅'); + + await user.click(screen.getByRole('button', { name: '저장' })); + + await waitFor(() => expect(capturedBody).not.toBeNull()); + expect(capturedBody).toMatchObject({ + specId: 4, + payload: { + eventStartDate: '2026-07-24', + eventEndDate: '2026-07-26', + placeName: '코엑스', + address: '서울 강남구 영동대로 513', + programs: [ + expect.objectContaining({ name: '마스터클래스' }), + expect.objectContaining({ name: '테이스팅' }), + ], + }, + }); + }); + + it('각 프로그램의 중첩 위스키 목록을 독립적인 field path로 관리한다', async () => { + const user = userEvent.setup(); + mockProgramSpec(); + + render(); + + await screen.findByLabelText('행사 시작일'); + await user.click(screen.getByRole('button', { name: '프로그램 추가' })); + await user.click(screen.getByRole('button', { name: '프로그램 추가' })); + + const firstProgram = screen.getByRole('group', { name: '프로그램 1' }); + const secondProgram = screen.getByRole('group', { name: '프로그램 2' }); + + await user.click( + within(firstProgram).getByRole('button', { + name: '1번 프로그램 시음 위스키 추가', + }) + ); + + expect(within(firstProgram).getByRole('button', { name: '직접 입력' })).toBeInTheDocument(); + expect( + within(secondProgram).queryByRole('button', { name: '직접 입력' }) + ).not.toBeInTheDocument(); + }); + + it('PROGRAM 상세 payload를 수정 폼에 복원한다', async () => { + const curation: CurationV2Detail = { + id: 10, + name: '2026 바쇼', + description: '행사 소개', + coverImageUrl: null, + imageUrls: [], + exposureStartDate: '2026-07-01', + exposureEndDate: '2026-07-31', + displayOrder: 0, + isActive: true, + createdAt: '2026-07-01T00:00:00', + modifiedAt: '2026-07-01T00:00:00', + spec: programSpec, + payload: { + eventStartDate: '2026-07-24', + eventEndDate: '2026-07-26', + placeName: '코엑스', + address: '서울 강남구 영동대로 513', + programs: [ + { + name: '마스터클래스', + type: 'MASTER_CLASS', + programDate: '2026-07-24', + startTime: '14:00', + description: '행사 설명', + whiskies: [], + }, + ], + }, + }; + + render(); + + expect(screen.getByLabelText('큐레이션명')).toHaveValue('2026 바쇼'); + expect(screen.getByLabelText('행사 시작일')).toHaveValue('2026-07-24'); + expect(screen.getByLabelText('1번 프로그램 프로그램명')).toHaveValue('마스터클래스'); + }); +}); + +function fillCommonFields() { + fireEvent.change(screen.getByLabelText('큐레이션명'), { + target: { value: '2026 바쇼' }, + }); + fireEvent.change(screen.getByLabelText('설명'), { + target: { value: '여러 프로그램을 소개합니다.' }, + }); + fireEvent.change(screen.getByLabelText('광고노출 시작일'), { + target: { value: '2026-07-01' }, + }); + fireEvent.change(screen.getByLabelText('광고노출 종료일'), { + target: { value: '2026-07-31' }, + }); +} + +function fillProgram(index: number, name: string) { + fireEvent.change(screen.getByLabelText(`${index}번 프로그램 프로그램명`), { + target: { value: name }, + }); + fireEvent.click(screen.getByLabelText(`${index}번 프로그램 프로그램 유형`)); + fireEvent.click(screen.getByRole('option', { name: 'Master Class' })); + fireEvent.change(screen.getByLabelText(`${index}번 프로그램 날짜`), { + target: { value: '2026-07-24' }, + }); + fireEvent.change(screen.getByLabelText(`${index}번 프로그램 시작 시간`), { + target: { value: '14:00' }, + }); + fireEvent.change(screen.getByLabelText(`${index}번 프로그램 프로그램 설명`), { + target: { value: '프로그램 설명' }, + }); +} diff --git a/src/pages/curation/schema-driven/__tests__/program-spec.fixture.ts b/src/pages/curation/schema-driven/__tests__/program-spec.fixture.ts new file mode 100644 index 0000000..ddd13c5 --- /dev/null +++ b/src/pages/curation/schema-driven/__tests__/program-spec.fixture.ts @@ -0,0 +1,139 @@ +import type { CurationV2Spec } from '@/types/api'; + +export const programSpec: CurationV2Spec = { + id: 4, + code: 'PROGRAM', + name: '프로그램', + description: '여러 프로그램과 프로그램별 위스키 라인업을 발행합니다.', + hydratorKey: 'alcohol', + version: 1, + isActive: true, + requestSpec: { + type: 'object', + required: ['eventStartDate', 'eventEndDate', 'placeName', 'address', 'programs'], + properties: { + eventStartDate: { + type: 'string', + format: 'date', + 'x-display-name': '행사 시작일', + }, + eventEndDate: { + type: 'string', + format: 'date', + 'x-display-name': '행사 종료일', + }, + placeName: { + type: 'string', + maxLength: 100, + 'x-field-style': 'plain-text', + 'x-display-name': '장소명', + }, + address: { + type: 'string', + maxLength: 200, + 'x-field-style': 'plain-text', + 'x-display-name': '장소 및 주소', + }, + entryFee: { + type: 'integer', + minimum: 0, + nullable: true, + 'x-display-name': '참가비', + }, + programTags: { + type: 'array', + maxItems: 2, + 'x-display-name': '프로그램 태그', + items: { + type: 'string', + enum: ['WHISKY', 'COCKTAIL', 'BEER'], + }, + }, + programs: { + type: 'array', + minItems: 1, + maxItems: 20, + 'x-display-name': '프로그램', + items: { + type: 'object', + required: ['name', 'type', 'programDate', 'startTime', 'description'], + properties: { + name: { + type: 'string', + minLength: 1, + maxLength: 120, + 'x-display-name': '프로그램명', + }, + type: { + type: 'string', + enum: ['MASTER_CLASS', 'TASTING', 'SEMINAR'], + 'x-display-name': '프로그램 유형', + }, + programDate: { + type: 'string', + format: 'date', + 'x-display-name': '날짜', + }, + startTime: { + type: 'string', + format: 'time', + 'x-display-name': '시작 시간', + }, + endTime: { + type: 'string', + format: 'time', + nullable: true, + 'x-display-name': '종료 시간', + }, + description: { + type: 'string', + minLength: 1, + maxLength: 1000, + 'x-field-style': 'long-text', + 'x-display-name': '프로그램 설명', + }, + whiskies: { + type: 'array', + maxItems: 10, + 'x-field-style': 'alcohol-card-list', + 'x-display-name': '시음 위스키', + items: { + type: 'object', + required: ['source', 'alcohol'], + properties: { + source: { + type: 'string', + enum: ['BOTTLE_NOTE', 'MANUAL'], + }, + alcohol: { + type: 'object', + required: ['korName', 'selectedTags'], + properties: { + alcoholId: { type: 'integer', nullable: true }, + korName: { type: 'string', maxLength: 100 }, + selectedTags: { + type: 'array', + maxItems: 12, + items: { type: 'string' }, + 'x-display-name': '테이스팅 태그', + }, + }, + }, + comment: { + type: 'string', + maxLength: 500, + nullable: true, + 'x-display-name': '위스키 기대평', + }, + }, + }, + }, + }, + }, + }, + }, + }, + responseSpec: { + type: 'object', + }, +}; diff --git a/src/pages/curation/schema-driven/__tests__/schema-driven-curation.form-model.test.ts b/src/pages/curation/schema-driven/__tests__/schema-driven-curation.form-model.test.ts new file mode 100644 index 0000000..d549abc --- /dev/null +++ b/src/pages/curation/schema-driven/__tests__/schema-driven-curation.form-model.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest'; + +import type { CurationV2Spec } from '@/types/api'; + +import { createSchemaDrivenCurationFormModel } from '../schema-driven-curation.form-model'; +import { programSpec } from './program-spec.fixture'; + +describe('createSchemaDrivenCurationFormModel', () => { + it('object requestSpec의 primitive, enum, enum array, object array를 모델로 만든다', () => { + const model = createSchemaDrivenCurationFormModel(programSpec); + + expect(model.payloadFields.map((field) => [field.key, field.kind])).toEqual([ + ['eventStartDate', 'date'], + ['eventEndDate', 'date'], + ['placeName', 'text'], + ['address', 'text'], + ['entryFee', 'number'], + ['programTags', 'multi-select'], + ['programs', 'object-array'], + ]); + + const programTags = model.payloadFields.find((field) => field.key === 'programTags'); + expect(programTags).toMatchObject({ + kind: 'multi-select', + maxItems: 2, + options: [ + { value: 'WHISKY', label: 'Whisky' }, + { value: 'COCKTAIL', label: 'Cocktail' }, + { value: 'BEER', label: 'Beer' }, + ], + }); + }); + + it('object array 안의 enum과 alcohol-card-list를 재귀 모델로 만든다', () => { + const model = createSchemaDrivenCurationFormModel(programSpec); + const programs = model.payloadFields.find((field) => field.key === 'programs'); + + expect(programs).toMatchObject({ + kind: 'object-array', + minItems: 1, + maxItems: 20, + }); + + if (!programs || programs.kind !== 'object-array') { + throw new Error('programs object-array 모델이 필요합니다.'); + } + + expect(programs.itemFields.map((field) => [field.key, field.kind])).toEqual([ + ['name', 'text'], + ['type', 'select'], + ['programDate', 'date'], + ['startTime', 'time'], + ['endTime', 'time'], + ['description', 'textarea'], + ['whiskies', 'alcohol-card-list'], + ]); + expect(programs.itemFields.find((field) => field.key === 'whiskies')).toMatchObject({ + label: '시음 위스키', + minItems: 0, + maxItems: 10, + }); + }); + + it('지원하지 않는 object 단일 필드는 명시적으로 실패한다', () => { + const unsupportedSpec: CurationV2Spec = { + ...programSpec, + requestSpec: { + type: 'object', + properties: { + metadata: { + type: 'object', + properties: { + value: { type: 'string' }, + }, + }, + }, + }, + }; + + expect(() => createSchemaDrivenCurationFormModel(unsupportedSpec)).toThrow( + '지원하지 않는 큐레이션 스키마 필드' + ); + }); +}); diff --git a/src/pages/curation/schema-driven/__tests__/schema-driven-curation.schema.test.ts b/src/pages/curation/schema-driven/__tests__/schema-driven-curation.schema.test.ts new file mode 100644 index 0000000..58f797c --- /dev/null +++ b/src/pages/curation/schema-driven/__tests__/schema-driven-curation.schema.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from 'vitest'; + +import { createSchemaDrivenCurationFormModel } from '../schema-driven-curation.form-model'; +import { + createDefaultSchemaDrivenCurationFormState, + createSchemaDrivenCurationFormSchema, +} from '../schema-driven-curation.schema'; +import { programSpec } from './program-spec.fixture'; + +describe('schema driven curation validation', () => { + const formModel = createSchemaDrivenCurationFormModel(programSpec); + const schema = createSchemaDrivenCurationFormSchema(formModel); + + it('required root field와 programs minItems를 검증한다', () => { + const result = schema.safeParse(createDefaultSchemaDrivenCurationFormState(formModel)); + + expect(result.success).toBe(false); + if (result.success) return; + + expect(result.error.flatten().fieldErrors.eventStartDate).toContain( + '행사 시작일은 필수입니다.' + ); + expect(result.error.flatten().fieldErrors.programs).toContain( + '프로그램을 최소 1개 이상 추가해주세요.' + ); + }); + + it('enum array maxItems와 enum 값을 검증한다', () => { + const values = createValidValues(); + values.programTags = ['WHISKY', 'COCKTAIL', 'BEER']; + values.programs[0]!.type = 'UNKNOWN'; + + const result = schema.safeParse(values); + + expect(result.success).toBe(false); + if (result.success) return; + + expect(result.error.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + path: ['programTags'], + message: '프로그램 태그는 최대 2개까지 선택할 수 있습니다.', + }), + expect.objectContaining({ + path: ['programs', 0, 'type'], + }), + ]) + ); + }); + + it('행사 종료일과 프로그램 종료 시간의 순서를 검증한다', () => { + const values = createValidValues(); + values.eventEndDate = '2026-07-23'; + values.programs[0]!.endTime = '13:00'; + + const result = schema.safeParse(values); + + expect(result.success).toBe(false); + if (result.success) return; + + expect(result.error.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + path: ['eventEndDate'], + message: '행사 종료일은 행사 시작일보다 빠를 수 없습니다.', + }), + expect.objectContaining({ + path: ['programs', 0, 'endTime'], + message: '종료 시간은 시작 시간보다 빠를 수 없습니다.', + }), + ]) + ); + }); + + function createValidValues() { + return { + ...createDefaultSchemaDrivenCurationFormState(formModel), + name: '2026 바쇼', + description: '프로그램 소개', + exposureStartDate: '2026-07-01', + exposureEndDate: '2026-07-31', + eventStartDate: '2026-07-24', + eventEndDate: '2026-07-26', + placeName: '코엑스', + address: '서울 강남구 영동대로 513', + programTags: [] as string[], + programs: [ + { + name: '마스터클래스', + type: 'MASTER_CLASS', + programDate: '2026-07-24', + startTime: '14:00', + endTime: '15:30', + description: '브랜드 앰버서더와 함께합니다.', + whiskies: [], + }, + ], + }; + } +}); diff --git a/src/pages/curation/schema-driven/schema-driven-curation.form-model.ts b/src/pages/curation/schema-driven/schema-driven-curation.form-model.ts new file mode 100644 index 0000000..7dd6a7a --- /dev/null +++ b/src/pages/curation/schema-driven/schema-driven-curation.form-model.ts @@ -0,0 +1,72 @@ +import type { CurationV2Spec } from '@/types/api'; + +import { + createCurationFormModelFromRequestSpec, + type CurationFieldModel, + type CurationFormModel, + type CurationFormSectionModel, +} from '../curation-form-model'; + +export interface SchemaDrivenCurationFormModel extends CurationFormModel { + spec: CurationV2Spec; + title: string; + editTitle: string; +} + +export function createSchemaDrivenCurationFormModel( + spec: CurationV2Spec +): SchemaDrivenCurationFormModel { + if (spec.requestSpec.type !== 'object' || spec.requestSpec['x-container'] === 'array') { + throw new Error('지원하지 않는 큐레이션 스키마: object payload만 자동 생성할 수 있습니다.'); + } + + const formModel = createCurationFormModelFromRequestSpec(spec.requestSpec, { + createSections: (fields) => createSchemaDrivenSections(spec, fields), + }); + + return { + ...formModel, + spec, + title: `${spec.name} 작성`, + editTitle: `${spec.name} 수정`, + }; +} + +function createSchemaDrivenSections( + spec: CurationV2Spec, + fields: CurationFieldModel[] +): CurationFormSectionModel[] { + const rootFields = fields.filter((field) => field.kind !== 'object-array'); + const objectArrayFields = fields.filter((field) => field.kind === 'object-array'); + const sections: CurationFormSectionModel[] = []; + + if (rootFields.length > 0) { + sections.push({ + id: 'payload', + title: `${spec.name} 정보`, + stepNumber: 2, + description: `${spec.name} 정보를 입력해주세요.`, + contentClassName: 'grid gap-4 md:grid-cols-2', + fields: rootFields.map((field) => ({ + field, + className: + field.kind === 'textarea' || field.kind === 'multi-select' || field.key.includes('Url') + ? 'md:col-span-2' + : undefined, + })), + }); + } + + objectArrayFields.forEach((field) => { + sections.push({ + id: field.key, + title: field.label, + stepNumber: sections.length + 2, + description: `${field.label} 정보를 입력해주세요.`, + contentClassName: 'space-y-4', + fields: [{ field }], + }); + }); + + return sections; +} diff --git a/src/pages/curation/schema-driven/schema-driven-curation.mapper.ts b/src/pages/curation/schema-driven/schema-driven-curation.mapper.ts new file mode 100644 index 0000000..323f5cf --- /dev/null +++ b/src/pages/curation/schema-driven/schema-driven-curation.mapper.ts @@ -0,0 +1,225 @@ +import type { CurationV2Detail } from '@/types/api'; + +import type { CurationFieldModel, CurationObjectArrayFieldModel } from '../curation-form-model'; +import type { CurationWhiskyCardValue } from '../curation-whisky-card-list.types'; +import { + createDefaultSchemaDrivenCurationFormState, + type SchemaDrivenCurationFormState, +} from './schema-driven-curation.schema'; +import type { SchemaDrivenCurationFormModel } from './schema-driven-curation.form-model'; + +const WHISKY_TEXT_KEYS = [ + 'korName', + 'engName', + 'imageUrl', + 'abv', + 'cask', + 'volume', + 'regionName', + 'korCategory', +] as const; + +export function createSchemaDrivenCurationFormStateFromCuration( + curation: CurationV2Detail, + formModel: SchemaDrivenCurationFormModel +): SchemaDrivenCurationFormState { + const formState = createDefaultSchemaDrivenCurationFormState(formModel); + const payload = isRecord(curation.payload) ? curation.payload : {}; + + formState.name = curation.name; + formState.description = curation.description ?? ''; + formState.imageUrls = + curation.imageUrls.length > 0 + ? [...curation.imageUrls] + : curation.coverImageUrl + ? [curation.coverImageUrl] + : []; + formState.exposureStartDate = toInputDate(curation.exposureStartDate); + formState.exposureEndDate = toInputDate(curation.exposureEndDate); + formState.displayOrder = curation.displayOrder; + formState.isActive = curation.isActive; + + for (const field of formModel.payloadFields) { + if (!Object.prototype.hasOwnProperty.call(payload, field.key)) continue; + formState[field.key] = hydrateFieldValue(field, payload[field.key]); + } + + return formState; +} + +export function buildSchemaDrivenCurationPayload( + values: SchemaDrivenCurationFormState, + formModel: SchemaDrivenCurationFormModel +): Record { + return serializeFields(formModel.payloadFields, values); +} + +function hydrateFieldValue(field: CurationFieldModel, value: unknown): unknown { + switch (field.kind) { + case 'object-array': + return Array.isArray(value) + ? value + .filter(isRecord) + .map((item) => + Object.fromEntries( + field.itemFields.map((itemField) => [ + itemField.key, + hydrateFieldValue(itemField, item[itemField.key]), + ]) + ) + ) + : []; + case 'alcohol-card-list': + return hydrateWhiskies(value); + case 'multi-select': + return Array.isArray(value) + ? value.filter( + (item): item is string => + typeof item === 'string' && field.options.some((option) => option.value === item) + ) + : []; + case 'select': + return typeof value === 'string' ? value : ''; + case 'boolean-radio': + return typeof value === 'boolean' ? value : Boolean(value); + case 'number': { + const numberValue = Number(value); + return Number.isFinite(numberValue) ? numberValue : (field.minimum ?? 0); + } + case 'date': + return toInputDate(value); + case 'time': + return toInputTime(value); + case 'textarea': + case 'text': + case 'address': + return value === null || value === undefined ? '' : String(value); + } +} + +function serializeFields( + fields: CurationFieldModel[], + values: Record +): Record { + return fields.reduce>((payload, field) => { + const serializedValue = serializeFieldValue(field, values[field.key]); + if (!field.required && isEmptyOptionalValue(serializedValue)) { + return payload; + } + + payload[field.key] = serializedValue; + return payload; + }, {}); +} + +function serializeFieldValue(field: CurationFieldModel, value: unknown): unknown { + switch (field.kind) { + case 'object-array': + return serializeObjectArray(field, value); + case 'alcohol-card-list': + return Array.isArray(value) ? value.map(serializeWhisky) : []; + case 'multi-select': + return Array.isArray(value) ? value.filter((item) => typeof item === 'string') : []; + case 'number': + case 'boolean-radio': + return value; + case 'select': + case 'textarea': + case 'text': + case 'address': + case 'date': + case 'time': + return typeof value === 'string' ? value.trim() : ''; + } +} + +function serializeObjectArray(field: CurationObjectArrayFieldModel, value: unknown) { + if (!Array.isArray(value)) return []; + + return value.filter(isRecord).map((item) => serializeFields(field.itemFields, item)); +} + +function serializeWhisky(value: unknown) { + const item = isRecord(value) ? value : {}; + const alcoholValue = isRecord(item.alcohol) ? item.alcohol : {}; + const source = item.source === 'BOTTLE_NOTE' ? 'BOTTLE_NOTE' : 'MANUAL'; + const alcohol: Record = { + alcoholId: + source === 'MANUAL' || typeof alcoholValue.alcoholId !== 'number' + ? null + : alcoholValue.alcoholId, + selectedTags: Array.isArray(alcoholValue.selectedTags) + ? alcoholValue.selectedTags + .filter((tag): tag is string => typeof tag === 'string') + .map((tag) => tag.trim()) + .filter(Boolean) + : [], + }; + + WHISKY_TEXT_KEYS.forEach((key) => { + const text = typeof alcoholValue[key] === 'string' ? alcoholValue[key].trim() : ''; + if (key === 'korName' || text) { + alcohol[key] = text; + } + }); + + const comment = typeof item.comment === 'string' ? item.comment.trim() : ''; + + return { + source, + alcohol, + ...(comment ? { comment } : {}), + }; +} + +function hydrateWhiskies(value: unknown): CurationWhiskyCardValue[] { + if (!Array.isArray(value)) return []; + + return value.filter(isRecord).map((item) => { + const alcohol = isRecord(item.alcohol) ? item.alcohol : {}; + + return { + source: item.source === 'BOTTLE_NOTE' ? 'BOTTLE_NOTE' : 'MANUAL', + alcohol: { + alcoholId: typeof alcohol.alcoholId === 'number' ? alcohol.alcoholId : null, + korName: normalizeText(alcohol.korName), + engName: normalizeText(alcohol.engName), + imageUrl: normalizeText(alcohol.imageUrl), + abv: normalizeText(alcohol.abv), + cask: normalizeText(alcohol.cask), + volume: normalizeText(alcohol.volume), + regionName: normalizeText(alcohol.regionName), + korCategory: normalizeText(alcohol.korCategory), + selectedTags: Array.isArray(alcohol.selectedTags) + ? alcohol.selectedTags.map(normalizeText).filter(Boolean) + : [], + }, + comment: normalizeText(item.comment), + }; + }); +} + +function isEmptyOptionalValue(value: unknown) { + return ( + value === '' || + value === null || + value === undefined || + (Array.isArray(value) && value.length === 0) + ); +} + +function normalizeText(value: unknown) { + return value === null || value === undefined ? '' : String(value); +} + +function toInputDate(value: unknown) { + return normalizeText(value).slice(0, 10); +} + +function toInputTime(value: unknown) { + return normalizeText(value).slice(0, 5); +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} diff --git a/src/pages/curation/schema-driven/schema-driven-curation.schema.ts b/src/pages/curation/schema-driven/schema-driven-curation.schema.ts new file mode 100644 index 0000000..57af503 --- /dev/null +++ b/src/pages/curation/schema-driven/schema-driven-curation.schema.ts @@ -0,0 +1,126 @@ +import { z } from 'zod'; + +import { compareDateInputValues } from '@/lib/date-validation'; + +import { + createCurationFieldValueSchema, + createDefaultCurationFieldValue, +} from '../curation-form-schema'; +import type { SchemaDrivenCurationFormModel } from './schema-driven-curation.form-model'; + +export interface SchemaDrivenCurationFormState { + name: string; + description: string; + imageUrls: string[]; + exposureStartDate: string; + exposureEndDate: string; + displayOrder: number; + isActive: boolean; + [key: string]: unknown; +} + +export function createDefaultSchemaDrivenCurationFormState( + formModel: SchemaDrivenCurationFormModel +): SchemaDrivenCurationFormState { + const formState: SchemaDrivenCurationFormState = { + name: '', + description: '', + imageUrls: [], + exposureStartDate: '', + exposureEndDate: '', + displayOrder: 0, + isActive: true, + }; + + for (const field of formModel.payloadFields) { + formState[field.key] = createDefaultCurationFieldValue(field); + } + + return formState; +} + +export function createSchemaDrivenCurationFormSchema( + formModel: SchemaDrivenCurationFormModel, + options: { mode?: 'create' | 'edit' } = {} +): z.ZodType { + const isEditMode = options.mode === 'edit'; + const payloadShape = formModel.payloadFields.reduce>>( + (shape, field) => { + shape[field.key] = createCurationFieldValueSchema(field); + return shape; + }, + {} + ); + + return z + .object({ + name: z.string().min(1, '큐레이션명은 필수입니다.'), + description: isEditMode ? z.string() : z.string().min(1, '설명은 필수입니다.'), + imageUrls: z.array(z.string()).max(3, '이미지는 최대 3개까지 등록할 수 있습니다.'), + exposureStartDate: isEditMode + ? z.string() + : z.string().min(1, '광고노출 시작일은 필수입니다.'), + exposureEndDate: isEditMode ? z.string() : z.string().min(1, '광고노출 종료일은 필수입니다.'), + displayOrder: z + .number() + .int('노출 순서는 정수로 입력해주세요.') + .min(0, '노출 순서는 0 이상이어야 합니다.'), + isActive: z.boolean(), + ...payloadShape, + }) + .superRefine((values, context) => { + const formValues = values as Record; + addDateOrderIssue( + values.exposureStartDate, + values.exposureEndDate, + ['exposureEndDate'], + '광고노출 종료일은 광고노출 시작일보다 빠를 수 없습니다.', + context + ); + + const eventStartDate = + typeof formValues.eventStartDate === 'string' ? formValues.eventStartDate : ''; + const eventEndDate = + typeof formValues.eventEndDate === 'string' ? formValues.eventEndDate : ''; + addDateOrderIssue( + eventStartDate, + eventEndDate, + ['eventEndDate'], + '행사 종료일은 행사 시작일보다 빠를 수 없습니다.', + context + ); + + if (!Array.isArray(formValues.programs)) return; + + formValues.programs.forEach((program: unknown, index: number) => { + if (!program || typeof program !== 'object' || Array.isArray(program)) return; + + const record = program as Record; + const startTime = typeof record.startTime === 'string' ? record.startTime : ''; + const endTime = typeof record.endTime === 'string' ? record.endTime : ''; + addDateOrderIssue( + startTime, + endTime, + ['programs', index, 'endTime'], + '종료 시간은 시작 시간보다 빠를 수 없습니다.', + context + ); + }); + }) as unknown as z.ZodType; +} + +function addDateOrderIssue( + start: string, + end: string, + path: Array, + message: string, + context: z.RefinementCtx +) { + if (!start || !end || compareDateInputValues(end, start) >= 0) return; + + context.addIssue({ + code: z.ZodIssueCode.custom, + path, + message, + }); +} diff --git a/src/pages/curation/useCurationSpecFormModel.ts b/src/pages/curation/useCurationSpecFormModel.ts deleted file mode 100644 index 8237aef..0000000 --- a/src/pages/curation/useCurationSpecFormModel.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { useCurationSpec, useCurationSpecByCode } from '@/hooks/useCurations'; -import type { CurationV2Spec, CurationV2SpecCode } from '@/types/api'; - -interface UseCurationSpecFormModelOptions { - specCode: CurationV2SpecCode; - createFormModel: (spec: CurationV2Spec) => TFormModel; - showErrorToast?: boolean; -} - -// 큐레이션 스펙 목록/상세 조회 후 타입별 form model 생성 함수로 연결합니다. -export function useCurationSpecFormModel({ - specCode, - createFormModel, - showErrorToast = false, -}: UseCurationSpecFormModelOptions) { - const specsQuery = useCurationSpecByCode(specCode); - const targetSpec = specsQuery.data?.isActive ? specsQuery.data : null; - const specDetailQuery = useCurationSpec(targetSpec?.id, { - showErrorToast, - }); - const specDetail = specDetailQuery.data; - const formModel = specDetail ? createFormModel(specDetail) : null; - - return { - specsQuery, - targetSpec, - specDetailQuery, - specDetail, - formModel, - }; -} diff --git a/src/pages/curation/whisky-curation/WhiskyCurationCreateGate.tsx b/src/pages/curation/whisky-curation/WhiskyCurationCreateGate.tsx deleted file mode 100644 index 164f327..0000000 --- a/src/pages/curation/whisky-curation/WhiskyCurationCreateGate.tsx +++ /dev/null @@ -1,172 +0,0 @@ -import type { ReactNode } from 'react'; -import { AlertCircle, Loader2 } from 'lucide-react'; -import { useNavigate } from 'react-router'; - -import { DetailPageHeader } from '@/components/common/DetailPageHeader'; -import { Button } from '@/components/ui/button'; -import { Card, CardContent } from '@/components/ui/card'; -import type { CurationV2Spec, CurationV2SpecCode } from '@/types/api'; - -import { useCurationSpecFormModel } from '../useCurationSpecFormModel'; -import { - createWhiskyCurationFormModel, - type WhiskyCurationFormModel, -} from './whisky-curation.schema'; - -type CreatePageViewState = - | 'specs-loading' - | 'specs-error' - | 'spec-missing' - | 'detail-loading' - | 'detail-error' - | 'form'; - -interface WhiskyCurationCreateGateRenderArgs { - specDetail: CurationV2Spec; - formModel: WhiskyCurationFormModel; - onBack: () => void; -} - -interface WhiskyCurationCreateGateProps { - specCode: CurationV2SpecCode; - fallbackTitle: string; - children: (args: WhiskyCurationCreateGateRenderArgs) => ReactNode; -} - -export function WhiskyCurationCreateGate({ - specCode, - fallbackTitle, - children, -}: WhiskyCurationCreateGateProps) { - const navigate = useNavigate(); - const { specsQuery, targetSpec, specDetailQuery, specDetail, formModel } = - useCurationSpecFormModel({ - specCode, - createFormModel: createWhiskyCurationFormModel, - }); - - const handleBack = () => { - navigate('/dashboard/curations'); - }; - - let viewState: CreatePageViewState = 'form'; - if (specsQuery.isLoading) { - viewState = 'specs-loading'; - } else if (specsQuery.isError) { - viewState = 'specs-error'; - } else if (!targetSpec) { - viewState = 'spec-missing'; - } else if (specDetailQuery.isLoading) { - viewState = 'detail-loading'; - } else if (specDetailQuery.isError) { - viewState = 'detail-error'; - } else if (!specDetail || !formModel) { - viewState = 'detail-loading'; - } - - const renderContent = () => { - switch (viewState) { - case 'specs-loading': - return ; - case 'specs-error': - return ( - void specsQuery.refetch()} - onBack={handleBack} - /> - ); - case 'spec-missing': - return ( - - ); - case 'detail-loading': - return ; - case 'detail-error': - return ( - void specDetailQuery.refetch()} - onBack={handleBack} - /> - ); - case 'form': - if (!specDetail || !formModel) { - return ; - } - - return children({ specDetail, formModel, onBack: handleBack }); - } - }; - - if (viewState === 'form') { - return renderContent(); - } - - return ( -
- - 목록 - - } - /> - - {renderContent()} -
- ); -} - -function LoadingState({ message }: { message: string }) { - return ( - - - - - ); -} - -interface BlockingStateProps { - title: string; - description: string; - onRetry?: () => void; - onBack: () => void; -} - -function BlockingState({ title, description, onRetry, onBack }: BlockingStateProps) { - return ( - - -
-
-
- {onRetry && ( - - )} - -
-
-
- ); -} diff --git a/src/pages/curation/whisky-curation/WhiskyCurationCreatePage.tsx b/src/pages/curation/whisky-curation/WhiskyCurationCreatePage.tsx deleted file mode 100644 index a3cebd5..0000000 --- a/src/pages/curation/whisky-curation/WhiskyCurationCreatePage.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import type { CurationV2SpecCode } from '@/types/api'; - -import { WhiskyCurationCreateGate } from './WhiskyCurationCreateGate'; -import { WhiskyCurationForm } from './WhiskyCurationForm'; - -interface WhiskyCurationCreatePageProps { - specCode: CurationV2SpecCode; - fallbackTitle: string; -} - -// 추천/페어링 큐레이션 작성 페이지. 코멘트·페어링 표시 여부는 폼이 스펙에서 판별합니다. -export function WhiskyCurationCreatePage({ - specCode, - fallbackTitle, -}: WhiskyCurationCreatePageProps) { - return ( - - {({ specDetail, formModel, onBack }) => ( - - )} - - ); -} diff --git a/src/pages/curation/whisky-curation/__tests__/WhiskyCurationPages.test.tsx b/src/pages/curation/whisky-curation/__tests__/WhiskyCurationPages.test.tsx index 7cd37e1..7b83018 100644 --- a/src/pages/curation/whisky-curation/__tests__/WhiskyCurationPages.test.tsx +++ b/src/pages/curation/whisky-curation/__tests__/WhiskyCurationPages.test.tsx @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { fireEvent, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { http, HttpResponse } from 'msw'; @@ -14,19 +14,28 @@ import type { CurationV2UpdateRequest, } from '@/types/api'; -import { WhiskyCurationCreatePage } from '../WhiskyCurationCreatePage'; +import { CurationCreatePage } from '../../CurationCreate'; import { WhiskyCurationEditPage } from '../WhiskyCurationEditPage'; +const routeState = vi.hoisted(() => ({ specCode: 'RECOMMENDED_WHISKY' })); + +vi.mock('react-router', async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...actual, + useParams: () => ({ specCode: routeState.specCode }), + }; +}); + function renderRecommendedCreatePage() { - return render( - - ); + routeState.specCode = 'RECOMMENDED_WHISKY'; + return render(); } function renderPairingCreatePage() { - return render( - - ); + routeState.specCode = 'WHISKY_PAIRING'; + return render(); } const SPEC_BASE = '/admin/api/v2/curation-specs'; diff --git a/src/pages/curation/whisky-tasting-event/CurationWhiskyTastingEventCreate.tsx b/src/pages/curation/whisky-tasting-event/CurationWhiskyTastingEventCreate.tsx deleted file mode 100644 index 7ad350a..0000000 --- a/src/pages/curation/whisky-tasting-event/CurationWhiskyTastingEventCreate.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { WhiskyTastingEventCreateGate } from './WhiskyTastingEventCreateGate'; -import { WhiskyTastingEventForm } from './WhiskyTastingEventForm'; - -export function CurationWhiskyTastingEventCreatePage() { - return ( - - {({ specDetail, formModel, onBack }) => ( - - )} - - ); -} diff --git a/src/pages/curation/whisky-tasting-event/WhiskyTastingEventCreateGate.tsx b/src/pages/curation/whisky-tasting-event/WhiskyTastingEventCreateGate.tsx deleted file mode 100644 index bbc2155..0000000 --- a/src/pages/curation/whisky-tasting-event/WhiskyTastingEventCreateGate.tsx +++ /dev/null @@ -1,166 +0,0 @@ -import type { ReactNode } from 'react'; -import { AlertCircle, Loader2 } from 'lucide-react'; -import { useNavigate } from 'react-router'; - -import { DetailPageHeader } from '@/components/common/DetailPageHeader'; -import { Button } from '@/components/ui/button'; -import { Card, CardContent } from '@/components/ui/card'; -import { CurationSpecCode, type CurationV2Spec } from '@/types/api'; - -import { useCurationSpecFormModel } from '../useCurationSpecFormModel'; -import { - createWhiskyTastingEventFormModel, - type WhiskyTastingEventFormModel, -} from './whisky-tasting-event.form-model'; - -type CreatePageViewState = - | 'specs-loading' - | 'specs-error' - | 'spec-missing' - | 'detail-loading' - | 'detail-error' - | 'form'; - -interface WhiskyTastingEventCreateGateRenderArgs { - specDetail: CurationV2Spec; - formModel: WhiskyTastingEventFormModel; - onBack: () => void; -} - -interface WhiskyTastingEventCreateGateProps { - children: (args: WhiskyTastingEventCreateGateRenderArgs) => ReactNode; -} - -export function WhiskyTastingEventCreateGate({ children }: WhiskyTastingEventCreateGateProps) { - const navigate = useNavigate(); - const { specsQuery, targetSpec, specDetailQuery, specDetail, formModel } = - useCurationSpecFormModel({ - specCode: CurationSpecCode.WHISKY_TASTING_EVENT, - createFormModel: createWhiskyTastingEventFormModel, - }); - - const handleBack = () => { - navigate('/dashboard/curations'); - }; - - let viewState: CreatePageViewState = 'form'; - if (specsQuery.isLoading) { - viewState = 'specs-loading'; - } else if (specsQuery.isError) { - viewState = 'specs-error'; - } else if (!targetSpec) { - viewState = 'spec-missing'; - } else if (specDetailQuery.isLoading) { - viewState = 'detail-loading'; - } else if (specDetailQuery.isError) { - viewState = 'detail-error'; - } else if (!specDetail || !formModel) { - viewState = 'detail-loading'; - } - - const renderContent = () => { - switch (viewState) { - case 'specs-loading': - return ; - case 'specs-error': - return ( - void specsQuery.refetch()} - onBack={handleBack} - /> - ); - case 'spec-missing': - return ( - - ); - case 'detail-loading': - return ; - case 'detail-error': - return ( - void specDetailQuery.refetch()} - onBack={handleBack} - /> - ); - case 'form': - if (!specDetail || !formModel) { - return ; - } - - return children({ specDetail, formModel, onBack: handleBack }); - } - }; - - if (viewState === 'form') { - return renderContent(); - } - - return ( -
- - 목록 - - } - /> - - {renderContent()} -
- ); -} - -function LoadingState({ message }: { message: string }) { - return ( - - - - - ); -} - -interface BlockingStateProps { - title: string; - description: string; - onRetry?: () => void; - onBack: () => void; -} - -function BlockingState({ title, description, onRetry, onBack }: BlockingStateProps) { - return ( - - -
-
-
- {onRetry && ( - - )} - -
-
-
- ); -} diff --git a/src/pages/curation/whisky-tasting-event/__tests__/CurationWhiskyTastingEventCreate.test.tsx b/src/pages/curation/whisky-tasting-event/__tests__/CurationWhiskyTastingEventCreate.test.tsx index 2e80b72..ed8b821 100644 --- a/src/pages/curation/whisky-tasting-event/__tests__/CurationWhiskyTastingEventCreate.test.tsx +++ b/src/pages/curation/whisky-tasting-event/__tests__/CurationWhiskyTastingEventCreate.test.tsx @@ -9,7 +9,16 @@ import { wrapApiError, wrapApiResponse } from '@/test/mocks/data'; import { useAuthStore } from '@/stores/auth'; import type { CurationV2CreateRequest, CurationV2Spec } from '@/types/api'; -import { CurationWhiskyTastingEventCreatePage } from '../CurationWhiskyTastingEventCreate'; +import { CurationCreatePage } from '../../CurationCreate'; + +vi.mock('react-router', async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...actual, + useParams: () => ({ specCode: 'WHISKY_TASTING_EVENT' }), + }; +}); const SPEC_BASE = '/admin/api/v2/curation-specs'; const CURATION_BASE = '/admin/api/v2/curations'; @@ -279,7 +288,7 @@ async function typeTastingTagSearch( return searchInput; } -describe('CurationWhiskyTastingEventCreatePage', () => { +describe('CurationCreatePage whisky tasting event strategy', () => { beforeEach(() => { setCurrentUserRoles([]); Object.defineProperty(URL, 'createObjectURL', { @@ -303,7 +312,7 @@ describe('CurationWhiskyTastingEventCreatePage', () => { const scrollIntoViewSpy = vi.spyOn(Element.prototype, 'scrollIntoView'); mockSpecSuccess(); - render(); + render(); const nameInput = await screen.findByLabelText('큐레이션명'); await user.click(screen.getByRole('button', { name: '저장' })); @@ -321,7 +330,7 @@ describe('CurationWhiskyTastingEventCreatePage', () => { it('스펙 목록과 상세 조회 후 시음회 작성 폼을 렌더링한다', async () => { mockSpecSuccess(); - render(); + render(); expect(await screen.findByRole('heading', { name: '시음회 작성' })).toBeInTheDocument(); expect(await screen.findByText('기본정보')).toBeInTheDocument(); @@ -363,7 +372,7 @@ describe('CurationWhiskyTastingEventCreatePage', () => { const user = userEvent.setup(); mockSpecSuccess(); - render(); + render(); const capacityInput = await screen.findByLabelText('총 모집 인원수'); const undecidedCheckbox = screen.getByRole('checkbox', { name: '모집 인원 미정' }); @@ -414,7 +423,7 @@ describe('CurationWhiskyTastingEventCreatePage', () => { ); }); - render(); + render(); const placeNameInput = await screen.findByLabelText('장소명'); await user.click(placeNameInput); @@ -449,7 +458,7 @@ describe('CurationWhiskyTastingEventCreatePage', () => { const user = userEvent.setup(); mockSpecSuccess(createTastingEventSpec({ alcoholsMaxItems: 1 })); - render(); + render(); expect(await screen.findByText(/1-1개까지 등록할 수 있습니다/)).toBeInTheDocument(); @@ -465,7 +474,7 @@ describe('CurationWhiskyTastingEventCreatePage', () => { const user = userEvent.setup(); mockSpecSuccess(); - render(); + render(); await user.click(await screen.findByRole('button', { name: '시음 위스키 추가' })); await user.click(screen.getByRole('button', { name: '직접 입력' })); @@ -483,7 +492,7 @@ describe('CurationWhiskyTastingEventCreatePage', () => { const user = userEvent.setup(); mockSpecSuccess(); - render(); + render(); await user.click(await screen.findByRole('button', { name: '시음 위스키 추가' })); let whiskySearchInput = await screen.findByPlaceholderText('위스키 검색 ...'); @@ -539,7 +548,7 @@ describe('CurationWhiskyTastingEventCreatePage', () => { const user = userEvent.setup(); mockSpecSuccess(); - render(); + render(); await user.click(await screen.findByRole('button', { name: '시음 위스키 추가' })); const whiskySearchInput = await screen.findByPlaceholderText('위스키 검색 ...'); @@ -571,7 +580,7 @@ describe('CurationWhiskyTastingEventCreatePage', () => { setCurrentUserRoles(['ROOT_ADMIN']); mockSpecSuccess(); - render(); + render(); expect(await screen.findByText('관리자 전용 설정')).toBeInTheDocument(); expect(screen.getByLabelText('노출 순서')).not.toBeDisabled(); @@ -583,7 +592,7 @@ describe('CurationWhiskyTastingEventCreatePage', () => { it('관리자가 아니면 관리자 전용 설정 조작 시 안내를 표시한다', async () => { mockSpecSuccess(); - render(); + render(); const displayOrderInput = await screen.findByLabelText('노출 순서'); @@ -598,7 +607,7 @@ describe('CurationWhiskyTastingEventCreatePage', () => { const user = userEvent.setup(); mockSpecSuccess(); - render(); + render(); expect(await screen.findByText('시음회 참여자를 모집할 목적이신가요?')).toBeInTheDocument(); expect(screen.getByRole('radio', { name: '네' })).toHaveAttribute('aria-checked', 'true'); @@ -632,7 +641,7 @@ describe('CurationWhiskyTastingEventCreatePage', () => { const user = userEvent.setup(); mockSpecSuccess(); - render(); + render(); expect(await screen.findByLabelText('신청링크')).toBeInTheDocument(); expect(screen.getByRole('radio', { name: '네' })).toHaveAttribute('aria-checked', 'true'); @@ -645,7 +654,7 @@ describe('CurationWhiskyTastingEventCreatePage', () => { it('광고노출 시작일이나 종료일이 과거 날짜여도 오늘 날짜 기준 validation을 표시하지 않는다', async () => { mockSpecSuccess(); - render(); + render(); await screen.findByLabelText('광고노출 시작일'); @@ -665,7 +674,7 @@ describe('CurationWhiskyTastingEventCreatePage', () => { it('광고노출 종료일이 시작일보다 빠르면 저장 전 validation을 표시한다', async () => { mockSpecSuccess(); - render(); + render(); await screen.findByLabelText('광고노출 시작일'); @@ -682,7 +691,7 @@ describe('CurationWhiskyTastingEventCreatePage', () => { mockSpecSuccess(); mockImageUpload(); - render(); + render(); const imageFileInput = await screen.findByLabelText('큐레이션 이미지 파일 선택'); @@ -734,11 +743,9 @@ describe('CurationWhiskyTastingEventCreatePage', () => { ) ); - render(); + render(); - expect( - await screen.findByText('시음회 스펙 상세를 불러올 권한이 없습니다.') - ).toBeInTheDocument(); + expect(await screen.findByText('큐레이션 스펙을 불러오지 못했습니다.')).toBeInTheDocument(); expect(screen.queryByLabelText('큐레이션명')).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: /저장/ })).not.toBeInTheDocument(); }); @@ -747,7 +754,7 @@ describe('CurationWhiskyTastingEventCreatePage', () => { const user = userEvent.setup(); mockSpecSuccess(); - render(); + render(); await screen.findByLabelText('큐레이션명'); @@ -801,7 +808,7 @@ describe('CurationWhiskyTastingEventCreatePage', () => { }) ); - render(); + render(); await screen.findByLabelText('큐레이션명'); @@ -925,7 +932,7 @@ describe('CurationWhiskyTastingEventCreatePage', () => { }) ); - render(); + render(); await screen.findByLabelText('큐레이션명'); @@ -1001,7 +1008,7 @@ describe('CurationWhiskyTastingEventCreatePage', () => { }) ); - render(); + render(); await screen.findByLabelText('큐레이션명'); diff --git a/src/pages/curation/whisky-tasting-event/whisky-tasting-event.form-model.ts b/src/pages/curation/whisky-tasting-event/whisky-tasting-event.form-model.ts index 97306bb..9cf86c1 100644 --- a/src/pages/curation/whisky-tasting-event/whisky-tasting-event.form-model.ts +++ b/src/pages/curation/whisky-tasting-event/whisky-tasting-event.form-model.ts @@ -24,6 +24,7 @@ const TASTING_EVENT_PLACE_NAME_FIELD: CurationTextFieldModel = { label: '장소명', required: true, kind: 'text', + usePlaceSearch: true, placeholder: '장소명을 입력하거나 검색하세요.', maxLength: 100, }; @@ -31,7 +32,9 @@ const TASTING_EVENT_PLACE_NAME_FIELD: CurationTextFieldModel = { export type WhiskyTastingEventFormModel = CurationFormModel; // 시음회 스펙을 자동 렌더링 파이프라인의 form model로 변환합니다. -export function createWhiskyTastingEventFormModel(spec: CurationV2Spec): WhiskyTastingEventFormModel { +export function createWhiskyTastingEventFormModel( + spec: CurationV2Spec +): WhiskyTastingEventFormModel { // 1. requestSpec 레이어: 상세 스펙의 requestSpec을 화면 생성의 단일 입력으로 사용합니다. const formModel = createCurationFormModelFromRequestSpec(spec.requestSpec, { // 2. schema parser 레이어: 공통 parser가 JSON Schema와 x-* 메타데이터를 읽습니다. @@ -54,6 +57,8 @@ export function createWhiskyTastingEventFormModel(spec: CurationV2Spec): WhiskyT // 공통 field model에 시음회 화면에서 필요한 라벨, placeholder, 단위 override를 적용합니다. function applyTastingEventFieldOverrides(field: CurationBasicFieldModel): CurationBasicFieldModel { switch (field.key) { + case 'placeName': + return field.kind === 'text' ? { ...field, usePlaceSearch: true } : field; case 'eventTime': return field.kind === 'text' || field.kind === 'time' ? ({ ...field, kind: 'time' } satisfies CurationTextFieldModel) diff --git a/src/routes/index.tsx b/src/routes/index.tsx index ef455ac..95728ef 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -20,8 +20,7 @@ import { BannerCreatePage } from '@/pages/banners/BannerCreate'; import { CurationEntryPage } from '@/pages/curation/CurationEntry'; import { CurationListPage } from '@/pages/curation/CurationList'; import { CurationDetailPage } from '@/pages/curation/CurationDetail'; -import { CurationWhiskyTastingEventCreatePage } from '@/pages/curation/whisky-tasting-event/CurationWhiskyTastingEventCreate'; -import { WhiskyCurationCreatePage } from '@/pages/curation/whisky-curation/WhiskyCurationCreatePage'; +import { CurationCreatePage } from '@/pages/curation/CurationCreate'; import { CurationSpecCode } from '@/types/api'; import { InquiryListPage } from '@/pages/inquiries/InquiryList'; import { PolicyListPage } from '@/pages/policies/PolicyList'; @@ -82,7 +81,10 @@ export function AppRoutes() { path="dashboard/curations/tasting-events/new" element={ - + } /> @@ -90,9 +92,9 @@ export function AppRoutes() { path="dashboard/curations/general/new" element={ - } @@ -101,13 +103,21 @@ export function AppRoutes() { path="dashboard/curations/pairings/new" element={ - } /> + + + + } + />