@ncds/ui-admin 1.8.28-alpha.4 → 1.8.28-alpha.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -21,6 +21,8 @@ const PAGE_SIZE = 15;
21
21
  const PAGER_WINDOW = 10;
22
22
  /** 검색 조합 안내 문구를 띄우는 기준 — 결과가 이 수를 넘을 때만 노출한다 */
23
23
  const SEARCH_HINT_MIN_TOTAL_COUNT = 30;
24
+ /** 기획서 케이스 2 — 이 길이 미만이면 검색하지 않는다 */
25
+ const MIN_SEARCH_QUERY_LENGTH = 2;
24
26
  /** 반응형 전환 기준 — assets/styles/base/_variable.scss 의 $breakpoint 와 동일 */
25
27
  const MOBILE_BREAKPOINT = 768;
26
28
  /** 본문 스크롤 컨테이너. Modal.Content 가 overflow-y:auto 를 갖는다. */
@@ -49,6 +51,8 @@ const resultKey = _ref => {
49
51
  } = _ref;
50
52
  return `${zipCode}|${roadAddress}|${jibunAddress}`;
51
53
  };
54
+ /** 앞뒤 공백만 자르고 센다. 가운데 공백은 글자 수에 포함한다. */
55
+ const isSearchQueryTooShort = query => query.trim().length < MIN_SEARCH_QUERY_LENGTH;
52
56
  /** SSR(도큐사우루스 등)에서는 초기값을 false(=PC)로 두고 mount 이후 갱신한다. */
53
57
  const useIsMobileViewport = () => {
54
58
  const [isMobile, setIsMobile] = (0, _react.useState)(false);
@@ -116,6 +120,10 @@ const PostcodeSearchModal = _ref2 => {
116
120
  // 검색이 실행된 검색어. 입력 중에는 갱신되지 않는다.
117
121
  // 초기값은 마운트 시점의 searchQuery — 사용처가 검색어를 채운 채로 열면 그 검색은 이미 실행된 것으로 본다.
118
122
  const [searchedQuery, setSearchedQuery] = (0, _react.useState)(searchQuery);
123
+ // 검색어 길이 검사에 걸렸을 때의 오류코드. 사용처 errorCode 보다 앞선다.
124
+ // 사용처가 결과를 지우지 않아도 이 값이 있는 동안 목록을 숨긴다(늦게 온 응답 포함).
125
+ const [validationErrorCode, setValidationErrorCode] = (0, _react.useState)();
126
+ const activeErrorCode = validationErrorCode ?? errorCode;
119
127
  // Modal 은 닫혀도 이 컴포넌트를 언마운트하지 않아 내부 상태가 다음 열기까지 남는다.
120
128
  // 열리는 순간 이전 세션의 선택·검색 상태를 지운다.
121
129
  // biome-ignore lint/correctness/useExhaustiveDependencies: searchQuery 를 deps 에 넣으면 입력할 때마다 초기화된다
@@ -123,8 +131,9 @@ const PostcodeSearchModal = _ref2 => {
123
131
  if (!open) return;
124
132
  setSelectedKey(undefined);
125
133
  setSearchedQuery(searchQuery);
134
+ setValidationErrorCode(undefined);
126
135
  }, [open]);
127
- const hasError = Boolean(errorCode);
136
+ const hasError = Boolean(activeErrorCode);
128
137
  const hasResults = !hasError && results.length > 0;
129
138
  // 결과가 적으면 굳이 검색 조합을 안내하지 않는다.
130
139
  const showSearchHint = hasResults && totalCount > SEARCH_HINT_MIN_TOTAL_COUNT;
@@ -137,6 +146,11 @@ const PostcodeSearchModal = _ref2 => {
137
146
  onSearchQueryChange(event.target.value);
138
147
  };
139
148
  const handleSearch = () => {
149
+ if (isSearchQueryTooShort(searchQuery)) {
150
+ setValidationErrorCode('E0008');
151
+ return;
152
+ }
153
+ setValidationErrorCode(undefined);
140
154
  setSearchedQuery(searchQuery);
141
155
  onSearch();
142
156
  };
@@ -177,7 +191,7 @@ const PostcodeSearchModal = _ref2 => {
177
191
  onKeyDown: handleSearchKeyDown,
178
192
  placeholder: SEARCH_PLACEHOLDER,
179
193
  destructive: hasError,
180
- hintText: errorCode ? ERROR_HINT[errorCode] ?? DEFAULT_ERROR_HINT : undefined
194
+ hintText: activeErrorCode ? ERROR_HINT[activeErrorCode] ?? DEFAULT_ERROR_HINT : undefined
181
195
  }), (0, _jsxRuntime.jsx)(_button.Button, {
182
196
  className: "ncua-postcode-search-modal__search-button",
183
197
  label: SEARCH_BUTTON_LABEL,
@@ -620,4 +620,131 @@ const paginationOf = el => el.querySelector('.ncua-pagination');
620
620
  // Then: 고정할 대상이 없으므로 클래스는 붙지 않는다
621
621
  (0, _vitest.expect)(isFixedSearchArea(el)).toBe(false);
622
622
  });
623
+ });
624
+ (0, _vitest.describe)('PostcodeSearchModal — 검색어 2글자 미만 검사 (#678)', () => {
625
+ const SHORT_QUERY_HINT = '검색어를 두 글자 이상 입력해주세요.';
626
+ const searchButtonOf = el => el.querySelector('.ncua-postcode-search-modal__search-button');
627
+ const clickSearch = el => {
628
+ (0, _testUtils.act)(() => {
629
+ searchButtonOf(el)?.click();
630
+ });
631
+ };
632
+ _vitest.it.each(['', ' ', '가', ' 가 '])('trim 후 2글자 미만("%s")이면 onSearch 를 부르지 않고 오류 문구를 보인다', searchQuery => {
633
+ // Given: 검색어가 trim 후 2글자 미만인 상태에서
634
+ const onSearch = _vitest.vi.fn();
635
+ const el = render(baseProps({
636
+ searchQuery,
637
+ onSearch
638
+ }));
639
+ // When: 검색 버튼을 누르면
640
+ clickSearch(el);
641
+ // Then: 검색은 실행되지 않고 입력칸이 오류 상태가 된다
642
+ (0, _vitest.expect)(onSearch).not.toHaveBeenCalled();
643
+ (0, _vitest.expect)(hintOf(el)?.textContent).toBe(SHORT_QUERY_HINT);
644
+ (0, _vitest.expect)(inputRootOf(el)?.classList.contains('destructive')).toBe(true);
645
+ });
646
+ _vitest.it.each(['가나', ' 가나 '])('trim 후 2글자 이상("%s")이면 onSearch 를 부르고 오류가 없다', searchQuery => {
647
+ // Given: 검색어가 trim 후 2글자 이상인 상태에서
648
+ const onSearch = _vitest.vi.fn();
649
+ const el = render(baseProps({
650
+ searchQuery,
651
+ onSearch
652
+ }));
653
+ // When: 검색 버튼을 누르면
654
+ clickSearch(el);
655
+ // Then: 검색이 실행되고 입력칸은 오류 상태가 아니다
656
+ (0, _vitest.expect)(onSearch).toHaveBeenCalledTimes(1);
657
+ (0, _vitest.expect)(hintOf(el)).toBeNull();
658
+ (0, _vitest.expect)(inputRootOf(el)?.classList.contains('destructive')).toBe(false);
659
+ });
660
+ (0, _vitest.it)('결과를 보여 주는 중에 1글자로 검색하면 목록·페이지네이션을 숨기고 Tip 을 보인다', () => {
661
+ // Given: 결과가 보이는 상태에서 검색어를 1글자로 바꾸고
662
+ const results = makeResults(SMALL_RESULT_COUNT);
663
+ render(baseProps({
664
+ searchQuery: '판교로',
665
+ results,
666
+ totalCount: SMALL_RESULT_COUNT
667
+ }));
668
+ const el = render(baseProps({
669
+ searchQuery: '가',
670
+ results,
671
+ totalCount: SMALL_RESULT_COUNT
672
+ }));
673
+ (0, _vitest.expect)(itemsOf(el)).toHaveLength(SMALL_RESULT_COUNT);
674
+ // When: 검색 버튼을 누르면
675
+ clickSearch(el);
676
+ // Then: 사용처가 결과를 지우지 않아도 지난 목록은 보이지 않는다
677
+ (0, _vitest.expect)(el.querySelector('.ncua-postcode-search-modal__list')).toBeNull();
678
+ (0, _vitest.expect)(paginationOf(el)).toBeNull();
679
+ (0, _vitest.expect)(tipOf(el)).not.toBeNull();
680
+ });
681
+ (0, _vitest.it)('검사에 걸린 뒤 2글자 이상으로 다시 검색하면 오류가 풀리고 onSearch 를 부른다', () => {
682
+ // Given: 1글자로 검색해 검사에 걸린 상태에서
683
+ const onSearch = _vitest.vi.fn();
684
+ const el = render(baseProps({
685
+ searchQuery: '가',
686
+ onSearch
687
+ }));
688
+ clickSearch(el);
689
+ // When: 2글자로 고쳐 다시 검색하면
690
+ render(baseProps({
691
+ searchQuery: '가나',
692
+ onSearch
693
+ }));
694
+ clickSearch(el);
695
+ // Then: 오류가 풀리고 검색이 실행된다
696
+ (0, _vitest.expect)(onSearch).toHaveBeenCalledTimes(1);
697
+ (0, _vitest.expect)(hintOf(el)).toBeNull();
698
+ (0, _vitest.expect)(inputRootOf(el)?.classList.contains('destructive')).toBe(false);
699
+ });
700
+ (0, _vitest.it)('검사에 걸린 뒤 모달을 닫았다 다시 열면 오류가 없다', () => {
701
+ // Given: 1글자로 검색해 검사에 걸린 상태에서
702
+ const el = render(baseProps({
703
+ searchQuery: '가'
704
+ }));
705
+ clickSearch(el);
706
+ // When: 모달을 닫았다가 다시 열면
707
+ render(baseProps({
708
+ open: false,
709
+ searchQuery: '가'
710
+ }));
711
+ const reopened = render(baseProps({
712
+ searchQuery: '가'
713
+ }));
714
+ // Then: 지난 검사 오류는 남지 않는다
715
+ (0, _vitest.expect)(hintOf(reopened)).toBeNull();
716
+ (0, _vitest.expect)(inputRootOf(reopened)?.classList.contains('destructive')).toBe(false);
717
+ });
718
+ (0, _vitest.it)('앱 errorCode(E0006) 가 있어도 1글자로 검색하면 검사 오류 문구(E0008)를 보인다', () => {
719
+ // Given: 사용처가 E0006 을 넘긴 상태에서 검색어를 1글자로 바꾸고
720
+ const el = render(baseProps({
721
+ searchQuery: '가',
722
+ errorCode: 'E0006'
723
+ }));
724
+ // When: 검색 버튼을 누르면
725
+ clickSearch(el);
726
+ // Then: 컴포넌트 검사 오류가 앞선다
727
+ (0, _vitest.expect)(hintOf(el)?.textContent).toBe(SHORT_QUERY_HINT);
728
+ });
729
+ (0, _vitest.it)('Enter 로 1글자 검색해도 버튼과 같이 onSearch 를 부르지 않고 오류 문구를 보인다', () => {
730
+ // Given: 검색어가 1글자인 상태에서
731
+ const onSearch = _vitest.vi.fn();
732
+ const el = render(baseProps({
733
+ searchQuery: '가',
734
+ onSearch
735
+ }));
736
+ const input = el.querySelector('.ncua-input input');
737
+ if (!input) throw new Error('검색 인풋이 렌더되지 않았습니다');
738
+ // When: Enter 를 누르면
739
+ (0, _testUtils.act)(() => {
740
+ input.dispatchEvent(new KeyboardEvent('keydown', {
741
+ key: 'Enter',
742
+ bubbles: true
743
+ }));
744
+ });
745
+ // Then: 버튼을 누른 것과 결과가 같다
746
+ (0, _vitest.expect)(onSearch).not.toHaveBeenCalled();
747
+ (0, _vitest.expect)(hintOf(el)?.textContent).toBe(SHORT_QUERY_HINT);
748
+ (0, _vitest.expect)(inputRootOf(el)?.classList.contains('destructive')).toBe(true);
749
+ });
623
750
  });
@@ -14,6 +14,8 @@ const PAGE_SIZE = 15;
14
14
  const PAGER_WINDOW = 10;
15
15
  /** 검색 조합 안내 문구를 띄우는 기준 — 결과가 이 수를 넘을 때만 노출한다 */
16
16
  const SEARCH_HINT_MIN_TOTAL_COUNT = 30;
17
+ /** 기획서 케이스 2 — 이 길이 미만이면 검색하지 않는다 */
18
+ const MIN_SEARCH_QUERY_LENGTH = 2;
17
19
  /** 반응형 전환 기준 — assets/styles/base/_variable.scss 의 $breakpoint 와 동일 */
18
20
  const MOBILE_BREAKPOINT = 768;
19
21
  /** 본문 스크롤 컨테이너. Modal.Content 가 overflow-y:auto 를 갖는다. */
@@ -42,6 +44,8 @@ const resultKey = _ref => {
42
44
  } = _ref;
43
45
  return `${zipCode}|${roadAddress}|${jibunAddress}`;
44
46
  };
47
+ /** 앞뒤 공백만 자르고 센다. 가운데 공백은 글자 수에 포함한다. */
48
+ const isSearchQueryTooShort = query => query.trim().length < MIN_SEARCH_QUERY_LENGTH;
45
49
  /** SSR(도큐사우루스 등)에서는 초기값을 false(=PC)로 두고 mount 이후 갱신한다. */
46
50
  const useIsMobileViewport = () => {
47
51
  const [isMobile, setIsMobile] = useState(false);
@@ -109,6 +113,10 @@ const PostcodeSearchModal = _ref2 => {
109
113
  // 검색이 실행된 검색어. 입력 중에는 갱신되지 않는다.
110
114
  // 초기값은 마운트 시점의 searchQuery — 사용처가 검색어를 채운 채로 열면 그 검색은 이미 실행된 것으로 본다.
111
115
  const [searchedQuery, setSearchedQuery] = useState(searchQuery);
116
+ // 검색어 길이 검사에 걸렸을 때의 오류코드. 사용처 errorCode 보다 앞선다.
117
+ // 사용처가 결과를 지우지 않아도 이 값이 있는 동안 목록을 숨긴다(늦게 온 응답 포함).
118
+ const [validationErrorCode, setValidationErrorCode] = useState();
119
+ const activeErrorCode = validationErrorCode ?? errorCode;
112
120
  // Modal 은 닫혀도 이 컴포넌트를 언마운트하지 않아 내부 상태가 다음 열기까지 남는다.
113
121
  // 열리는 순간 이전 세션의 선택·검색 상태를 지운다.
114
122
  // biome-ignore lint/correctness/useExhaustiveDependencies: searchQuery 를 deps 에 넣으면 입력할 때마다 초기화된다
@@ -116,8 +124,9 @@ const PostcodeSearchModal = _ref2 => {
116
124
  if (!open) return;
117
125
  setSelectedKey(undefined);
118
126
  setSearchedQuery(searchQuery);
127
+ setValidationErrorCode(undefined);
119
128
  }, [open]);
120
- const hasError = Boolean(errorCode);
129
+ const hasError = Boolean(activeErrorCode);
121
130
  const hasResults = !hasError && results.length > 0;
122
131
  // 결과가 적으면 굳이 검색 조합을 안내하지 않는다.
123
132
  const showSearchHint = hasResults && totalCount > SEARCH_HINT_MIN_TOTAL_COUNT;
@@ -130,6 +139,11 @@ const PostcodeSearchModal = _ref2 => {
130
139
  onSearchQueryChange(event.target.value);
131
140
  };
132
141
  const handleSearch = () => {
142
+ if (isSearchQueryTooShort(searchQuery)) {
143
+ setValidationErrorCode('E0008');
144
+ return;
145
+ }
146
+ setValidationErrorCode(undefined);
133
147
  setSearchedQuery(searchQuery);
134
148
  onSearch();
135
149
  };
@@ -170,7 +184,7 @@ const PostcodeSearchModal = _ref2 => {
170
184
  onKeyDown: handleSearchKeyDown,
171
185
  placeholder: SEARCH_PLACEHOLDER,
172
186
  destructive: hasError,
173
- hintText: errorCode ? ERROR_HINT[errorCode] ?? DEFAULT_ERROR_HINT : undefined
187
+ hintText: activeErrorCode ? ERROR_HINT[activeErrorCode] ?? DEFAULT_ERROR_HINT : undefined
174
188
  }), _jsx(Button, {
175
189
  className: "ncua-postcode-search-modal__search-button",
176
190
  label: SEARCH_BUTTON_LABEL,
@@ -617,4 +617,131 @@ describe('PostcodeSearchModal — 검색 영역 고정 표시', () => {
617
617
  // Then: 고정할 대상이 없으므로 클래스는 붙지 않는다
618
618
  expect(isFixedSearchArea(el)).toBe(false);
619
619
  });
620
+ });
621
+ describe('PostcodeSearchModal — 검색어 2글자 미만 검사 (#678)', () => {
622
+ const SHORT_QUERY_HINT = '검색어를 두 글자 이상 입력해주세요.';
623
+ const searchButtonOf = el => el.querySelector('.ncua-postcode-search-modal__search-button');
624
+ const clickSearch = el => {
625
+ act(() => {
626
+ searchButtonOf(el)?.click();
627
+ });
628
+ };
629
+ it.each(['', ' ', '가', ' 가 '])('trim 후 2글자 미만("%s")이면 onSearch 를 부르지 않고 오류 문구를 보인다', searchQuery => {
630
+ // Given: 검색어가 trim 후 2글자 미만인 상태에서
631
+ const onSearch = vi.fn();
632
+ const el = render(baseProps({
633
+ searchQuery,
634
+ onSearch
635
+ }));
636
+ // When: 검색 버튼을 누르면
637
+ clickSearch(el);
638
+ // Then: 검색은 실행되지 않고 입력칸이 오류 상태가 된다
639
+ expect(onSearch).not.toHaveBeenCalled();
640
+ expect(hintOf(el)?.textContent).toBe(SHORT_QUERY_HINT);
641
+ expect(inputRootOf(el)?.classList.contains('destructive')).toBe(true);
642
+ });
643
+ it.each(['가나', ' 가나 '])('trim 후 2글자 이상("%s")이면 onSearch 를 부르고 오류가 없다', searchQuery => {
644
+ // Given: 검색어가 trim 후 2글자 이상인 상태에서
645
+ const onSearch = vi.fn();
646
+ const el = render(baseProps({
647
+ searchQuery,
648
+ onSearch
649
+ }));
650
+ // When: 검색 버튼을 누르면
651
+ clickSearch(el);
652
+ // Then: 검색이 실행되고 입력칸은 오류 상태가 아니다
653
+ expect(onSearch).toHaveBeenCalledTimes(1);
654
+ expect(hintOf(el)).toBeNull();
655
+ expect(inputRootOf(el)?.classList.contains('destructive')).toBe(false);
656
+ });
657
+ it('결과를 보여 주는 중에 1글자로 검색하면 목록·페이지네이션을 숨기고 Tip 을 보인다', () => {
658
+ // Given: 결과가 보이는 상태에서 검색어를 1글자로 바꾸고
659
+ const results = makeResults(SMALL_RESULT_COUNT);
660
+ render(baseProps({
661
+ searchQuery: '판교로',
662
+ results,
663
+ totalCount: SMALL_RESULT_COUNT
664
+ }));
665
+ const el = render(baseProps({
666
+ searchQuery: '가',
667
+ results,
668
+ totalCount: SMALL_RESULT_COUNT
669
+ }));
670
+ expect(itemsOf(el)).toHaveLength(SMALL_RESULT_COUNT);
671
+ // When: 검색 버튼을 누르면
672
+ clickSearch(el);
673
+ // Then: 사용처가 결과를 지우지 않아도 지난 목록은 보이지 않는다
674
+ expect(el.querySelector('.ncua-postcode-search-modal__list')).toBeNull();
675
+ expect(paginationOf(el)).toBeNull();
676
+ expect(tipOf(el)).not.toBeNull();
677
+ });
678
+ it('검사에 걸린 뒤 2글자 이상으로 다시 검색하면 오류가 풀리고 onSearch 를 부른다', () => {
679
+ // Given: 1글자로 검색해 검사에 걸린 상태에서
680
+ const onSearch = vi.fn();
681
+ const el = render(baseProps({
682
+ searchQuery: '가',
683
+ onSearch
684
+ }));
685
+ clickSearch(el);
686
+ // When: 2글자로 고쳐 다시 검색하면
687
+ render(baseProps({
688
+ searchQuery: '가나',
689
+ onSearch
690
+ }));
691
+ clickSearch(el);
692
+ // Then: 오류가 풀리고 검색이 실행된다
693
+ expect(onSearch).toHaveBeenCalledTimes(1);
694
+ expect(hintOf(el)).toBeNull();
695
+ expect(inputRootOf(el)?.classList.contains('destructive')).toBe(false);
696
+ });
697
+ it('검사에 걸린 뒤 모달을 닫았다 다시 열면 오류가 없다', () => {
698
+ // Given: 1글자로 검색해 검사에 걸린 상태에서
699
+ const el = render(baseProps({
700
+ searchQuery: '가'
701
+ }));
702
+ clickSearch(el);
703
+ // When: 모달을 닫았다가 다시 열면
704
+ render(baseProps({
705
+ open: false,
706
+ searchQuery: '가'
707
+ }));
708
+ const reopened = render(baseProps({
709
+ searchQuery: '가'
710
+ }));
711
+ // Then: 지난 검사 오류는 남지 않는다
712
+ expect(hintOf(reopened)).toBeNull();
713
+ expect(inputRootOf(reopened)?.classList.contains('destructive')).toBe(false);
714
+ });
715
+ it('앱 errorCode(E0006) 가 있어도 1글자로 검색하면 검사 오류 문구(E0008)를 보인다', () => {
716
+ // Given: 사용처가 E0006 을 넘긴 상태에서 검색어를 1글자로 바꾸고
717
+ const el = render(baseProps({
718
+ searchQuery: '가',
719
+ errorCode: 'E0006'
720
+ }));
721
+ // When: 검색 버튼을 누르면
722
+ clickSearch(el);
723
+ // Then: 컴포넌트 검사 오류가 앞선다
724
+ expect(hintOf(el)?.textContent).toBe(SHORT_QUERY_HINT);
725
+ });
726
+ it('Enter 로 1글자 검색해도 버튼과 같이 onSearch 를 부르지 않고 오류 문구를 보인다', () => {
727
+ // Given: 검색어가 1글자인 상태에서
728
+ const onSearch = vi.fn();
729
+ const el = render(baseProps({
730
+ searchQuery: '가',
731
+ onSearch
732
+ }));
733
+ const input = el.querySelector('.ncua-input input');
734
+ if (!input) throw new Error('검색 인풋이 렌더되지 않았습니다');
735
+ // When: Enter 를 누르면
736
+ act(() => {
737
+ input.dispatchEvent(new KeyboardEvent('keydown', {
738
+ key: 'Enter',
739
+ bubbles: true
740
+ }));
741
+ });
742
+ // Then: 버튼을 누른 것과 결과가 같다
743
+ expect(onSearch).not.toHaveBeenCalled();
744
+ expect(hintOf(el)?.textContent).toBe(SHORT_QUERY_HINT);
745
+ expect(inputRootOf(el)?.classList.contains('destructive')).toBe(true);
746
+ });
620
747
  });
@@ -14,6 +14,8 @@ const PAGE_SIZE = 15;
14
14
  const PAGER_WINDOW = 10;
15
15
  /** 검색 조합 안내 문구를 띄우는 기준 — 결과가 이 수를 넘을 때만 노출한다 */
16
16
  const SEARCH_HINT_MIN_TOTAL_COUNT = 30;
17
+ /** 기획서 케이스 2 — 이 길이 미만이면 검색하지 않는다 */
18
+ const MIN_SEARCH_QUERY_LENGTH = 2;
17
19
  /** 반응형 전환 기준 — assets/styles/base/_variable.scss 의 $breakpoint 와 동일 */
18
20
  const MOBILE_BREAKPOINT = 768;
19
21
  /** 본문 스크롤 컨테이너. Modal.Content 가 overflow-y:auto 를 갖는다. */
@@ -35,6 +37,8 @@ const ERROR_HINT = {
35
37
  };
36
38
  /** 선택 표시를 판정하는 키. 우편번호만으로는 같은 건물의 다른 지번을 구분하지 못한다. */
37
39
  const resultKey = ({ zipCode, roadAddress, jibunAddress }) => `${zipCode}|${roadAddress}|${jibunAddress}`;
40
+ /** 앞뒤 공백만 자르고 센다. 가운데 공백은 글자 수에 포함한다. */
41
+ const isSearchQueryTooShort = (query) => query.trim().length < MIN_SEARCH_QUERY_LENGTH;
38
42
  /** SSR(도큐사우루스 등)에서는 초기값을 false(=PC)로 두고 mount 이후 갱신한다. */
39
43
  const useIsMobileViewport = () => {
40
44
  const [isMobile, setIsMobile] = useState(false);
@@ -85,6 +89,10 @@ const PostcodeSearchModal = ({ open, onClose, zIndex, className, searchQuery, on
85
89
  // 검색이 실행된 검색어. 입력 중에는 갱신되지 않는다.
86
90
  // 초기값은 마운트 시점의 searchQuery — 사용처가 검색어를 채운 채로 열면 그 검색은 이미 실행된 것으로 본다.
87
91
  const [searchedQuery, setSearchedQuery] = useState(searchQuery);
92
+ // 검색어 길이 검사에 걸렸을 때의 오류코드. 사용처 errorCode 보다 앞선다.
93
+ // 사용처가 결과를 지우지 않아도 이 값이 있는 동안 목록을 숨긴다(늦게 온 응답 포함).
94
+ const [validationErrorCode, setValidationErrorCode] = useState();
95
+ const activeErrorCode = validationErrorCode ?? errorCode;
88
96
  // Modal 은 닫혀도 이 컴포넌트를 언마운트하지 않아 내부 상태가 다음 열기까지 남는다.
89
97
  // 열리는 순간 이전 세션의 선택·검색 상태를 지운다.
90
98
  // biome-ignore lint/correctness/useExhaustiveDependencies: searchQuery 를 deps 에 넣으면 입력할 때마다 초기화된다
@@ -93,8 +101,9 @@ const PostcodeSearchModal = ({ open, onClose, zIndex, className, searchQuery, on
93
101
  return;
94
102
  setSelectedKey(undefined);
95
103
  setSearchedQuery(searchQuery);
104
+ setValidationErrorCode(undefined);
96
105
  }, [open]);
97
- const hasError = Boolean(errorCode);
106
+ const hasError = Boolean(activeErrorCode);
98
107
  const hasResults = !hasError && results.length > 0;
99
108
  // 결과가 적으면 굳이 검색 조합을 안내하지 않는다.
100
109
  const showSearchHint = hasResults && totalCount > SEARCH_HINT_MIN_TOTAL_COUNT;
@@ -107,6 +116,11 @@ const PostcodeSearchModal = ({ open, onClose, zIndex, className, searchQuery, on
107
116
  onSearchQueryChange(event.target.value);
108
117
  };
109
118
  const handleSearch = () => {
119
+ if (isSearchQueryTooShort(searchQuery)) {
120
+ setValidationErrorCode('E0008');
121
+ return;
122
+ }
123
+ setValidationErrorCode(undefined);
110
124
  setSearchedQuery(searchQuery);
111
125
  onSearch();
112
126
  };
@@ -119,7 +133,7 @@ const PostcodeSearchModal = ({ open, onClose, zIndex, className, searchQuery, on
119
133
  };
120
134
  return (_jsxs(Modal, { isOpen: open, onClose: onClose, size: "md", zIndex: zIndex, className: className, children: [_jsx(Modal.Header, { title: MODAL_TITLE, onClose: onClose, showDivider: true }), _jsx(Modal.Content, { children: _jsxs("div", { className: "ncua-postcode-search-modal", ref: rootRef, children: [_jsxs("div", { className: classnames('ncua-postcode-search-modal__search-area', {
121
135
  'is-fixed': hasResults && isContentScrolled,
122
- }), children: [_jsxs("div", { className: "ncua-postcode-search-modal__search", children: [_jsx(InputBase, { className: "ncua-postcode-search-modal__search-input", size: "sm", fullWidth: true, value: searchQuery, clearText: !!searchQuery, onClearText: () => onSearchQueryChange(''), onChange: handleSearchQueryChange, onKeyDown: handleSearchKeyDown, placeholder: SEARCH_PLACEHOLDER, destructive: hasError, hintText: errorCode ? (ERROR_HINT[errorCode] ?? DEFAULT_ERROR_HINT) : undefined }), _jsx(Button, { className: "ncua-postcode-search-modal__search-button", label: SEARCH_BUTTON_LABEL, size: "sm", hierarchy: "secondary", onClick: handleSearch })] }), showSearchHint && (_jsxs("div", { children: [_jsx("p", { className: "ncua-postcode-search-modal__result-title", children: "\uC544\uB798 \uC870\uD569\uC73C\uB85C \uAC80\uC0C9 \uC2DC \uC8FC\uC18C\uB97C \uB354 \uC27D\uAC8C \uCC3E\uC744 \uC218 \uC788\uC5B4\uC694." }), _jsx("p", { className: "ncua-postcode-search-modal__result-desc", children: "\uB3C4\uB85C\uBA85+\uAC74\uBB3C\uBC88\uD638, \uC9C0\uC5ED\uBA85+\uC9C0\uBC88, \uC9C0\uC5ED\uBA85+\uAC74\uBB3C\uBA85(\uC544\uD30C\uD2B8\uBA85)" })] }))] }), !hasResults && _jsx(PostcodeSearchModal.Tip, {}), hasResults && (_jsxs(_Fragment, { children: [_jsxs("p", { className: "ncua-postcode-search-modal__count", children: ["\uAC80\uC0C9 ", _jsx("b", { children: totalCount }), "\uAC1C"] }), _jsx("ul", { className: "ncua-postcode-search-modal__list", children: results.map((result, index) => (_jsx("li", { className: "ncua-postcode-search-modal__item", children: _jsx(Radio, { name: RESULT_RADIO_NAME, value: result.zipCode, checked: selectedKey === resultKey(result), onChange: () => {
136
+ }), children: [_jsxs("div", { className: "ncua-postcode-search-modal__search", children: [_jsx(InputBase, { className: "ncua-postcode-search-modal__search-input", size: "sm", fullWidth: true, value: searchQuery, clearText: !!searchQuery, onClearText: () => onSearchQueryChange(''), onChange: handleSearchQueryChange, onKeyDown: handleSearchKeyDown, placeholder: SEARCH_PLACEHOLDER, destructive: hasError, hintText: activeErrorCode ? (ERROR_HINT[activeErrorCode] ?? DEFAULT_ERROR_HINT) : undefined }), _jsx(Button, { className: "ncua-postcode-search-modal__search-button", label: SEARCH_BUTTON_LABEL, size: "sm", hierarchy: "secondary", onClick: handleSearch })] }), showSearchHint && (_jsxs("div", { children: [_jsx("p", { className: "ncua-postcode-search-modal__result-title", children: "\uC544\uB798 \uC870\uD569\uC73C\uB85C \uAC80\uC0C9 \uC2DC \uC8FC\uC18C\uB97C \uB354 \uC27D\uAC8C \uCC3E\uC744 \uC218 \uC788\uC5B4\uC694." }), _jsx("p", { className: "ncua-postcode-search-modal__result-desc", children: "\uB3C4\uB85C\uBA85+\uAC74\uBB3C\uBC88\uD638, \uC9C0\uC5ED\uBA85+\uC9C0\uBC88, \uC9C0\uC5ED\uBA85+\uAC74\uBB3C\uBA85(\uC544\uD30C\uD2B8\uBA85)" })] }))] }), !hasResults && _jsx(PostcodeSearchModal.Tip, {}), hasResults && (_jsxs(_Fragment, { children: [_jsxs("p", { className: "ncua-postcode-search-modal__count", children: ["\uAC80\uC0C9 ", _jsx("b", { children: totalCount }), "\uAC1C"] }), _jsx("ul", { className: "ncua-postcode-search-modal__list", children: results.map((result, index) => (_jsx("li", { className: "ncua-postcode-search-modal__item", children: _jsx(Radio, { name: RESULT_RADIO_NAME, value: result.zipCode, checked: selectedKey === resultKey(result), onChange: () => {
123
137
  setSelectedKey(resultKey(result));
124
138
  onSelect(result);
125
139
  }, text: _jsxs(_Fragment, { children: [_jsxs("span", { className: "ncua-postcode-search-modal__addr-line", children: [_jsx("span", { className: "ncua-postcode-search-modal__badge", children: ROAD_BADGE_LABEL }), _jsx("span", { className: "ncua-postcode-search-modal__item-road", children: result.roadAddress }), _jsx("span", { className: "ncua-postcode-search-modal__item-zip", children: result.zipCode })] }), _jsxs("span", { className: "ncua-postcode-search-modal__addr-line", children: [_jsx("span", { className: "ncua-postcode-search-modal__badge", children: JIBUN_BADGE_LABEL }), _jsx("span", { className: "ncua-postcode-search-modal__item-jibun", children: result.jibunAddress })] })] }) }) }, `${result.zipCode}-${index}`))) }), _jsx("div", { className: "ncua-postcode-search-modal__pagination", children: _jsx(Pagination, { totalCount: totalCount, itemCountPerPage: pageSize, pageCount: PAGER_WINDOW, currentPage: currentPage, breakPoint: isMobile ? 'mo' : 'pc', onPageChange: onPageChange }) })] })), showEmptyState && (_jsx("div", { className: "ncua-postcode-search-modal__empty", children: _jsx(EmptyState, { title: EMPTY_TITLE, description: EMPTY_DESCRIPTION }) }))] }) })] }));
@@ -501,3 +501,95 @@ describe('PostcodeSearchModal — 검색 영역 고정 표시', () => {
501
501
  expect(isFixedSearchArea(el)).toBe(false);
502
502
  });
503
503
  });
504
+ describe('PostcodeSearchModal — 검색어 2글자 미만 검사 (#678)', () => {
505
+ const SHORT_QUERY_HINT = '검색어를 두 글자 이상 입력해주세요.';
506
+ const searchButtonOf = (el) => el.querySelector('.ncua-postcode-search-modal__search-button');
507
+ const clickSearch = (el) => {
508
+ act(() => {
509
+ searchButtonOf(el)?.click();
510
+ });
511
+ };
512
+ it.each(['', ' ', '가', ' 가 '])('trim 후 2글자 미만("%s")이면 onSearch 를 부르지 않고 오류 문구를 보인다', (searchQuery) => {
513
+ // Given: 검색어가 trim 후 2글자 미만인 상태에서
514
+ const onSearch = vi.fn();
515
+ const el = render(baseProps({ searchQuery, onSearch }));
516
+ // When: 검색 버튼을 누르면
517
+ clickSearch(el);
518
+ // Then: 검색은 실행되지 않고 입력칸이 오류 상태가 된다
519
+ expect(onSearch).not.toHaveBeenCalled();
520
+ expect(hintOf(el)?.textContent).toBe(SHORT_QUERY_HINT);
521
+ expect(inputRootOf(el)?.classList.contains('destructive')).toBe(true);
522
+ });
523
+ it.each(['가나', ' 가나 '])('trim 후 2글자 이상("%s")이면 onSearch 를 부르고 오류가 없다', (searchQuery) => {
524
+ // Given: 검색어가 trim 후 2글자 이상인 상태에서
525
+ const onSearch = vi.fn();
526
+ const el = render(baseProps({ searchQuery, onSearch }));
527
+ // When: 검색 버튼을 누르면
528
+ clickSearch(el);
529
+ // Then: 검색이 실행되고 입력칸은 오류 상태가 아니다
530
+ expect(onSearch).toHaveBeenCalledTimes(1);
531
+ expect(hintOf(el)).toBeNull();
532
+ expect(inputRootOf(el)?.classList.contains('destructive')).toBe(false);
533
+ });
534
+ it('결과를 보여 주는 중에 1글자로 검색하면 목록·페이지네이션을 숨기고 Tip 을 보인다', () => {
535
+ // Given: 결과가 보이는 상태에서 검색어를 1글자로 바꾸고
536
+ const results = makeResults(SMALL_RESULT_COUNT);
537
+ render(baseProps({ searchQuery: '판교로', results, totalCount: SMALL_RESULT_COUNT }));
538
+ const el = render(baseProps({ searchQuery: '가', results, totalCount: SMALL_RESULT_COUNT }));
539
+ expect(itemsOf(el)).toHaveLength(SMALL_RESULT_COUNT);
540
+ // When: 검색 버튼을 누르면
541
+ clickSearch(el);
542
+ // Then: 사용처가 결과를 지우지 않아도 지난 목록은 보이지 않는다
543
+ expect(el.querySelector('.ncua-postcode-search-modal__list')).toBeNull();
544
+ expect(paginationOf(el)).toBeNull();
545
+ expect(tipOf(el)).not.toBeNull();
546
+ });
547
+ it('검사에 걸린 뒤 2글자 이상으로 다시 검색하면 오류가 풀리고 onSearch 를 부른다', () => {
548
+ // Given: 1글자로 검색해 검사에 걸린 상태에서
549
+ const onSearch = vi.fn();
550
+ const el = render(baseProps({ searchQuery: '가', onSearch }));
551
+ clickSearch(el);
552
+ // When: 2글자로 고쳐 다시 검색하면
553
+ render(baseProps({ searchQuery: '가나', onSearch }));
554
+ clickSearch(el);
555
+ // Then: 오류가 풀리고 검색이 실행된다
556
+ expect(onSearch).toHaveBeenCalledTimes(1);
557
+ expect(hintOf(el)).toBeNull();
558
+ expect(inputRootOf(el)?.classList.contains('destructive')).toBe(false);
559
+ });
560
+ it('검사에 걸린 뒤 모달을 닫았다 다시 열면 오류가 없다', () => {
561
+ // Given: 1글자로 검색해 검사에 걸린 상태에서
562
+ const el = render(baseProps({ searchQuery: '가' }));
563
+ clickSearch(el);
564
+ // When: 모달을 닫았다가 다시 열면
565
+ render(baseProps({ open: false, searchQuery: '가' }));
566
+ const reopened = render(baseProps({ searchQuery: '가' }));
567
+ // Then: 지난 검사 오류는 남지 않는다
568
+ expect(hintOf(reopened)).toBeNull();
569
+ expect(inputRootOf(reopened)?.classList.contains('destructive')).toBe(false);
570
+ });
571
+ it('앱 errorCode(E0006) 가 있어도 1글자로 검색하면 검사 오류 문구(E0008)를 보인다', () => {
572
+ // Given: 사용처가 E0006 을 넘긴 상태에서 검색어를 1글자로 바꾸고
573
+ const el = render(baseProps({ searchQuery: '가', errorCode: 'E0006' }));
574
+ // When: 검색 버튼을 누르면
575
+ clickSearch(el);
576
+ // Then: 컴포넌트 검사 오류가 앞선다
577
+ expect(hintOf(el)?.textContent).toBe(SHORT_QUERY_HINT);
578
+ });
579
+ it('Enter 로 1글자 검색해도 버튼과 같이 onSearch 를 부르지 않고 오류 문구를 보인다', () => {
580
+ // Given: 검색어가 1글자인 상태에서
581
+ const onSearch = vi.fn();
582
+ const el = render(baseProps({ searchQuery: '가', onSearch }));
583
+ const input = el.querySelector('.ncua-input input');
584
+ if (!input)
585
+ throw new Error('검색 인풋이 렌더되지 않았습니다');
586
+ // When: Enter 를 누르면
587
+ act(() => {
588
+ input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
589
+ });
590
+ // Then: 버튼을 누른 것과 결과가 같다
591
+ expect(onSearch).not.toHaveBeenCalled();
592
+ expect(hintOf(el)?.textContent).toBe(SHORT_QUERY_HINT);
593
+ expect(inputRootOf(el)?.classList.contains('destructive')).toBe(true);
594
+ });
595
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ncds/ui-admin",
3
- "version": "1.8.28-alpha.4",
3
+ "version": "1.8.28-alpha.5",
4
4
  "description": "nhn-commerce의 어드민 디자인 시스템입니다.",
5
5
  "scripts": {
6
6
  "barrel": "node barrel.js",