Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 26 additions & 52 deletions src/pages/curation/components/CurationFormFieldRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -108,6 +109,9 @@ function StandardCurationFormFieldRenderer({
}) {
const form = useFormContext<FieldValues>();
const name = field.key;
if (field.kind === 'hidden') {
return renderStandardInput(field, form, name);
}
const error = form.getFieldState(name, form.formState).error?.message;

return (
Expand Down Expand Up @@ -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 <PlaceNameSearchField field={field} form={form} name={name} />;
}

return <TextInputField field={field} form={form} name={name} />;
}

Expand All @@ -157,6 +157,12 @@ function renderAddressInput({ field, form, name }: StandardFieldRendererProps) {
return <AddressField field={field} form={form} name={name} />;
}

function renderHiddenInput({ field, form, name }: StandardFieldRendererProps) {
if (field.kind !== 'hidden') return null;

return <input type="hidden" {...form.register(name)} />;
}

// textarea field model을 Textarea renderer로 위임합니다.
function renderTextAreaInput({ field, form, name }: StandardFieldRendererProps) {
if (field.kind !== 'textarea') return null;
Expand Down Expand Up @@ -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<typeof useFormContext<FieldValues>>;
name: string;
}) {
const currentValue = useWatch({
control: form.control,
name,
});
const hasPlaceName = typeof currentValue === 'string' && currentValue.trim().length > 0;
function syncPlaceSelection(
form: ReturnType<typeof useFormContext<FieldValues>>,
place: SelectedPlace
) {
const valuesByField = {
placeName: place.placeName,
kakaoPlaceId: place.id,
barAddress: place.address,
address: place.address,
};

return (
<PlaceSearchInput
aria-label={field.ariaLabel ?? field.label}
placeholder={field.placeholder}
maxLength={field.maxLength}
registration={form.register(name)}
readOnly={!hasPlaceName}
openOnInputClick={!hasPlaceName}
onAddressSelect={(next) =>
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 컴포넌트로 렌더링합니다.
Expand Down
Original file line number Diff line number Diff line change
@@ -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 }) => (
<button type="button" onClick={() => onPlaceSelect?.(selectedPlace)}>
장소 선택
</button>
),
}));

function FormStateProbe() {
const values = useWatch<Record<string, string>>();

return <output data-testid="form-values">{JSON.stringify(values)}</output>;
}

function FieldHarness({
field,
defaultValues,
}: {
field: CurationTextFieldModel;
defaultValues: Record<string, string>;
}) {
const form = useForm<Record<string, string>>({ defaultValues });

return (
<FormProvider {...form}>
<CurationFormFieldRenderer field={field} />
<FormStateProbe />
</FormProvider>
);
}

describe('CurationFormFieldRenderer', () => {
it('장소 검색 결과를 request spec에 있는 장소 필드로 함께 매핑한다', async () => {
const user = userEvent.setup();

render(
<FieldHarness
field={{
key: 'placeName',
label: '장소명',
required: false,
kind: 'address',
}}
defaultValues={{
placeName: '',
kakaoPlaceId: '',
barAddress: '',
detailAddress: '',
}}
/>
);

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(
<FieldHarness
field={{
key: 'address',
label: '장소 및 주소',
required: false,
kind: 'address',
}}
defaultValues={{
placeName: '',
kakaoPlaceId: '',
address: '',
detailLocation: '',
}}
/>
);

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(
<FieldHarness
field={{
key: 'kakaoPlaceId',
label: 'Kakao 장소 ID',
required: false,
kind: 'hidden',
}}
defaultValues={{ kakaoPlaceId: '' }}
/>
);

expect(screen.queryByText('Kakao 장소 ID')).not.toBeInTheDocument();
expect(container.querySelector('input[type="hidden"][name="kakaoPlaceId"]')).not.toBeNull();
});
});
7 changes: 5 additions & 2 deletions src/pages/curation/curation-form-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export type CurationFieldKind =
| 'date'
| 'time'
| 'address'
| 'hidden'
| 'number'
| 'boolean-radio'
| 'select'
Expand All @@ -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 {
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -317,6 +319,7 @@ export function createCurationBasicFieldModel(
case 'date':
case 'time':
case 'address':
case 'hidden':
case 'text':
return {
...base,
Expand Down
2 changes: 2 additions & 0 deletions src/pages/curation/curation-form-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,7 @@ export function createCurationFieldValueSchema(field: CurationFieldModel): z.Zod
case 'date':
case 'time':
case 'address':
case 'hidden':
return createTextFieldValueSchema(field);
}
}
Expand All @@ -276,6 +277,7 @@ export function createDefaultCurationFieldValue(field: CurationFieldModel): unkn
case 'textarea':
case 'text':
case 'address':
case 'hidden':
return '';
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Expand Down Expand Up @@ -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() : '';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ const tastingEventSpec: CurationV2Spec = {
name: '위스키 시음회',
description: '시음회 날짜, 장소, 참가 정보와 시음 위스키 라인업',
hydratorKey: 'alcohol',
version: 2,
version: 3_000_001,
isActive: true,
requestSpec: {
type: 'object',
Expand All @@ -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,
Expand Down Expand Up @@ -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',
Expand Down
Loading