Skip to content
Open
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
4 changes: 2 additions & 2 deletions src/components/common/PlaceSearchInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ interface PlaceSearchPaginationState {

export interface PlaceSearchInputProps {
registration: UseFormRegisterReturn;
onAddressSelect: (address: string) => void;
onAddressSelect?: (address: string) => void;
onPlaceSelect?: (place: SelectedPlace) => void;
placeholder?: string;
maxLength?: number;
Expand Down Expand Up @@ -189,7 +189,7 @@ export function PlaceSearchInput({

const handleSelectPlace = (place: KakaoPlaceDocument) => {
const address = place.road_address_name || place.address_name;
onAddressSelect(address);
onAddressSelect?.(address);
onPlaceSelect?.({
id: place.id,
placeName: place.place_name,
Expand Down
52 changes: 46 additions & 6 deletions src/components/common/__tests__/PlaceSearchInput.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,11 @@ function setKakaoPlacesMock() {

function Harness({
defaultValue = '',
onAddressSelect = true,
onPlaceSelect,
}: {
defaultValue?: string;
onAddressSelect?: boolean;
onPlaceSelect?: (place: SelectedPlace) => void;
}) {
const { register, setValue } = useForm({ defaultValues: { addr: defaultValue } });
Expand All @@ -57,7 +59,7 @@ function Harness({
<PlaceSearchInput
aria-label="장소"
registration={register('addr')}
onAddressSelect={(addr) => setValue('addr', addr)}
onAddressSelect={onAddressSelect ? (addr) => setValue('addr', addr) : undefined}
onPlaceSelect={onPlaceSelect}
/>
);
Expand Down Expand Up @@ -123,6 +125,45 @@ describe('PlaceSearchInput', () => {
expect(screen.queryByText('도시남 바')).not.toBeInTheDocument();
});

it('주소 변경 콜백 없이도 장소 선택 정보를 전달한다', async () => {
const handlePlaceSelect = vi.fn();
setKakaoPlacesMock();
keywordSearchMock.mockImplementation((_keyword, callback) => {
callback(
[
{
id: '123',
place_name: '도시남 바',
category_name: '음식점 > 술집',
phone: '02-123-4567',
address_name: '서울 강남구 역삼동 123-45',
road_address_name: '서울 강남구 테헤란로 123',
x: '127.0276',
y: '37.4979',
place_url: 'https://place.map.kakao.com/123',
},
],
'OK',
{ ...paginationPage1, last: 1, totalCount: 1, hasNextPage: false }
);
});

render(<Harness onAddressSelect={false} onPlaceSelect={handlePlaceSelect} />);

fireEvent.click(screen.getByRole('button', { name: '장소 검색' }));
fireEvent.change(screen.getByLabelText('장소 검색어'), { target: { value: '도시남' } });
fireEvent.click(screen.getByRole('button', { name: '검색' }));
fireEvent.click(await screen.findByText('도시남 바'));

expect(handlePlaceSelect).toHaveBeenCalledWith(
expect.objectContaining({
id: '123',
placeName: '도시남 바',
address: '서울 강남구 테헤란로 123',
})
);
});

it('검색 결과 하단에서 다음 페이지를 조회한다', async () => {
setKakaoPlacesMock();
keywordSearchMock.mockImplementation((keyword, callback, options) => {
Expand Down Expand Up @@ -180,11 +221,10 @@ describe('PlaceSearchInput', () => {
fireEvent.click(screen.getByRole('button', { name: '다음' }));

expect(await screen.findByText('도시남 라운지')).toBeInTheDocument();
expect(keywordSearchMock).toHaveBeenLastCalledWith(
'도시남',
expect.any(Function),
{ size: 10, page: 2 }
);
expect(keywordSearchMock).toHaveBeenLastCalledWith('도시남', expect.any(Function), {
size: 10,
page: 2,
});
expect(screen.getByText('총 12개 · 2 / 2 페이지')).toBeInTheDocument();
});

Expand Down
23 changes: 23 additions & 0 deletions src/pages/curation/__tests__/CurationDetail.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,11 +56,32 @@ const tastingEventSpec: CurationV2Spec = {
'x-field-style': 'time',
'x-display-name': '시음회 시간',
},
placeName: {
type: 'string',
maxLength: 100,
description: '시음회 장소명',
'x-field-style': 'address-search',
'x-display-name': '장소명',
'x-place-search-targets': {
placeName: 'placeName',
kakaoPlaceId: 'id',
barAddress: 'address',
},
},
kakaoPlaceId: {
type: 'string',
minLength: 1,
maxLength: 20,
description: 'Kakao Places 장소 ID',
'x-field-style': 'hidden',
'x-display-name': 'Kakao 장소 ID',
},
barAddress: {
type: 'string',
maxLength: 200,
description: '장소 및 바 주소',
'x-field-style': 'plain-text',
'x-read-only': true,
'x-display-name': '장소 및 바(bar) 주소',
},
isRecruiting: {
Expand Down Expand Up @@ -210,6 +231,7 @@ describe('CurationDetailPage', () => {
eventDate: '2026-06-15',
eventTime: '19:30',
placeName: '도시남 바',
kakaoPlaceId: '123',
barAddress: '서울 강남구 테헤란로 123',
isRecruiting: true,
entryFee: 50000,
Expand Down Expand Up @@ -312,6 +334,7 @@ describe('CurationDetailPage', () => {
eventDate: '2026-06-15',
eventTime: '19:30',
placeName: '도시남 바',
kakaoPlaceId: '123',
barAddress: '서울 강남구 테헤란로 123',
capacity: 0,
guideText: '수정된 안내사항',
Expand Down
23 changes: 12 additions & 11 deletions src/pages/curation/components/CurationFormFieldRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,7 @@ function TextInputField({
type={field.kind === 'text' ? 'text' : field.kind}
maxLength={field.maxLength}
placeholder={field.placeholder}
readOnly={field.readOnly}
{...form.register(name)}
/>
);
Expand Down Expand Up @@ -305,29 +306,29 @@ function AddressField({
placeholder={field.placeholder}
maxLength={field.maxLength}
registration={form.register(name)}
onAddressSelect={(next) =>
form.setValue(name, next, { shouldDirty: true, shouldValidate: true })
}
onPlaceSelect={(place) => syncPlaceSelection(form, place)}
onPlaceSelect={(place) => syncPlaceSelection(form, field.placeSearchTargets, place)}
/>
);
}

function syncPlaceSelection(
form: ReturnType<typeof useFormContext<FieldValues>>,
targets: CurationTextFieldModel['placeSearchTargets'],
place: SelectedPlace
) {
const valuesByField = {
if (!targets) return;

const valuesBySource = {
placeName: place.placeName,
kakaoPlaceId: place.id,
barAddress: place.address,
id: place.id,
address: place.address,
};

for (const [fieldName, value] of Object.entries(valuesByField)) {
if (typeof form.getValues(fieldName) !== 'string') continue;

form.setValue(fieldName, value, { shouldDirty: true, shouldValidate: true });
for (const [fieldName, source] of Object.entries(targets)) {
form.setValue(fieldName, valuesBySource[source], {
shouldDirty: true,
shouldValidate: true,
});
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ function FieldHarness({
}

describe('CurationFormFieldRenderer', () => {
it('장소 검색 결과를 request spec에 있는 장소 필드로 함께 매핑한다', async () => {
it('시음회 스펙이 선언한 target만 장소 검색 결과로 매핑한다', async () => {
const user = userEvent.setup();

render(
Expand All @@ -61,60 +61,94 @@ describe('CurationFormFieldRenderer', () => {
label: '장소명',
required: false,
kind: 'address',
placeSearchTargets: {
placeName: 'placeName',
kakaoPlaceId: 'id',
barAddress: 'address',
},
}}
defaultValues={{
placeName: '',
kakaoPlaceId: '',
barAddress: '',
detailAddress: '',
address: '프로그램 주소는 변경하지 않음',
detailAddress: '2층 안쪽 입구',
}}
/>
);

await user.click(await screen.findByRole('button', { name: '장소 선택' }));

await waitFor(() => {
expect(JSON.parse(screen.getByTestId('form-values').textContent ?? '{}')).toMatchObject({
expect(JSON.parse(screen.getByTestId('form-values').textContent ?? '{}')).toEqual({
placeName: '보틀노트 테이스팅룸',
kakaoPlaceId: '27288225',
barAddress: '서울 강남구 테헤란로 123',
detailAddress: '',
address: '프로그램 주소는 변경하지 않음',
detailAddress: '2층 안쪽 입구',
});
});
});

it('프로그램 주소 검색도 같은 선택값을 address와 Kakao 장소 ID에 매핑한다', async () => {
it('프로그램 스펙은 address target만 장소 검색 결과로 매핑한다', async () => {
const user = userEvent.setup();

render(
<FieldHarness
field={{
key: 'address',
label: '장소 및 주소',
key: 'placeName',
label: '장소명',
required: false,
kind: 'address',
placeSearchTargets: {
placeName: 'placeName',
kakaoPlaceId: 'id',
address: 'address',
},
}}
defaultValues={{
placeName: '',
kakaoPlaceId: '',
address: '',
detailLocation: '',
barAddress: '시음회 주소는 변경하지 않음',
detailAddress: 'B홀 2층',
}}
/>
);

await user.click(await screen.findByRole('button', { name: '장소 선택' }));

await waitFor(() => {
expect(JSON.parse(screen.getByTestId('form-values').textContent ?? '{}')).toMatchObject({
expect(JSON.parse(screen.getByTestId('form-values').textContent ?? '{}')).toEqual({
placeName: '보틀노트 테이스팅룸',
kakaoPlaceId: '27288225',
address: '서울 강남구 테헤란로 123',
detailLocation: '',
barAddress: '시음회 주소는 변경하지 않음',
detailAddress: 'B홀 2층',
});
});
});

it('x-read-only plain text는 disabled가 아닌 readOnly input으로 렌더링한다', () => {
render(
<FieldHarness
field={{
key: 'barAddress',
label: '장소 및 바 주소',
required: true,
kind: 'text',
readOnly: true,
}}
defaultValues={{ barAddress: '서울 강남구 테헤란로 123' }}
/>
);

const input = screen.getByLabelText('장소 및 바 주소');
expect(input).toHaveValue('서울 강남구 테헤란로 123');
expect(input).toHaveProperty('readOnly', true);
expect(input).not.toBeDisabled();
});

it('hidden field는 라벨 없이 전송용 input으로만 렌더링한다', () => {
const { container } = render(
<FieldHarness
Expand Down
Loading