From 812846605428c641cc918ac019bc18dda31872dc Mon Sep 17 00:00:00 2001 From: "bottlenote-app[bot]" <289617182+bottlenote-app[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2026 12:42:57 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=81=90=EB=A0=88=EC=9D=B4=EC=85=98=20?= =?UTF-8?q?=EC=9E=A5=EC=86=8C=20=EA=B2=80=EC=83=89=20=EC=8A=A4=ED=8E=99=20?= =?UTF-8?q?=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/CurationFormFieldRenderer.tsx | 78 ++++------ .../CurationFormFieldRenderer.test.tsx | 134 ++++++++++++++++++ src/pages/curation/curation-form-model.ts | 7 +- src/pages/curation/curation-form-schema.ts | 2 + .../schema-driven-curation.form-model.ts | 4 +- .../schema-driven-curation.mapper.ts | 2 + .../whisky-tasting-event.form-model.test.ts | 27 +++- .../whisky-tasting-event.mapper.test.ts | 30 ++++ .../whisky-tasting-event.form-model.ts | 61 +------- .../whisky-tasting-event.mapper.ts | 8 +- .../whisky-tasting-event.schema.ts | 1 + 11 files changed, 239 insertions(+), 115 deletions(-) create mode 100644 src/pages/curation/components/__tests__/CurationFormFieldRenderer.test.tsx create mode 100644 src/pages/curation/whisky-tasting-event/__tests__/whisky-tasting-event.mapper.test.ts diff --git a/src/pages/curation/components/CurationFormFieldRenderer.tsx b/src/pages/curation/components/CurationFormFieldRenderer.tsx index 36ce000..a1650af 100644 --- a/src/pages/curation/components/CurationFormFieldRenderer.tsx +++ b/src/pages/curation/components/CurationFormFieldRenderer.tsx @@ -3,7 +3,7 @@ import type { FieldValues } from 'react-hook-form'; import { useFormContext, useWatch } from 'react-hook-form'; import { FormField } from '@/components/common/FormField'; -import { PlaceSearchInput } from '@/components/common/PlaceSearchInput'; +import { PlaceSearchInput, type SelectedPlace } from '@/components/common/PlaceSearchInput'; import { Checkbox } from '@/components/ui/checkbox'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; @@ -59,6 +59,7 @@ const STANDARD_FIELD_RENDERER_REGISTRY: Record< date: renderTextInput, time: renderTextInput, address: renderAddressInput, + hidden: renderHiddenInput, textarea: renderTextAreaInput, number: renderNumberInput, 'boolean-radio': renderBooleanRadioInput, @@ -108,6 +109,9 @@ function StandardCurationFormFieldRenderer({ }) { const form = useFormContext(); const name = field.key; + if (field.kind === 'hidden') { + return renderStandardInput(field, form, name); + } const error = form.getFieldState(name, form.formState).error?.message; return ( @@ -143,10 +147,6 @@ 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.usePlaceSearch) { - return ; - } - return ; } @@ -157,6 +157,12 @@ function renderAddressInput({ field, form, name }: StandardFieldRendererProps) { return ; } +function renderHiddenInput({ field, form, name }: StandardFieldRendererProps) { + if (field.kind !== 'hidden') return null; + + return ; +} + // textarea field model을 Textarea renderer로 위임합니다. function renderTextAreaInput({ field, form, name }: StandardFieldRendererProps) { if (field.kind !== 'textarea') return null; @@ -302,59 +308,27 @@ function AddressField({ onAddressSelect={(next) => form.setValue(name, next, { shouldDirty: true, shouldValidate: true }) } - onPlaceSelect={(place) => { - if (name !== 'barAddress') return; - - form.setValue('placeName', place.placeName, { - shouldDirty: true, - shouldValidate: true, - }); - }} + onPlaceSelect={(place) => syncPlaceSelection(form, place)} /> ); } -function PlaceNameSearchField({ - field, - form, - name, -}: { - field: CurationTextFieldModel; - form: ReturnType>; - name: string; -}) { - const currentValue = useWatch({ - control: form.control, - name, - }); - const hasPlaceName = typeof currentValue === 'string' && currentValue.trim().length > 0; +function syncPlaceSelection( + form: ReturnType>, + place: SelectedPlace +) { + const valuesByField = { + placeName: place.placeName, + kakaoPlaceId: place.id, + barAddress: place.address, + address: place.address, + }; - return ( - - form.setValue('barAddress', next, { shouldDirty: true, shouldValidate: true }) - } - onPlaceSelect={(place) => { - form.setValue(name, place.placeName, { - shouldDirty: true, - shouldValidate: true, - }); - const detailAddress = form.getValues('detailAddress'); - if (typeof detailAddress === 'string' && detailAddress.trim()) return; + for (const [fieldName, value] of Object.entries(valuesByField)) { + if (typeof form.getValues(fieldName) !== 'string') continue; - form.setValue('detailAddress', place.placeName, { - shouldDirty: true, - shouldValidate: true, - }); - }} - /> - ); + form.setValue(fieldName, value, { shouldDirty: true, shouldValidate: true }); + } } // textarea field model을 Textarea 컴포넌트로 렌더링합니다. diff --git a/src/pages/curation/components/__tests__/CurationFormFieldRenderer.test.tsx b/src/pages/curation/components/__tests__/CurationFormFieldRenderer.test.tsx new file mode 100644 index 0000000..6d5ba55 --- /dev/null +++ b/src/pages/curation/components/__tests__/CurationFormFieldRenderer.test.tsx @@ -0,0 +1,134 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { FormProvider, useForm, useWatch } from 'react-hook-form'; +import { describe, expect, it, vi } from 'vitest'; + +import type { SelectedPlace } from '@/components/common/PlaceSearchInput'; + +import type { CurationTextFieldModel } from '../../curation-form-model'; +import { CurationFormFieldRenderer } from '../CurationFormFieldRenderer'; + +const selectedPlace: SelectedPlace = { + id: '27288225', + placeName: '보틀노트 테이스팅룸', + address: '서울 강남구 테헤란로 123', + roadAddress: '서울 강남구 테헤란로 123', + lotAddress: '서울 강남구 역삼동 123', + longitude: '127.0276', + latitude: '37.4979', + placeUrl: 'https://place.map.kakao.com/27288225', +}; + +vi.mock('@/components/common/PlaceSearchInput', () => ({ + PlaceSearchInput: ({ onPlaceSelect }: { onPlaceSelect?: (place: SelectedPlace) => void }) => ( + + ), +})); + +function FormStateProbe() { + const values = useWatch>(); + + return {JSON.stringify(values)}; +} + +function FieldHarness({ + field, + defaultValues, +}: { + field: CurationTextFieldModel; + defaultValues: Record; +}) { + const form = useForm>({ defaultValues }); + + return ( + + + + + ); +} + +describe('CurationFormFieldRenderer', () => { + it('장소 검색 결과를 request spec에 있는 장소 필드로 함께 매핑한다', async () => { + const user = userEvent.setup(); + + render( + + ); + + await user.click(await screen.findByRole('button', { name: '장소 선택' })); + + await waitFor(() => { + expect(JSON.parse(screen.getByTestId('form-values').textContent ?? '{}')).toMatchObject({ + placeName: '보틀노트 테이스팅룸', + kakaoPlaceId: '27288225', + barAddress: '서울 강남구 테헤란로 123', + detailAddress: '', + }); + }); + }); + + it('프로그램 주소 검색도 같은 선택값을 address와 Kakao 장소 ID에 매핑한다', async () => { + const user = userEvent.setup(); + + render( + + ); + + await user.click(await screen.findByRole('button', { name: '장소 선택' })); + + await waitFor(() => { + expect(JSON.parse(screen.getByTestId('form-values').textContent ?? '{}')).toMatchObject({ + placeName: '보틀노트 테이스팅룸', + kakaoPlaceId: '27288225', + address: '서울 강남구 테헤란로 123', + detailLocation: '', + }); + }); + }); + + it('hidden field는 라벨 없이 전송용 input으로만 렌더링한다', () => { + const { container } = render( + + ); + + expect(screen.queryByText('Kakao 장소 ID')).not.toBeInTheDocument(); + expect(container.querySelector('input[type="hidden"][name="kakaoPlaceId"]')).not.toBeNull(); + }); +}); diff --git a/src/pages/curation/curation-form-model.ts b/src/pages/curation/curation-form-model.ts index 9137ebf..d180b60 100644 --- a/src/pages/curation/curation-form-model.ts +++ b/src/pages/curation/curation-form-model.ts @@ -8,6 +8,7 @@ export type CurationFieldKind = | 'date' | 'time' | 'address' + | 'hidden' | 'number' | 'boolean-radio' | 'select' @@ -29,10 +30,9 @@ export interface CurationBaseFieldModel { } export interface CurationTextFieldModel extends CurationBaseFieldModel { - kind: 'text' | 'textarea' | 'date' | 'time' | 'address'; + kind: 'text' | 'textarea' | 'date' | 'time' | 'address' | 'hidden'; minLength?: number; maxLength?: number; - usePlaceSearch?: boolean; } export interface CurationNumberFieldModel extends CurationBaseFieldModel { @@ -240,9 +240,11 @@ function resolveSchemaKind( schema: JsonSchemaNode, fieldStyle?: string ): CurationFieldKind { + if (fieldStyle === 'address-search' && key.toLowerCase().endsWith('placeid')) return 'hidden'; if (fieldStyle === 'long-text') return 'textarea'; if (fieldStyle === 'plain-text') return 'text'; if (fieldStyle === 'address-search') return 'address'; + if (fieldStyle === 'hidden') return 'hidden'; if (fieldStyle === 'alcohol-card-list') return 'alcohol-card-list'; if (fieldStyle === 'time') return 'time'; if (fieldStyle === 'date') return 'date'; @@ -317,6 +319,7 @@ export function createCurationBasicFieldModel( case 'date': case 'time': case 'address': + case 'hidden': case 'text': return { ...base, diff --git a/src/pages/curation/curation-form-schema.ts b/src/pages/curation/curation-form-schema.ts index 8a93132..9fd8db5 100644 --- a/src/pages/curation/curation-form-schema.ts +++ b/src/pages/curation/curation-form-schema.ts @@ -252,6 +252,7 @@ export function createCurationFieldValueSchema(field: CurationFieldModel): z.Zod case 'date': case 'time': case 'address': + case 'hidden': return createTextFieldValueSchema(field); } } @@ -276,6 +277,7 @@ export function createDefaultCurationFieldValue(field: CurationFieldModel): unkn case 'textarea': case 'text': case 'address': + case 'hidden': return ''; } } 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 index 7dd6a7a..d8fa21c 100644 --- a/src/pages/curation/schema-driven/schema-driven-curation.form-model.ts +++ b/src/pages/curation/schema-driven/schema-driven-curation.form-model.ts @@ -36,7 +36,9 @@ function createSchemaDrivenSections( spec: CurationV2Spec, fields: CurationFieldModel[] ): CurationFormSectionModel[] { - const rootFields = fields.filter((field) => field.kind !== 'object-array'); + const rootFields = fields.filter( + (field) => field.kind !== 'object-array' && field.kind !== 'hidden' + ); const objectArrayFields = fields.filter((field) => field.kind === 'object-array'); const sections: CurationFormSectionModel[] = []; diff --git a/src/pages/curation/schema-driven/schema-driven-curation.mapper.ts b/src/pages/curation/schema-driven/schema-driven-curation.mapper.ts index 323f5cf..e9ddfa8 100644 --- a/src/pages/curation/schema-driven/schema-driven-curation.mapper.ts +++ b/src/pages/curation/schema-driven/schema-driven-curation.mapper.ts @@ -93,6 +93,7 @@ function hydrateFieldValue(field: CurationFieldModel, value: unknown): unknown { case 'textarea': case 'text': case 'address': + case 'hidden': return value === null || value === undefined ? '' : String(value); } } @@ -127,6 +128,7 @@ function serializeFieldValue(field: CurationFieldModel, value: unknown): unknown case 'textarea': case 'text': case 'address': + case 'hidden': case 'date': case 'time': return typeof value === 'string' ? value.trim() : ''; diff --git a/src/pages/curation/whisky-tasting-event/__tests__/whisky-tasting-event.form-model.test.ts b/src/pages/curation/whisky-tasting-event/__tests__/whisky-tasting-event.form-model.test.ts index 82d0081..7bea5ba 100644 --- a/src/pages/curation/whisky-tasting-event/__tests__/whisky-tasting-event.form-model.test.ts +++ b/src/pages/curation/whisky-tasting-event/__tests__/whisky-tasting-event.form-model.test.ts @@ -10,7 +10,7 @@ const tastingEventSpec: CurationV2Spec = { name: '위스키 시음회', description: '시음회 날짜, 장소, 참가 정보와 시음 위스키 라인업', hydratorKey: 'alcohol', - version: 2, + version: 3_000_001, isActive: true, requestSpec: { type: 'object', @@ -37,6 +37,21 @@ const tastingEventSpec: CurationV2Spec = { description: '시음회 시간', 'x-display-name': '시음회 시간', }, + placeName: { + type: 'string', + maxLength: 100, + description: '시음회 장소명', + 'x-field-style': 'address-search', + 'x-display-name': '장소명', + }, + kakaoPlaceId: { + type: 'string', + minLength: 1, + maxLength: 20, + description: 'Kakao Places 장소 ID', + 'x-field-style': 'address-search', + 'x-display-name': 'Kakao 장소 ID', + }, barAddress: { type: 'string', maxLength: 200, @@ -140,10 +155,16 @@ describe('createWhiskyTastingEventFormModel', () => { }); expect(fieldsByKey.placeName).toMatchObject({ label: '장소명', - kind: 'text', - required: true, + kind: 'address', + required: false, maxLength: 100, }); + expect(fieldsByKey.kakaoPlaceId).toMatchObject({ + label: 'Kakao 장소 ID', + kind: 'hidden', + required: false, + maxLength: 20, + }); expect(fieldsByKey.detailAddress).toMatchObject({ label: '상세 주소', kind: 'text', diff --git a/src/pages/curation/whisky-tasting-event/__tests__/whisky-tasting-event.mapper.test.ts b/src/pages/curation/whisky-tasting-event/__tests__/whisky-tasting-event.mapper.test.ts new file mode 100644 index 0000000..55cfb69 --- /dev/null +++ b/src/pages/curation/whisky-tasting-event/__tests__/whisky-tasting-event.mapper.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; + +import type { WhiskyTastingEventFormModel } from '../whisky-tasting-event.form-model'; +import { buildWhiskyTastingEventPayload } from '../whisky-tasting-event.mapper'; +import type { WhiskyTastingEventFormState } from '../whisky-tasting-event.schema'; + +const formModel = { + payloadFields: [ + { + key: 'kakaoPlaceId', + label: 'Kakao 장소 ID', + required: false, + kind: 'hidden', + }, + ], +} as unknown as WhiskyTastingEventFormModel; + +describe('buildWhiskyTastingEventPayload', () => { + it('선택하지 않은 optional Kakao 장소 ID는 전송하지 않고 선택값만 전송한다', () => { + const emptyValues = { kakaoPlaceId: '' } as unknown as WhiskyTastingEventFormState; + const selectedValues = { kakaoPlaceId: ' 27288225 ' } as unknown as WhiskyTastingEventFormState; + + expect(buildWhiskyTastingEventPayload(emptyValues, formModel)).not.toHaveProperty( + 'kakaoPlaceId' + ); + expect(buildWhiskyTastingEventPayload(selectedValues, formModel)).toEqual({ + kakaoPlaceId: '27288225', + }); + }); +}); 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 9cf86c1..95cf8c2 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 @@ -19,16 +19,6 @@ const DATE_LOCATION_FIELD_KEYS = [ 'detailAddress', ]; -const TASTING_EVENT_PLACE_NAME_FIELD: CurationTextFieldModel = { - key: 'placeName', - label: '장소명', - required: true, - kind: 'text', - usePlaceSearch: true, - placeholder: '장소명을 입력하거나 검색하세요.', - maxLength: 100, -}; - export type WhiskyTastingEventFormModel = CurationFormModel; // 시음회 스펙을 자동 렌더링 파이프라인의 form model로 변환합니다. @@ -36,7 +26,7 @@ export function createWhiskyTastingEventFormModel( spec: CurationV2Spec ): WhiskyTastingEventFormModel { // 1. requestSpec 레이어: 상세 스펙의 requestSpec을 화면 생성의 단일 입력으로 사용합니다. - const formModel = createCurationFormModelFromRequestSpec(spec.requestSpec, { + return createCurationFormModelFromRequestSpec(spec.requestSpec, { // 2. schema parser 레이어: 공통 parser가 JSON Schema와 x-* 메타데이터를 읽습니다. // alcohol-card-list(x-field-style)는 공통 parser가 서버 vocabulary를 직접 인식해 만듭니다. // 3. form/field model 레이어: 공통 필드는 기본 field model로 변환합니다. @@ -44,21 +34,11 @@ export function createWhiskyTastingEventFormModel( // 5. form renderer 준비 레이어: 화면 렌더러가 순회할 섹션 배치를 만듭니다. createSections: createTastingEventFormSections, }); - - const payloadFields = insertTastingEventPlaceNameField(formModel.payloadFields); - - return { - ...formModel, - payloadFields, - sections: createTastingEventFormSections(payloadFields), - }; } // 공통 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) @@ -106,46 +86,15 @@ function applyTastingEventFieldOverrides(field: CurationBasicFieldModel): Curati } } -function insertTastingEventPlaceNameField(fields: CurationFieldModel[]): CurationFieldModel[] { - const existingPlaceNameField = fields.find( - (field) => field.key === TASTING_EVENT_PLACE_NAME_FIELD.key - ); - const placeNameField = existingPlaceNameField - ? ({ ...existingPlaceNameField, required: true } satisfies CurationFieldModel) - : TASTING_EVENT_PLACE_NAME_FIELD; - const fieldsWithoutPlaceName = fields.filter( - (field) => field.key !== TASTING_EVENT_PLACE_NAME_FIELD.key - ); - - const barAddressIndex = fieldsWithoutPlaceName.findIndex((field) => field.key === 'barAddress'); - if (barAddressIndex >= 0) { - return [ - ...fieldsWithoutPlaceName.slice(0, barAddressIndex), - placeNameField, - ...fieldsWithoutPlaceName.slice(barAddressIndex), - ]; - } - - const detailAddressIndex = fieldsWithoutPlaceName.findIndex( - (field) => field.key === 'detailAddress' - ); - if (detailAddressIndex < 0) { - return [...fieldsWithoutPlaceName, placeNameField]; - } - - return [ - ...fieldsWithoutPlaceName.slice(0, detailAddressIndex), - placeNameField, - ...fieldsWithoutPlaceName.slice(detailAddressIndex), - ]; -} - // payload 필드 목록을 시음회 화면의 날짜/참가/위스키 섹션으로 배치합니다. function createTastingEventFormSections(fields: CurationFieldModel[]): CurationFormSectionModel[] { const dateLocationFields = fields.filter((field) => DATE_LOCATION_FIELD_KEYS.includes(field.key)); const alcoholLineupFields = fields.filter((field) => field.kind === 'alcohol-card-list'); const participationFields = fields.filter( - (field) => !DATE_LOCATION_FIELD_KEYS.includes(field.key) && field.kind !== 'alcohol-card-list' + (field) => + !DATE_LOCATION_FIELD_KEYS.includes(field.key) && + field.kind !== 'alcohol-card-list' && + field.kind !== 'hidden' ); return [ diff --git a/src/pages/curation/whisky-tasting-event/whisky-tasting-event.mapper.ts b/src/pages/curation/whisky-tasting-event/whisky-tasting-event.mapper.ts index f652798..e6cf6b1 100644 --- a/src/pages/curation/whisky-tasting-event/whisky-tasting-event.mapper.ts +++ b/src/pages/curation/whisky-tasting-event/whisky-tasting-event.mapper.ts @@ -41,7 +41,12 @@ export function buildWhiskyTastingEventPayload( return payload; } - payload[key] = normalizePayloadValue(value); + const normalizedValue = normalizePayloadValue(value); + if (field.kind === 'hidden' && !field.required && normalizedValue === '') { + return payload; + } + + payload[key] = normalizedValue; return payload; }, {}); } @@ -92,6 +97,7 @@ export function createWhiskyTastingEventFormStateFromCuration( case 'textarea': case 'text': case 'address': + case 'hidden': formState[field.key] = normalizeStringValue(value); break; } diff --git a/src/pages/curation/whisky-tasting-event/whisky-tasting-event.schema.ts b/src/pages/curation/whisky-tasting-event/whisky-tasting-event.schema.ts index 38b0558..7da5a61 100644 --- a/src/pages/curation/whisky-tasting-event/whisky-tasting-event.schema.ts +++ b/src/pages/curation/whisky-tasting-event/whisky-tasting-event.schema.ts @@ -226,6 +226,7 @@ function createDefaultTastingEventPayloadFieldValue(field: CurationFieldModel): case 'textarea': case 'text': case 'address': + case 'hidden': return ''; } }