@ncds/ui-admin 1.8.16 → 1.8.18

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.
Files changed (62) hide show
  1. package/dist/cjs/src/components/forms-and-input/date-picker/DatePicker.js +174 -38
  2. package/dist/cjs/src/components/forms-and-input/date-picker/__tests__/DatePicker.test.js +410 -0
  3. package/dist/cjs/src/components/forms-and-input/file-input/FileInput.js +25 -7
  4. package/dist/cjs/src/components/forms-and-input/file-input/__tests__/FileInput.test.js +168 -0
  5. package/dist/cjs/src/components/forms-and-input/image-file-input/ImageFileInput.js +76 -19
  6. package/dist/cjs/src/components/forms-and-input/image-file-input/__tests__/ImageFileInput.test.js +533 -0
  7. package/dist/cjs/src/components/forms-and-input/image-file-input/components/ImagePreview.js +10 -3
  8. package/dist/cjs/src/components/forms-and-input/range-date-picker/RangeDatePicker.js +9 -6
  9. package/dist/cjs/src/components/forms-and-input/range-date-picker/__tests__/RangeDatePicker.test.js +61 -0
  10. package/dist/cjs/src/components/forms-and-input/range-date-picker-with-buttons/__tests__/RangeDatePickerWithButtons.test.js +249 -0
  11. package/dist/cjs/src/hooks/__tests__/useObjectUrl.test.js +120 -0
  12. package/dist/cjs/src/hooks/index.js +11 -0
  13. package/dist/cjs/src/hooks/useObjectUrl.js +39 -0
  14. package/dist/cjs/vitest.config.js +23 -1
  15. package/dist/esm/src/components/forms-and-input/date-picker/DatePicker.js +174 -38
  16. package/dist/esm/src/components/forms-and-input/date-picker/__tests__/DatePicker.test.js +410 -0
  17. package/dist/esm/src/components/forms-and-input/file-input/FileInput.js +25 -7
  18. package/dist/esm/src/components/forms-and-input/file-input/__tests__/FileInput.test.js +165 -0
  19. package/dist/esm/src/components/forms-and-input/image-file-input/ImageFileInput.js +71 -19
  20. package/dist/esm/src/components/forms-and-input/image-file-input/__tests__/ImageFileInput.test.js +530 -0
  21. package/dist/esm/src/components/forms-and-input/image-file-input/components/ImagePreview.js +10 -3
  22. package/dist/esm/src/components/forms-and-input/range-date-picker/RangeDatePicker.js +9 -6
  23. package/dist/esm/src/components/forms-and-input/range-date-picker/__tests__/RangeDatePicker.test.js +61 -0
  24. package/dist/esm/src/components/forms-and-input/range-date-picker-with-buttons/__tests__/RangeDatePickerWithButtons.test.js +250 -1
  25. package/dist/esm/src/hooks/__tests__/useObjectUrl.test.js +117 -0
  26. package/dist/esm/src/hooks/index.js +2 -1
  27. package/dist/esm/src/hooks/useObjectUrl.js +32 -0
  28. package/dist/esm/vitest.config.js +22 -1
  29. package/dist/temp/src/components/forms-and-input/date-picker/DatePicker.js +184 -36
  30. package/dist/temp/src/components/forms-and-input/date-picker/__tests__/DatePicker.test.js +323 -0
  31. package/dist/temp/src/components/forms-and-input/file-input/FileInput.d.ts +8 -0
  32. package/dist/temp/src/components/forms-and-input/file-input/FileInput.js +16 -3
  33. package/dist/temp/src/components/forms-and-input/file-input/__tests__/FileInput.test.d.ts +1 -0
  34. package/dist/temp/src/components/forms-and-input/file-input/__tests__/FileInput.test.js +139 -0
  35. package/dist/temp/src/components/forms-and-input/image-file-input/ImageFileInput.d.ts +33 -1
  36. package/dist/temp/src/components/forms-and-input/image-file-input/ImageFileInput.js +63 -18
  37. package/dist/temp/src/components/forms-and-input/image-file-input/__tests__/ImageFileInput.test.d.ts +1 -0
  38. package/dist/temp/src/components/forms-and-input/image-file-input/__tests__/ImageFileInput.test.js +435 -0
  39. package/dist/temp/src/components/forms-and-input/image-file-input/components/ImagePreview.d.ts +20 -2
  40. package/dist/temp/src/components/forms-and-input/image-file-input/components/ImagePreview.js +7 -2
  41. package/dist/temp/src/components/forms-and-input/range-date-picker/RangeDatePicker.d.ts +9 -0
  42. package/dist/temp/src/components/forms-and-input/range-date-picker/RangeDatePicker.js +9 -6
  43. package/dist/temp/src/components/forms-and-input/range-date-picker/__tests__/RangeDatePicker.test.js +40 -0
  44. package/dist/temp/src/components/forms-and-input/range-date-picker-with-buttons/__tests__/RangeDatePickerWithButtons.test.js +190 -1
  45. package/dist/temp/src/hooks/__tests__/useObjectUrl.test.d.ts +1 -0
  46. package/dist/temp/src/hooks/__tests__/useObjectUrl.test.js +96 -0
  47. package/dist/temp/src/hooks/index.d.ts +1 -0
  48. package/dist/temp/src/hooks/index.js +1 -0
  49. package/dist/temp/src/hooks/useObjectUrl.d.ts +16 -0
  50. package/dist/temp/src/hooks/useObjectUrl.js +32 -0
  51. package/dist/temp/vitest.config.js +24 -0
  52. package/dist/types/src/components/forms-and-input/file-input/FileInput.d.ts +8 -0
  53. package/dist/types/src/components/forms-and-input/file-input/__tests__/FileInput.test.d.ts +1 -0
  54. package/dist/types/src/components/forms-and-input/image-file-input/ImageFileInput.d.ts +33 -1
  55. package/dist/types/src/components/forms-and-input/image-file-input/__tests__/ImageFileInput.test.d.ts +1 -0
  56. package/dist/types/src/components/forms-and-input/image-file-input/components/ImagePreview.d.ts +20 -2
  57. package/dist/types/src/components/forms-and-input/range-date-picker/RangeDatePicker.d.ts +9 -0
  58. package/dist/types/src/hooks/__tests__/useObjectUrl.test.d.ts +1 -0
  59. package/dist/types/src/hooks/index.d.ts +1 -0
  60. package/dist/types/src/hooks/useObjectUrl.d.ts +16 -0
  61. package/dist/ui-admin/assets/styles/style.css +20 -4
  62. package/package.json +5 -3
@@ -0,0 +1,435 @@
1
+ // @vitest-environment jsdom
2
+ import { createElement } from 'react';
3
+ import { createRoot } from 'react-dom/client';
4
+ import { act } from 'react-dom/test-utils';
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
6
+ import { ImageFileInput, ImageFileInputErrorType } from '../ImageFileInput';
7
+ const mounted = [];
8
+ function mount(props) {
9
+ const container = document.createElement('div');
10
+ document.body.appendChild(container);
11
+ const root = createRoot(container);
12
+ act(() => {
13
+ root.render(createElement(ImageFileInput, props));
14
+ });
15
+ mounted.push({ root, container });
16
+ return container;
17
+ }
18
+ const previewImages = (container) => Array.from(container.querySelectorAll('.ncua-image-file-input__preview-image'));
19
+ const emptySlot = (container) => container.querySelector('.ncua-image-file-input__empty-slot-wrapper');
20
+ const makeFile = (name, size = 10) => new File([new Uint8Array(size)], name, { type: 'image/jpeg' });
21
+ /**
22
+ * 숨겨진 file input에 파일을 실어 change 이벤트를 발생시킨다.
23
+ * jsdom에는 파일 선택 UI가 없어 files 프로퍼티를 직접 정의해야 한다.
24
+ */
25
+ function selectFiles(container, filesToSelect) {
26
+ const input = container.querySelector('input[type="file"]');
27
+ if (!input)
28
+ throw new Error('file input을 찾지 못했습니다');
29
+ Object.defineProperty(input, 'files', {
30
+ configurable: true,
31
+ value: {
32
+ ...filesToSelect,
33
+ length: filesToSelect.length,
34
+ item: (index) => filesToSelect[index] ?? null,
35
+ [Symbol.iterator]: function* () {
36
+ yield* filesToSelect;
37
+ },
38
+ },
39
+ });
40
+ act(() => {
41
+ input.dispatchEvent(new Event('change', { bubbles: true }));
42
+ });
43
+ }
44
+ afterEach(() => {
45
+ for (const { root, container } of mounted.splice(0)) {
46
+ act(() => {
47
+ root.unmount();
48
+ });
49
+ container.remove();
50
+ }
51
+ vi.restoreAllMocks();
52
+ });
53
+ describe('ImageFileInput — 저장된 이미지 URL 미리보기', () => {
54
+ it('uploadedFiles의 URL을 img src로 그대로 렌더한다 (fetch 없이)', () => {
55
+ const fetchSpy = vi.spyOn(globalThis, 'fetch');
56
+ const container = mount({
57
+ uploadedFiles: [{ fileName: 'saved.jpg', fileImageUrl: 'https://external.example.com/saved.jpg' }],
58
+ });
59
+ const images = previewImages(container);
60
+ expect(images).toHaveLength(1);
61
+ expect(images[0].getAttribute('src')).toBe('https://external.example.com/saved.jpg');
62
+ expect(fetchSpy).not.toHaveBeenCalled();
63
+ });
64
+ it('fileName을 img alt로 사용한다', () => {
65
+ const container = mount({
66
+ uploadedFiles: [{ fileName: '상품이미지.jpg', fileImageUrl: 'https://external.example.com/a.jpg' }],
67
+ });
68
+ expect(previewImages(container)[0].getAttribute('alt')).toBe('상품이미지.jpg');
69
+ });
70
+ it('여러 장을 전달한 순서대로 렌더한다', () => {
71
+ const container = mount({
72
+ maxFileCount: 5,
73
+ uploadedFiles: [
74
+ { fileName: 'a.jpg', fileImageUrl: 'https://external.example.com/a.jpg' },
75
+ { fileName: 'b.jpg', fileImageUrl: 'https://external.example.com/b.jpg' },
76
+ ],
77
+ });
78
+ expect(previewImages(container).map((img) => img.getAttribute('src'))).toEqual([
79
+ 'https://external.example.com/a.jpg',
80
+ 'https://external.example.com/b.jpg',
81
+ ]);
82
+ });
83
+ it('저장된 이미지를 삭제하면 남은 목록으로 onUploadedFilesChange를 호출한다', () => {
84
+ const onUploadedFilesChange = vi.fn();
85
+ const container = mount({
86
+ maxFileCount: 5,
87
+ uploadedFiles: [
88
+ { fileName: 'a.jpg', fileImageUrl: 'https://external.example.com/a.jpg' },
89
+ { fileName: 'b.jpg', fileImageUrl: 'https://external.example.com/b.jpg' },
90
+ ],
91
+ onUploadedFilesChange,
92
+ });
93
+ const removeButtons = container.querySelectorAll('.ncua-image-file-input__preview-remove-button');
94
+ expect(removeButtons).toHaveLength(2);
95
+ act(() => {
96
+ removeButtons[0].click();
97
+ });
98
+ expect(onUploadedFilesChange).toHaveBeenCalledTimes(1);
99
+ expect(onUploadedFilesChange).toHaveBeenCalledWith([
100
+ { fileName: 'b.jpg', fileImageUrl: 'https://external.example.com/b.jpg' },
101
+ ]);
102
+ });
103
+ // uploadedFiles는 value/onChange와 같은 controlled prop이다.
104
+ // 콜백을 넘기지 않으면 미리보기가 그대로 유지되는 것이 의도된 동작이며,
105
+ // 이 테스트는 "무반응이어도 괜찮다"가 아니라 그 계약을 고정하기 위한 것이다.
106
+ it('콜백 없이 uploadedFiles만 넘기면 삭제해도 미리보기가 유지된다 (controlled 계약)', () => {
107
+ const container = mount({
108
+ uploadedFiles: [{ fileName: 'a.jpg', fileImageUrl: 'https://external.example.com/a.jpg' }],
109
+ });
110
+ const removeButton = container.querySelector('.ncua-image-file-input__preview-remove-button');
111
+ act(() => {
112
+ removeButton?.click();
113
+ });
114
+ expect(previewImages(container)).toHaveLength(1);
115
+ });
116
+ it('같은 URL에 이름만 다른 항목이 있어도 key가 겹치지 않는다', () => {
117
+ // React 는 key 가 겹쳐도 DOM 에는 그려주고 console.error 로만 경고한다.
118
+ // 따라서 렌더 결과가 아니라 경고 발생 여부로 판정해야 한다.
119
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
120
+ const sharedUrl = 'https://external.example.com/shared.jpg';
121
+ const container = mount({
122
+ maxFileCount: 5,
123
+ uploadedFiles: [
124
+ { fileName: 'first.jpg', fileImageUrl: sharedUrl },
125
+ { fileName: 'second.jpg', fileImageUrl: sharedUrl },
126
+ ],
127
+ });
128
+ expect(previewImages(container).map((img) => img.getAttribute('alt'))).toEqual(['first.jpg', 'second.jpg']);
129
+ expect(consoleError.mock.calls.flat(Number.POSITIVE_INFINITY).join(' ')).not.toContain('same key');
130
+ });
131
+ it('URL과 파일명이 모두 같은 항목이 둘이어도 key가 겹치지 않는다', () => {
132
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
133
+ const same = { fileName: 'same.jpg', fileImageUrl: 'https://external.example.com/same.jpg' };
134
+ const container = mount({ maxFileCount: 5, uploadedFiles: [same, { ...same }] });
135
+ expect(previewImages(container)).toHaveLength(2);
136
+ expect(consoleError.mock.calls.flat(Number.POSITIVE_INFINITY).join(' ')).not.toContain('same key');
137
+ });
138
+ it('같은 파일명이 여러 개여도 클릭한 항목만 정확히 제거한다 (순번 아닌 항목 기준 삭제)', () => {
139
+ const onUploadedFilesChange = vi.fn();
140
+ const duplicated = [
141
+ { fileName: '같은이름.jpg', fileImageUrl: 'https://external.example.com/first.jpg' },
142
+ { fileName: '같은이름.jpg', fileImageUrl: 'https://external.example.com/second.jpg' },
143
+ ];
144
+ const container = mount({ maxFileCount: 5, uploadedFiles: duplicated, onUploadedFilesChange });
145
+ act(() => {
146
+ container.querySelectorAll('.ncua-image-file-input__preview-remove-button')[1].click();
147
+ });
148
+ expect(onUploadedFilesChange).toHaveBeenCalledWith([duplicated[0]]);
149
+ });
150
+ });
151
+ describe('ImageFileInput — URL 없는 항목 방어 (CDN/바닐라 등 타입 검사 밖 사용)', () => {
152
+ it('fileImageUrl이 비어 있으면 렌더하지 않는다', () => {
153
+ const container = mount({
154
+ maxFileCount: 3,
155
+ uploadedFiles: [
156
+ { fileName: 'a.jpg', fileImageUrl: 'https://external.example.com/a.jpg' },
157
+ { fileName: 'broken.jpg', fileImageUrl: '' },
158
+ ],
159
+ });
160
+ expect(previewImages(container).map((img) => img.getAttribute('src'))).toEqual([
161
+ 'https://external.example.com/a.jpg',
162
+ ]);
163
+ });
164
+ it('fileImageUrl이 비어 있는 항목은 슬롯을 차지하지 않는다', () => {
165
+ const container = mount({
166
+ maxFileCount: 2,
167
+ uploadedFiles: [
168
+ { fileName: 'a.jpg', fileImageUrl: 'https://external.example.com/a.jpg' },
169
+ { fileName: 'broken.jpg', fileImageUrl: '' },
170
+ ],
171
+ });
172
+ // 유효한 1장 < maxFileCount 2 → 빈 슬롯이 남아야 한다
173
+ expect(emptySlot(container)).not.toBeNull();
174
+ });
175
+ it('삭제 시 렌더되지 않은 항목도 호출자 목록에 그대로 남긴다', () => {
176
+ const onUploadedFilesChange = vi.fn();
177
+ const broken = { fileName: 'broken.jpg', fileImageUrl: '' };
178
+ const container = mount({
179
+ maxFileCount: 3,
180
+ uploadedFiles: [{ fileName: 'a.jpg', fileImageUrl: 'https://external.example.com/a.jpg' }, broken],
181
+ onUploadedFilesChange,
182
+ });
183
+ act(() => {
184
+ container.querySelector('.ncua-image-file-input__preview-remove-button')?.click();
185
+ });
186
+ expect(onUploadedFilesChange).toHaveBeenCalledWith([broken]);
187
+ });
188
+ });
189
+ describe('ImageFileInput — object URL 해제', () => {
190
+ /** 리렌더를 반복해도 URL이 늘지 않는지 보기 위한 횟수 */
191
+ const REPEATED_RENDERS = 3;
192
+ const originalCreateObjectURL = URL.createObjectURL;
193
+ const originalRevokeObjectURL = URL.revokeObjectURL;
194
+ let createObjectURL;
195
+ let revokeObjectURL;
196
+ beforeEach(() => {
197
+ let issued = 0;
198
+ createObjectURL = vi.fn(() => `blob:mock/${++issued}`);
199
+ revokeObjectURL = vi.fn();
200
+ URL.createObjectURL = createObjectURL;
201
+ URL.revokeObjectURL = revokeObjectURL;
202
+ });
203
+ afterEach(() => {
204
+ URL.createObjectURL = originalCreateObjectURL;
205
+ URL.revokeObjectURL = originalRevokeObjectURL;
206
+ });
207
+ it('리렌더를 반복해도 파일당 object URL을 한 번만 만든다', () => {
208
+ const file = makeFile('a.jpg');
209
+ const container = document.createElement('div');
210
+ document.body.appendChild(container);
211
+ const root = createRoot(container);
212
+ for (let renderCount = 0; renderCount < REPEATED_RENDERS; renderCount++) {
213
+ act(() => {
214
+ root.render(createElement(ImageFileInput, { value: [file], onChange: () => undefined }));
215
+ });
216
+ }
217
+ expect(createObjectURL).toHaveBeenCalledTimes(1);
218
+ act(() => {
219
+ root.unmount();
220
+ });
221
+ container.remove();
222
+ });
223
+ it('언마운트하면 만들었던 object URL을 해제한다', () => {
224
+ const container = mount({ value: [makeFile('a.jpg')], onChange: () => undefined });
225
+ expect(createObjectURL).toHaveBeenCalledTimes(1);
226
+ for (const entry of mounted.splice(0)) {
227
+ act(() => {
228
+ entry.root.unmount();
229
+ });
230
+ entry.container.remove();
231
+ }
232
+ expect(revokeObjectURL).toHaveBeenCalledWith('blob:mock/1');
233
+ expect(container.isConnected).toBe(false);
234
+ });
235
+ });
236
+ describe('ImageFileInput — 파일 선택 시 저장된 이미지 반영', () => {
237
+ it('저장된 이미지를 포함해 maxFileCount를 초과하면 EXCEED_MAX_FILE_COUNT로 거른다', () => {
238
+ const onFail = vi.fn();
239
+ const onChange = vi.fn();
240
+ const container = mount({
241
+ maxFileCount: 2,
242
+ value: [],
243
+ onChange,
244
+ onFail,
245
+ uploadedFiles: [
246
+ { fileName: 'a.jpg', fileImageUrl: 'https://external.example.com/a.jpg' },
247
+ { fileName: 'b.jpg', fileImageUrl: 'https://external.example.com/b.jpg' },
248
+ ],
249
+ });
250
+ selectFiles(container, [makeFile('new.jpg')]);
251
+ expect(onFail).toHaveBeenCalledTimes(1);
252
+ expect(onFail.mock.calls[0][0][0].errorType).toBe(ImageFileInputErrorType.EXCEED_MAX_FILE_COUNT);
253
+ // 전량 반려이므로 목록을 건드리지 않는다
254
+ expect(onChange).not.toHaveBeenCalled();
255
+ });
256
+ it('저장된 이미지와 파일명이 같으면 ALREADY_UPLOADED로 거른다', () => {
257
+ const onFail = vi.fn();
258
+ const container = mount({
259
+ maxFileCount: 5,
260
+ value: [],
261
+ onChange: () => undefined,
262
+ onFail,
263
+ uploadedFiles: [{ fileName: 'same.jpg', fileImageUrl: 'https://external.example.com/same.jpg' }],
264
+ });
265
+ selectFiles(container, [makeFile('same.jpg')]);
266
+ expect(onFail).toHaveBeenCalledTimes(1);
267
+ expect(onFail.mock.calls[0][0][0].errorType).toBe(ImageFileInputErrorType.ALREADY_UPLOADED);
268
+ });
269
+ it('maxFileCount가 1이면 새 파일 선택 시 저장된 이미지도 비운다', () => {
270
+ const onUploadedFilesChange = vi.fn();
271
+ const onChange = vi.fn();
272
+ const container = mount({
273
+ maxFileCount: 1,
274
+ value: [],
275
+ onChange,
276
+ uploadedFiles: [{ fileName: 'old.jpg', fileImageUrl: 'https://external.example.com/old.jpg' }],
277
+ onUploadedFilesChange,
278
+ });
279
+ const picked = makeFile('new.jpg');
280
+ selectFiles(container, [picked]);
281
+ expect(onChange).toHaveBeenCalledWith([picked]);
282
+ expect(onUploadedFilesChange).toHaveBeenCalledWith([]);
283
+ });
284
+ it('maxFileCount가 1이면 저장된 이미지와 이름이 같아도 교체를 허용한다', () => {
285
+ const onFail = vi.fn();
286
+ const onChange = vi.fn();
287
+ const onUploadedFilesChange = vi.fn();
288
+ const container = mount({
289
+ maxFileCount: 1,
290
+ value: [],
291
+ onChange,
292
+ onFail,
293
+ uploadedFiles: [{ fileName: 'photo.jpg', fileImageUrl: 'https://external.example.com/photo.jpg' }],
294
+ onUploadedFilesChange,
295
+ });
296
+ const picked = makeFile('photo.jpg');
297
+ selectFiles(container, [picked]);
298
+ expect(onFail).not.toHaveBeenCalled();
299
+ expect(onChange).toHaveBeenCalledWith([picked]);
300
+ expect(onUploadedFilesChange).toHaveBeenCalledWith([]);
301
+ });
302
+ it('한 장짜리 교체 시 렌더되지 않은 항목은 호출자 목록에 남긴다', () => {
303
+ const onUploadedFilesChange = vi.fn();
304
+ const broken = { fileName: 'broken.jpg', fileImageUrl: '' };
305
+ const container = mount({
306
+ maxFileCount: 1,
307
+ value: [],
308
+ onChange: () => undefined,
309
+ uploadedFiles: [{ fileName: 'old.jpg', fileImageUrl: 'https://external.example.com/old.jpg' }, broken],
310
+ onUploadedFilesChange,
311
+ });
312
+ selectFiles(container, [makeFile('new.jpg')]);
313
+ expect(onUploadedFilesChange).toHaveBeenCalledWith([broken]);
314
+ });
315
+ it('저장된 이미지가 없으면 maxFileCount 1이어도 불필요하게 콜백을 호출하지 않는다', () => {
316
+ const onUploadedFilesChange = vi.fn();
317
+ const container = mount({
318
+ maxFileCount: 1,
319
+ value: [],
320
+ onChange: () => undefined,
321
+ uploadedFiles: [],
322
+ onUploadedFilesChange,
323
+ });
324
+ selectFiles(container, [makeFile('new.jpg')]);
325
+ expect(onUploadedFilesChange).not.toHaveBeenCalled();
326
+ });
327
+ });
328
+ describe('ImageFileInput — disabled 읽기 전용', () => {
329
+ const removeButtons = (container) => container.querySelectorAll('.ncua-image-file-input__preview-remove-button');
330
+ it('disabled면 저장된 이미지의 삭제 버튼을 렌더하지 않는다', () => {
331
+ const container = mount({
332
+ disabled: true,
333
+ maxFileCount: 3,
334
+ uploadedFiles: [{ fileName: 'a.jpg', fileImageUrl: 'https://external.example.com/a.jpg' }],
335
+ });
336
+ expect(previewImages(container)).toHaveLength(1); // 썸네일은 보인다
337
+ expect(removeButtons(container)).toHaveLength(0); // 삭제 아이콘은 미노출
338
+ });
339
+ it('disabled면 새로 고른 파일의 삭제 버튼도 렌더하지 않는다', () => {
340
+ const container = mount({
341
+ disabled: true,
342
+ maxFileCount: 3,
343
+ value: [makeFile('new.jpg')],
344
+ onChange: () => undefined,
345
+ });
346
+ expect(removeButtons(container)).toHaveLength(0);
347
+ });
348
+ it('disabled가 아니면 삭제 버튼을 렌더한다', () => {
349
+ const container = mount({
350
+ maxFileCount: 3,
351
+ uploadedFiles: [{ fileName: 'a.jpg', fileImageUrl: 'https://external.example.com/a.jpg' }],
352
+ });
353
+ expect(removeButtons(container)).toHaveLength(1);
354
+ });
355
+ });
356
+ describe('ImageFileInput — 전량 반려 시 기존 선택 보존', () => {
357
+ it('maxFileCount가 1일 때 모두 반려되면 이미 고른 파일을 유지한다', () => {
358
+ const already = makeFile('picked.jpg');
359
+ const onChange = vi.fn();
360
+ const onFail = vi.fn();
361
+ const container = mount({
362
+ maxFileCount: 1,
363
+ value: [already],
364
+ onChange,
365
+ onFail,
366
+ });
367
+ // 같은 파일을 다시 고르면 ALREADY_UPLOADED 로 전량 반려된다
368
+ selectFiles(container, [makeFile('picked.jpg')]);
369
+ expect(onFail).toHaveBeenCalledTimes(1);
370
+ expect(onFail.mock.calls[0][0][0].errorType).toBe(ImageFileInputErrorType.ALREADY_UPLOADED);
371
+ expect(onChange).not.toHaveBeenCalled(); // 빈 배열로 덮어쓰지 않는다
372
+ });
373
+ it('전량 반려되면 저장된 이미지도 비우지 않는다', () => {
374
+ const saved = { fileName: 'saved.jpg', fileImageUrl: 'https://external.example.com/saved.jpg' };
375
+ const onUploadedFilesChange = vi.fn();
376
+ const container = mount({
377
+ maxFileCount: 3,
378
+ value: [makeFile('picked.jpg')],
379
+ onChange: () => undefined,
380
+ uploadedFiles: [saved],
381
+ onUploadedFilesChange,
382
+ onFail: () => undefined,
383
+ });
384
+ selectFiles(container, [makeFile('picked.jpg')]);
385
+ expect(onUploadedFilesChange).not.toHaveBeenCalled();
386
+ });
387
+ it('일부만 통과하면 통과분만 반영한다', () => {
388
+ const already = makeFile('dup.jpg');
389
+ const onChange = vi.fn();
390
+ const container = mount({
391
+ maxFileCount: 5,
392
+ value: [already],
393
+ onChange,
394
+ onFail: () => undefined,
395
+ });
396
+ const fresh = makeFile('fresh.jpg');
397
+ selectFiles(container, [makeFile('dup.jpg'), fresh]);
398
+ expect(onChange).toHaveBeenCalledWith([already, fresh]);
399
+ });
400
+ });
401
+ describe('ImageFileInput — 저장된 이미지의 슬롯 수 합산', () => {
402
+ it('uploadedFiles가 maxFileCount를 채우면 빈 슬롯을 감춘다', () => {
403
+ const container = mount({
404
+ maxFileCount: 2,
405
+ uploadedFiles: [
406
+ { fileName: 'a.jpg', fileImageUrl: 'https://external.example.com/a.jpg' },
407
+ { fileName: 'b.jpg', fileImageUrl: 'https://external.example.com/b.jpg' },
408
+ ],
409
+ });
410
+ expect(emptySlot(container)).toBeNull();
411
+ });
412
+ it('maxFileCount에 여유가 있으면 빈 슬롯을 노출한다', () => {
413
+ const container = mount({
414
+ maxFileCount: 3,
415
+ uploadedFiles: [
416
+ { fileName: 'a.jpg', fileImageUrl: 'https://external.example.com/a.jpg' },
417
+ { fileName: 'b.jpg', fileImageUrl: 'https://external.example.com/b.jpg' },
418
+ ],
419
+ });
420
+ expect(emptySlot(container)).not.toBeNull();
421
+ });
422
+ it('maxFileCount가 없을 때 저장된 이미지가 있으면 빈 슬롯을 감춘다', () => {
423
+ const container = mount({
424
+ uploadedFiles: [{ fileName: 'a.jpg', fileImageUrl: 'https://external.example.com/a.jpg' }],
425
+ });
426
+ expect(emptySlot(container)).toBeNull();
427
+ });
428
+ });
429
+ describe('ImageFileInput — 기존 File 동작 회귀', () => {
430
+ it('uploadedFiles를 넘기지 않으면 빈 슬롯만 렌더한다', () => {
431
+ const container = mount({});
432
+ expect(previewImages(container)).toHaveLength(0);
433
+ expect(emptySlot(container)).not.toBeNull();
434
+ });
435
+ });
@@ -1,5 +1,23 @@
1
1
  export interface ImagePreviewProps {
2
- file: File;
2
+ /**
3
+ * Newly selected file. Rendered via object URL.
4
+ * Either this or `imageUrl` must be provided.
5
+ */
6
+ file?: File;
7
+ /**
8
+ * URL of an image already stored on the server.
9
+ * Rendered directly, so no fetch or CORS handling is required.
10
+ */
11
+ imageUrl?: string;
12
+ /**
13
+ * Alternative text. Falls back to the file name when `file` is given.
14
+ */
15
+ alt?: string;
16
+ /**
17
+ * Read-only mode. The thumbnail stays visible but the remove button is not
18
+ * rendered, per DES-SPEC-013 §5.3 (Disabled — 삭제 아이콘 미노출).
19
+ */
20
+ disabled?: boolean;
3
21
  onRemove: () => void;
4
22
  }
5
- export declare const ImagePreview: ({ file, onRemove }: ImagePreviewProps) => import("react/jsx-runtime").JSX.Element;
23
+ export declare const ImagePreview: ({ file, imageUrl, alt, disabled, onRemove }: ImagePreviewProps) => import("react/jsx-runtime").JSX.Element | null;
@@ -1,6 +1,11 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Trash03 } from '@ncds/ui-admin-icon';
3
+ import { useObjectUrl } from '../../../../hooks/useObjectUrl';
3
4
  import { Button } from '../../../action/button';
4
- export const ImagePreview = ({ file, onRemove }) => {
5
- return (_jsxs("div", { className: "ncua-image-file-input__preview-container", children: [_jsx("img", { className: "ncua-image-file-input__preview-image", src: URL.createObjectURL(file), alt: file.name }), _jsx(Button, { size: "sm", hierarchy: "text", onlyIcon: true, leadingIcon: { type: 'icon', icon: Trash03, color: 'gray200' }, label: "Remove image", onClick: onRemove, className: "ncua-image-file-input__preview-remove-button" })] }));
5
+ export const ImagePreview = ({ file, imageUrl, alt, disabled, onRemove }) => {
6
+ const objectUrl = useObjectUrl(file);
7
+ const src = file ? objectUrl : imageUrl;
8
+ if (!src)
9
+ return null;
10
+ return (_jsxs("div", { className: "ncua-image-file-input__preview-container", children: [_jsx("img", { className: "ncua-image-file-input__preview-image", src: src, alt: alt ?? file?.name ?? '' }), !disabled && (_jsx(Button, { size: "sm", hierarchy: "text", onlyIcon: true, leadingIcon: { type: 'icon', icon: Trash03, color: 'gray200' }, label: "Remove image", onClick: onRemove, className: "ncua-image-file-input__preview-remove-button" }))] }));
6
11
  };
@@ -13,6 +13,15 @@ type RangeDatePickerProps = {
13
13
  period: number;
14
14
  };
15
15
  };
16
+ /**
17
+ * 검증 결과 통지. 시작일·종료일 effect 가 각자 자기 쪽 변경만 검증하므로,
18
+ * 사용자가 한쪽 날짜를 바꾸면 그 한쪽(`type`)만 통지된다.
19
+ *
20
+ * 단 **마운트 시점에 이미 역전된 범위**가 들어오면 두 effect 가 함께 돌아
21
+ * `type:'start'` 과 `type:'end'` 가 각각 통지된다. 이때 `newDate` 는 서로 반대편 날짜이므로
22
+ * 양쪽을 모두 반영하면 시작일·종료일이 뒤바뀐다. 한쪽만 처리해야 한다.
23
+ * (validationOption.setting 을 넘기는 사용처에서는 종전부터 동일하게 동작했다.)
24
+ */
16
25
  onDateValidation?: (params: {
17
26
  type: 'start' | 'end';
18
27
  errorType: 'reset' | 'period' | 'overlap' | null;
@@ -90,17 +90,20 @@ const RangeDatePicker = forwardRef(({ startDateOptions, endDateOptions, validati
90
90
  if (!areBothDatesValid(startDateOptions.currentDate, endDateOptions.currentDate)) {
91
91
  return;
92
92
  }
93
- // 종료일이 '오늘'이면 종료측 검증을 건너뛴다: shopby 종료일=오늘이 정상 기본값이라
94
- // (고도몰=어제) 상태를 에러로 잡지 않기 위함. (도입 d1bd8751)
95
- const isNotTodayEndDate = !moment(endDateOptions.currentDate).isSame(moment(), 'day');
96
- if (!validationOption?.setting || !isNotTodayEndDate) {
97
- return;
98
- }
93
+ // overlap(종료일 < 시작일)은 시작일 effect 동일하게 setting 유무보다 먼저 검증한다.
94
+ // 과거에는 아래 setting 가드 뒤에 있어서, setting 넘기지 않는 사용처가 종료일을 시작일보다
95
+ // 앞으로 두어도 onDateValidation 이 호출되지 않았다. (#410)
99
96
  const isOverDate = moment(endDateOptions.currentDate).isBefore(startDateOptions.currentDate);
100
97
  if (isOverDate) {
101
98
  changeSettingDateAndAlert('end');
102
99
  return;
103
100
  }
101
+ // 종료일이 '오늘'이면 기간(period) 검증은 건너뛴다: shopby 는 종료일=오늘이 정상 기본값이라
102
+ // (고도몰=어제) 이 상태를 에러로 잡지 않기 위함. (도입 d1bd8751)
103
+ const isNotTodayEndDate = !moment(endDateOptions.currentDate).isSame(moment(), 'day');
104
+ if (!validationOption?.setting || !isNotTodayEndDate) {
105
+ return;
106
+ }
104
107
  const { unit, period } = validationOption.setting;
105
108
  const isValidPeriod = moment(endDateOptions.currentDate).isSameOrBefore(moment(startDateOptions.currentDate).add(period, unit));
106
109
  if (isValidPeriod) {
@@ -58,3 +58,43 @@ describe('#1 빈/무효 날짜 검증 가드', () => {
58
58
  expect(onDateValidation).toHaveBeenCalled();
59
59
  });
60
60
  });
61
+ describe('#2 validationOption.setting 없이도 overlap 을 검증한다 (#410)', () => {
62
+ it('종료일이 시작일보다 이전이면 type:end / errorType:overlap 으로 통지한다', () => {
63
+ const onDateValidation = vi.fn();
64
+ mount({
65
+ startDateOptions: { currentDate: '2024-03-10', onChangeDate: () => undefined },
66
+ endDateOptions: { currentDate: '2024-03-05', onChangeDate: () => undefined },
67
+ onDateValidation,
68
+ });
69
+ expect(onDateValidation).toHaveBeenCalledWith({
70
+ type: 'end',
71
+ errorType: 'overlap',
72
+ newDate: '2024-03-10',
73
+ currentDate: '2024-03-05',
74
+ });
75
+ });
76
+ it('시작일이 종료일보다 이후이면 type:start / errorType:overlap 으로 통지한다', () => {
77
+ const onDateValidation = vi.fn();
78
+ mount({
79
+ startDateOptions: { currentDate: '2024-03-10', onChangeDate: () => undefined },
80
+ endDateOptions: { currentDate: '2024-03-05', onChangeDate: () => undefined },
81
+ onDateValidation,
82
+ });
83
+ expect(onDateValidation).toHaveBeenCalledWith({
84
+ type: 'start',
85
+ errorType: 'overlap',
86
+ newDate: '2024-03-05',
87
+ currentDate: '2024-03-10',
88
+ });
89
+ });
90
+ it('정상 범위(시작일 <= 종료일)면 setting 없이 overlap 을 통지하지 않는다', () => {
91
+ const onDateValidation = vi.fn();
92
+ mount({
93
+ startDateOptions: { currentDate: '2024-03-01', onChangeDate: () => undefined },
94
+ endDateOptions: { currentDate: '2024-03-05', onChangeDate: () => undefined },
95
+ onDateValidation,
96
+ });
97
+ const overlapCalls = onDateValidation.mock.calls.filter(([arg]) => arg?.errorType === 'overlap');
98
+ expect(overlapCalls).toHaveLength(0);
99
+ });
100
+ });