@ncds/ui-admin 1.8.15 → 1.8.17

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 (42) hide show
  1. package/dist/cjs/src/components/forms-and-input/file-input/FileInput.js +25 -7
  2. package/dist/cjs/src/components/forms-and-input/file-input/__tests__/FileInput.test.js +168 -0
  3. package/dist/cjs/src/components/forms-and-input/image-file-input/ImageFileInput.js +76 -19
  4. package/dist/cjs/src/components/forms-and-input/image-file-input/__tests__/ImageFileInput.test.js +533 -0
  5. package/dist/cjs/src/components/forms-and-input/image-file-input/components/ImagePreview.js +10 -3
  6. package/dist/cjs/src/hooks/__tests__/useObjectUrl.test.js +120 -0
  7. package/dist/cjs/src/hooks/index.js +11 -0
  8. package/dist/cjs/src/hooks/useObjectUrl.js +39 -0
  9. package/dist/esm/src/components/forms-and-input/file-input/FileInput.js +25 -7
  10. package/dist/esm/src/components/forms-and-input/file-input/__tests__/FileInput.test.js +165 -0
  11. package/dist/esm/src/components/forms-and-input/image-file-input/ImageFileInput.js +71 -19
  12. package/dist/esm/src/components/forms-and-input/image-file-input/__tests__/ImageFileInput.test.js +530 -0
  13. package/dist/esm/src/components/forms-and-input/image-file-input/components/ImagePreview.js +10 -3
  14. package/dist/esm/src/hooks/__tests__/useObjectUrl.test.js +117 -0
  15. package/dist/esm/src/hooks/index.js +2 -1
  16. package/dist/esm/src/hooks/useObjectUrl.js +32 -0
  17. package/dist/temp/src/components/forms-and-input/file-input/FileInput.d.ts +8 -0
  18. package/dist/temp/src/components/forms-and-input/file-input/FileInput.js +16 -3
  19. package/dist/temp/src/components/forms-and-input/file-input/__tests__/FileInput.test.d.ts +1 -0
  20. package/dist/temp/src/components/forms-and-input/file-input/__tests__/FileInput.test.js +139 -0
  21. package/dist/temp/src/components/forms-and-input/image-file-input/ImageFileInput.d.ts +33 -1
  22. package/dist/temp/src/components/forms-and-input/image-file-input/ImageFileInput.js +63 -18
  23. package/dist/temp/src/components/forms-and-input/image-file-input/__tests__/ImageFileInput.test.d.ts +1 -0
  24. package/dist/temp/src/components/forms-and-input/image-file-input/__tests__/ImageFileInput.test.js +435 -0
  25. package/dist/temp/src/components/forms-and-input/image-file-input/components/ImagePreview.d.ts +20 -2
  26. package/dist/temp/src/components/forms-and-input/image-file-input/components/ImagePreview.js +7 -2
  27. package/dist/temp/src/hooks/__tests__/useObjectUrl.test.d.ts +1 -0
  28. package/dist/temp/src/hooks/__tests__/useObjectUrl.test.js +96 -0
  29. package/dist/temp/src/hooks/index.d.ts +1 -0
  30. package/dist/temp/src/hooks/index.js +1 -0
  31. package/dist/temp/src/hooks/useObjectUrl.d.ts +16 -0
  32. package/dist/temp/src/hooks/useObjectUrl.js +32 -0
  33. package/dist/types/src/components/forms-and-input/file-input/FileInput.d.ts +8 -0
  34. package/dist/types/src/components/forms-and-input/file-input/__tests__/FileInput.test.d.ts +1 -0
  35. package/dist/types/src/components/forms-and-input/image-file-input/ImageFileInput.d.ts +33 -1
  36. package/dist/types/src/components/forms-and-input/image-file-input/__tests__/ImageFileInput.test.d.ts +1 -0
  37. package/dist/types/src/components/forms-and-input/image-file-input/components/ImagePreview.d.ts +20 -2
  38. package/dist/types/src/hooks/__tests__/useObjectUrl.test.d.ts +1 -0
  39. package/dist/types/src/hooks/index.d.ts +1 -0
  40. package/dist/types/src/hooks/useObjectUrl.d.ts +16 -0
  41. package/dist/ui-admin/assets/styles/style.css +21 -5
  42. package/package.json +1 -1
@@ -0,0 +1,530 @@
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(/*#__PURE__*/createElement(ImageFileInput, props));
14
+ });
15
+ mounted.push({
16
+ root,
17
+ container
18
+ });
19
+ return container;
20
+ }
21
+ const previewImages = container => Array.from(container.querySelectorAll('.ncua-image-file-input__preview-image'));
22
+ const emptySlot = container => container.querySelector('.ncua-image-file-input__empty-slot-wrapper');
23
+ const makeFile = function (name) {
24
+ let size = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 10;
25
+ return new File([new Uint8Array(size)], name, {
26
+ type: 'image/jpeg'
27
+ });
28
+ };
29
+ /**
30
+ * 숨겨진 file input에 파일을 실어 change 이벤트를 발생시킨다.
31
+ * jsdom에는 파일 선택 UI가 없어 files 프로퍼티를 직접 정의해야 한다.
32
+ */
33
+ function selectFiles(container, filesToSelect) {
34
+ const input = container.querySelector('input[type="file"]');
35
+ if (!input) throw new Error('file input을 찾지 못했습니다');
36
+ Object.defineProperty(input, 'files', {
37
+ configurable: true,
38
+ value: {
39
+ ...filesToSelect,
40
+ length: filesToSelect.length,
41
+ item: index => filesToSelect[index] ?? null,
42
+ [Symbol.iterator]: function* () {
43
+ yield* filesToSelect;
44
+ }
45
+ }
46
+ });
47
+ act(() => {
48
+ input.dispatchEvent(new Event('change', {
49
+ bubbles: true
50
+ }));
51
+ });
52
+ }
53
+ afterEach(() => {
54
+ for (const {
55
+ root,
56
+ container
57
+ } of mounted.splice(0)) {
58
+ act(() => {
59
+ root.unmount();
60
+ });
61
+ container.remove();
62
+ }
63
+ vi.restoreAllMocks();
64
+ });
65
+ describe('ImageFileInput — 저장된 이미지 URL 미리보기', () => {
66
+ it('uploadedFiles의 URL을 img src로 그대로 렌더한다 (fetch 없이)', () => {
67
+ const fetchSpy = vi.spyOn(globalThis, 'fetch');
68
+ const container = mount({
69
+ uploadedFiles: [{
70
+ fileName: 'saved.jpg',
71
+ fileImageUrl: 'https://external.example.com/saved.jpg'
72
+ }]
73
+ });
74
+ const images = previewImages(container);
75
+ expect(images).toHaveLength(1);
76
+ expect(images[0].getAttribute('src')).toBe('https://external.example.com/saved.jpg');
77
+ expect(fetchSpy).not.toHaveBeenCalled();
78
+ });
79
+ it('fileName을 img alt로 사용한다', () => {
80
+ const container = mount({
81
+ uploadedFiles: [{
82
+ fileName: '상품이미지.jpg',
83
+ fileImageUrl: 'https://external.example.com/a.jpg'
84
+ }]
85
+ });
86
+ expect(previewImages(container)[0].getAttribute('alt')).toBe('상품이미지.jpg');
87
+ });
88
+ it('여러 장을 전달한 순서대로 렌더한다', () => {
89
+ const container = mount({
90
+ maxFileCount: 5,
91
+ uploadedFiles: [{
92
+ fileName: 'a.jpg',
93
+ fileImageUrl: 'https://external.example.com/a.jpg'
94
+ }, {
95
+ fileName: 'b.jpg',
96
+ fileImageUrl: 'https://external.example.com/b.jpg'
97
+ }]
98
+ });
99
+ expect(previewImages(container).map(img => img.getAttribute('src'))).toEqual(['https://external.example.com/a.jpg', 'https://external.example.com/b.jpg']);
100
+ });
101
+ it('저장된 이미지를 삭제하면 남은 목록으로 onUploadedFilesChange를 호출한다', () => {
102
+ const onUploadedFilesChange = vi.fn();
103
+ const container = mount({
104
+ maxFileCount: 5,
105
+ uploadedFiles: [{
106
+ fileName: 'a.jpg',
107
+ fileImageUrl: 'https://external.example.com/a.jpg'
108
+ }, {
109
+ fileName: 'b.jpg',
110
+ fileImageUrl: 'https://external.example.com/b.jpg'
111
+ }],
112
+ onUploadedFilesChange
113
+ });
114
+ const removeButtons = container.querySelectorAll('.ncua-image-file-input__preview-remove-button');
115
+ expect(removeButtons).toHaveLength(2);
116
+ act(() => {
117
+ removeButtons[0].click();
118
+ });
119
+ expect(onUploadedFilesChange).toHaveBeenCalledTimes(1);
120
+ expect(onUploadedFilesChange).toHaveBeenCalledWith([{
121
+ fileName: 'b.jpg',
122
+ fileImageUrl: 'https://external.example.com/b.jpg'
123
+ }]);
124
+ });
125
+ // uploadedFiles는 value/onChange와 같은 controlled prop이다.
126
+ // 콜백을 넘기지 않으면 미리보기가 그대로 유지되는 것이 의도된 동작이며,
127
+ // 이 테스트는 "무반응이어도 괜찮다"가 아니라 그 계약을 고정하기 위한 것이다.
128
+ it('콜백 없이 uploadedFiles만 넘기면 삭제해도 미리보기가 유지된다 (controlled 계약)', () => {
129
+ const container = mount({
130
+ uploadedFiles: [{
131
+ fileName: 'a.jpg',
132
+ fileImageUrl: 'https://external.example.com/a.jpg'
133
+ }]
134
+ });
135
+ const removeButton = container.querySelector('.ncua-image-file-input__preview-remove-button');
136
+ act(() => {
137
+ removeButton?.click();
138
+ });
139
+ expect(previewImages(container)).toHaveLength(1);
140
+ });
141
+ it('같은 URL에 이름만 다른 항목이 있어도 key가 겹치지 않는다', () => {
142
+ // React 는 key 가 겹쳐도 DOM 에는 그려주고 console.error 로만 경고한다.
143
+ // 따라서 렌더 결과가 아니라 경고 발생 여부로 판정해야 한다.
144
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
145
+ const sharedUrl = 'https://external.example.com/shared.jpg';
146
+ const container = mount({
147
+ maxFileCount: 5,
148
+ uploadedFiles: [{
149
+ fileName: 'first.jpg',
150
+ fileImageUrl: sharedUrl
151
+ }, {
152
+ fileName: 'second.jpg',
153
+ fileImageUrl: sharedUrl
154
+ }]
155
+ });
156
+ expect(previewImages(container).map(img => img.getAttribute('alt'))).toEqual(['first.jpg', 'second.jpg']);
157
+ expect(consoleError.mock.calls.flat(Number.POSITIVE_INFINITY).join(' ')).not.toContain('same key');
158
+ });
159
+ it('URL과 파일명이 모두 같은 항목이 둘이어도 key가 겹치지 않는다', () => {
160
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
161
+ const same = {
162
+ fileName: 'same.jpg',
163
+ fileImageUrl: 'https://external.example.com/same.jpg'
164
+ };
165
+ const container = mount({
166
+ maxFileCount: 5,
167
+ uploadedFiles: [same, {
168
+ ...same
169
+ }]
170
+ });
171
+ expect(previewImages(container)).toHaveLength(2);
172
+ expect(consoleError.mock.calls.flat(Number.POSITIVE_INFINITY).join(' ')).not.toContain('same key');
173
+ });
174
+ it('같은 파일명이 여러 개여도 클릭한 항목만 정확히 제거한다 (순번 아닌 항목 기준 삭제)', () => {
175
+ const onUploadedFilesChange = vi.fn();
176
+ const duplicated = [{
177
+ fileName: '같은이름.jpg',
178
+ fileImageUrl: 'https://external.example.com/first.jpg'
179
+ }, {
180
+ fileName: '같은이름.jpg',
181
+ fileImageUrl: 'https://external.example.com/second.jpg'
182
+ }];
183
+ const container = mount({
184
+ maxFileCount: 5,
185
+ uploadedFiles: duplicated,
186
+ onUploadedFilesChange
187
+ });
188
+ act(() => {
189
+ container.querySelectorAll('.ncua-image-file-input__preview-remove-button')[1].click();
190
+ });
191
+ expect(onUploadedFilesChange).toHaveBeenCalledWith([duplicated[0]]);
192
+ });
193
+ });
194
+ describe('ImageFileInput — URL 없는 항목 방어 (CDN/바닐라 등 타입 검사 밖 사용)', () => {
195
+ it('fileImageUrl이 비어 있으면 렌더하지 않는다', () => {
196
+ const container = mount({
197
+ maxFileCount: 3,
198
+ uploadedFiles: [{
199
+ fileName: 'a.jpg',
200
+ fileImageUrl: 'https://external.example.com/a.jpg'
201
+ }, {
202
+ fileName: 'broken.jpg',
203
+ fileImageUrl: ''
204
+ }]
205
+ });
206
+ expect(previewImages(container).map(img => img.getAttribute('src'))).toEqual(['https://external.example.com/a.jpg']);
207
+ });
208
+ it('fileImageUrl이 비어 있는 항목은 슬롯을 차지하지 않는다', () => {
209
+ const container = mount({
210
+ maxFileCount: 2,
211
+ uploadedFiles: [{
212
+ fileName: 'a.jpg',
213
+ fileImageUrl: 'https://external.example.com/a.jpg'
214
+ }, {
215
+ fileName: 'broken.jpg',
216
+ fileImageUrl: ''
217
+ }]
218
+ });
219
+ // 유효한 1장 < maxFileCount 2 → 빈 슬롯이 남아야 한다
220
+ expect(emptySlot(container)).not.toBeNull();
221
+ });
222
+ it('삭제 시 렌더되지 않은 항목도 호출자 목록에 그대로 남긴다', () => {
223
+ const onUploadedFilesChange = vi.fn();
224
+ const broken = {
225
+ fileName: 'broken.jpg',
226
+ fileImageUrl: ''
227
+ };
228
+ const container = mount({
229
+ maxFileCount: 3,
230
+ uploadedFiles: [{
231
+ fileName: 'a.jpg',
232
+ fileImageUrl: 'https://external.example.com/a.jpg'
233
+ }, broken],
234
+ onUploadedFilesChange
235
+ });
236
+ act(() => {
237
+ container.querySelector('.ncua-image-file-input__preview-remove-button')?.click();
238
+ });
239
+ expect(onUploadedFilesChange).toHaveBeenCalledWith([broken]);
240
+ });
241
+ });
242
+ describe('ImageFileInput — object URL 해제', () => {
243
+ /** 리렌더를 반복해도 URL이 늘지 않는지 보기 위한 횟수 */
244
+ const REPEATED_RENDERS = 3;
245
+ const originalCreateObjectURL = URL.createObjectURL;
246
+ const originalRevokeObjectURL = URL.revokeObjectURL;
247
+ let createObjectURL;
248
+ let revokeObjectURL;
249
+ beforeEach(() => {
250
+ let issued = 0;
251
+ createObjectURL = vi.fn(() => `blob:mock/${++issued}`);
252
+ revokeObjectURL = vi.fn();
253
+ URL.createObjectURL = createObjectURL;
254
+ URL.revokeObjectURL = revokeObjectURL;
255
+ });
256
+ afterEach(() => {
257
+ URL.createObjectURL = originalCreateObjectURL;
258
+ URL.revokeObjectURL = originalRevokeObjectURL;
259
+ });
260
+ it('리렌더를 반복해도 파일당 object URL을 한 번만 만든다', () => {
261
+ const file = makeFile('a.jpg');
262
+ const container = document.createElement('div');
263
+ document.body.appendChild(container);
264
+ const root = createRoot(container);
265
+ for (let renderCount = 0; renderCount < REPEATED_RENDERS; renderCount++) {
266
+ act(() => {
267
+ root.render(/*#__PURE__*/createElement(ImageFileInput, {
268
+ value: [file],
269
+ onChange: () => undefined
270
+ }));
271
+ });
272
+ }
273
+ expect(createObjectURL).toHaveBeenCalledTimes(1);
274
+ act(() => {
275
+ root.unmount();
276
+ });
277
+ container.remove();
278
+ });
279
+ it('언마운트하면 만들었던 object URL을 해제한다', () => {
280
+ const container = mount({
281
+ value: [makeFile('a.jpg')],
282
+ onChange: () => undefined
283
+ });
284
+ expect(createObjectURL).toHaveBeenCalledTimes(1);
285
+ for (const entry of mounted.splice(0)) {
286
+ act(() => {
287
+ entry.root.unmount();
288
+ });
289
+ entry.container.remove();
290
+ }
291
+ expect(revokeObjectURL).toHaveBeenCalledWith('blob:mock/1');
292
+ expect(container.isConnected).toBe(false);
293
+ });
294
+ });
295
+ describe('ImageFileInput — 파일 선택 시 저장된 이미지 반영', () => {
296
+ it('저장된 이미지를 포함해 maxFileCount를 초과하면 EXCEED_MAX_FILE_COUNT로 거른다', () => {
297
+ const onFail = vi.fn();
298
+ const onChange = vi.fn();
299
+ const container = mount({
300
+ maxFileCount: 2,
301
+ value: [],
302
+ onChange,
303
+ onFail,
304
+ uploadedFiles: [{
305
+ fileName: 'a.jpg',
306
+ fileImageUrl: 'https://external.example.com/a.jpg'
307
+ }, {
308
+ fileName: 'b.jpg',
309
+ fileImageUrl: 'https://external.example.com/b.jpg'
310
+ }]
311
+ });
312
+ selectFiles(container, [makeFile('new.jpg')]);
313
+ expect(onFail).toHaveBeenCalledTimes(1);
314
+ expect(onFail.mock.calls[0][0][0].errorType).toBe(ImageFileInputErrorType.EXCEED_MAX_FILE_COUNT);
315
+ // 전량 반려이므로 목록을 건드리지 않는다
316
+ expect(onChange).not.toHaveBeenCalled();
317
+ });
318
+ it('저장된 이미지와 파일명이 같으면 ALREADY_UPLOADED로 거른다', () => {
319
+ const onFail = vi.fn();
320
+ const container = mount({
321
+ maxFileCount: 5,
322
+ value: [],
323
+ onChange: () => undefined,
324
+ onFail,
325
+ uploadedFiles: [{
326
+ fileName: 'same.jpg',
327
+ fileImageUrl: 'https://external.example.com/same.jpg'
328
+ }]
329
+ });
330
+ selectFiles(container, [makeFile('same.jpg')]);
331
+ expect(onFail).toHaveBeenCalledTimes(1);
332
+ expect(onFail.mock.calls[0][0][0].errorType).toBe(ImageFileInputErrorType.ALREADY_UPLOADED);
333
+ });
334
+ it('maxFileCount가 1이면 새 파일 선택 시 저장된 이미지도 비운다', () => {
335
+ const onUploadedFilesChange = vi.fn();
336
+ const onChange = vi.fn();
337
+ const container = mount({
338
+ maxFileCount: 1,
339
+ value: [],
340
+ onChange,
341
+ uploadedFiles: [{
342
+ fileName: 'old.jpg',
343
+ fileImageUrl: 'https://external.example.com/old.jpg'
344
+ }],
345
+ onUploadedFilesChange
346
+ });
347
+ const picked = makeFile('new.jpg');
348
+ selectFiles(container, [picked]);
349
+ expect(onChange).toHaveBeenCalledWith([picked]);
350
+ expect(onUploadedFilesChange).toHaveBeenCalledWith([]);
351
+ });
352
+ it('maxFileCount가 1이면 저장된 이미지와 이름이 같아도 교체를 허용한다', () => {
353
+ const onFail = vi.fn();
354
+ const onChange = vi.fn();
355
+ const onUploadedFilesChange = vi.fn();
356
+ const container = mount({
357
+ maxFileCount: 1,
358
+ value: [],
359
+ onChange,
360
+ onFail,
361
+ uploadedFiles: [{
362
+ fileName: 'photo.jpg',
363
+ fileImageUrl: 'https://external.example.com/photo.jpg'
364
+ }],
365
+ onUploadedFilesChange
366
+ });
367
+ const picked = makeFile('photo.jpg');
368
+ selectFiles(container, [picked]);
369
+ expect(onFail).not.toHaveBeenCalled();
370
+ expect(onChange).toHaveBeenCalledWith([picked]);
371
+ expect(onUploadedFilesChange).toHaveBeenCalledWith([]);
372
+ });
373
+ it('한 장짜리 교체 시 렌더되지 않은 항목은 호출자 목록에 남긴다', () => {
374
+ const onUploadedFilesChange = vi.fn();
375
+ const broken = {
376
+ fileName: 'broken.jpg',
377
+ fileImageUrl: ''
378
+ };
379
+ const container = mount({
380
+ maxFileCount: 1,
381
+ value: [],
382
+ onChange: () => undefined,
383
+ uploadedFiles: [{
384
+ fileName: 'old.jpg',
385
+ fileImageUrl: 'https://external.example.com/old.jpg'
386
+ }, broken],
387
+ onUploadedFilesChange
388
+ });
389
+ selectFiles(container, [makeFile('new.jpg')]);
390
+ expect(onUploadedFilesChange).toHaveBeenCalledWith([broken]);
391
+ });
392
+ it('저장된 이미지가 없으면 maxFileCount 1이어도 불필요하게 콜백을 호출하지 않는다', () => {
393
+ const onUploadedFilesChange = vi.fn();
394
+ const container = mount({
395
+ maxFileCount: 1,
396
+ value: [],
397
+ onChange: () => undefined,
398
+ uploadedFiles: [],
399
+ onUploadedFilesChange
400
+ });
401
+ selectFiles(container, [makeFile('new.jpg')]);
402
+ expect(onUploadedFilesChange).not.toHaveBeenCalled();
403
+ });
404
+ });
405
+ describe('ImageFileInput — disabled 읽기 전용', () => {
406
+ const removeButtons = container => container.querySelectorAll('.ncua-image-file-input__preview-remove-button');
407
+ it('disabled면 저장된 이미지의 삭제 버튼을 렌더하지 않는다', () => {
408
+ const container = mount({
409
+ disabled: true,
410
+ maxFileCount: 3,
411
+ uploadedFiles: [{
412
+ fileName: 'a.jpg',
413
+ fileImageUrl: 'https://external.example.com/a.jpg'
414
+ }]
415
+ });
416
+ expect(previewImages(container)).toHaveLength(1); // 썸네일은 보인다
417
+ expect(removeButtons(container)).toHaveLength(0); // 삭제 아이콘은 미노출
418
+ });
419
+ it('disabled면 새로 고른 파일의 삭제 버튼도 렌더하지 않는다', () => {
420
+ const container = mount({
421
+ disabled: true,
422
+ maxFileCount: 3,
423
+ value: [makeFile('new.jpg')],
424
+ onChange: () => undefined
425
+ });
426
+ expect(removeButtons(container)).toHaveLength(0);
427
+ });
428
+ it('disabled가 아니면 삭제 버튼을 렌더한다', () => {
429
+ const container = mount({
430
+ maxFileCount: 3,
431
+ uploadedFiles: [{
432
+ fileName: 'a.jpg',
433
+ fileImageUrl: 'https://external.example.com/a.jpg'
434
+ }]
435
+ });
436
+ expect(removeButtons(container)).toHaveLength(1);
437
+ });
438
+ });
439
+ describe('ImageFileInput — 전량 반려 시 기존 선택 보존', () => {
440
+ it('maxFileCount가 1일 때 모두 반려되면 이미 고른 파일을 유지한다', () => {
441
+ const already = makeFile('picked.jpg');
442
+ const onChange = vi.fn();
443
+ const onFail = vi.fn();
444
+ const container = mount({
445
+ maxFileCount: 1,
446
+ value: [already],
447
+ onChange,
448
+ onFail
449
+ });
450
+ // 같은 파일을 다시 고르면 ALREADY_UPLOADED 로 전량 반려된다
451
+ selectFiles(container, [makeFile('picked.jpg')]);
452
+ expect(onFail).toHaveBeenCalledTimes(1);
453
+ expect(onFail.mock.calls[0][0][0].errorType).toBe(ImageFileInputErrorType.ALREADY_UPLOADED);
454
+ expect(onChange).not.toHaveBeenCalled(); // 빈 배열로 덮어쓰지 않는다
455
+ });
456
+ it('전량 반려되면 저장된 이미지도 비우지 않는다', () => {
457
+ const saved = {
458
+ fileName: 'saved.jpg',
459
+ fileImageUrl: 'https://external.example.com/saved.jpg'
460
+ };
461
+ const onUploadedFilesChange = vi.fn();
462
+ const container = mount({
463
+ maxFileCount: 3,
464
+ value: [makeFile('picked.jpg')],
465
+ onChange: () => undefined,
466
+ uploadedFiles: [saved],
467
+ onUploadedFilesChange,
468
+ onFail: () => undefined
469
+ });
470
+ selectFiles(container, [makeFile('picked.jpg')]);
471
+ expect(onUploadedFilesChange).not.toHaveBeenCalled();
472
+ });
473
+ it('일부만 통과하면 통과분만 반영한다', () => {
474
+ const already = makeFile('dup.jpg');
475
+ const onChange = vi.fn();
476
+ const container = mount({
477
+ maxFileCount: 5,
478
+ value: [already],
479
+ onChange,
480
+ onFail: () => undefined
481
+ });
482
+ const fresh = makeFile('fresh.jpg');
483
+ selectFiles(container, [makeFile('dup.jpg'), fresh]);
484
+ expect(onChange).toHaveBeenCalledWith([already, fresh]);
485
+ });
486
+ });
487
+ describe('ImageFileInput — 저장된 이미지의 슬롯 수 합산', () => {
488
+ it('uploadedFiles가 maxFileCount를 채우면 빈 슬롯을 감춘다', () => {
489
+ const container = mount({
490
+ maxFileCount: 2,
491
+ uploadedFiles: [{
492
+ fileName: 'a.jpg',
493
+ fileImageUrl: 'https://external.example.com/a.jpg'
494
+ }, {
495
+ fileName: 'b.jpg',
496
+ fileImageUrl: 'https://external.example.com/b.jpg'
497
+ }]
498
+ });
499
+ expect(emptySlot(container)).toBeNull();
500
+ });
501
+ it('maxFileCount에 여유가 있으면 빈 슬롯을 노출한다', () => {
502
+ const container = mount({
503
+ maxFileCount: 3,
504
+ uploadedFiles: [{
505
+ fileName: 'a.jpg',
506
+ fileImageUrl: 'https://external.example.com/a.jpg'
507
+ }, {
508
+ fileName: 'b.jpg',
509
+ fileImageUrl: 'https://external.example.com/b.jpg'
510
+ }]
511
+ });
512
+ expect(emptySlot(container)).not.toBeNull();
513
+ });
514
+ it('maxFileCount가 없을 때 저장된 이미지가 있으면 빈 슬롯을 감춘다', () => {
515
+ const container = mount({
516
+ uploadedFiles: [{
517
+ fileName: 'a.jpg',
518
+ fileImageUrl: 'https://external.example.com/a.jpg'
519
+ }]
520
+ });
521
+ expect(emptySlot(container)).toBeNull();
522
+ });
523
+ });
524
+ describe('ImageFileInput — 기존 File 동작 회귀', () => {
525
+ it('uploadedFiles를 넘기지 않으면 빈 슬롯만 렌더한다', () => {
526
+ const container = mount({});
527
+ expect(previewImages(container)).toHaveLength(0);
528
+ expect(emptySlot(container)).not.toBeNull();
529
+ });
530
+ });
@@ -1,18 +1,25 @@
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
5
  export const ImagePreview = _ref => {
5
6
  let {
6
7
  file,
8
+ imageUrl,
9
+ alt,
10
+ disabled,
7
11
  onRemove
8
12
  } = _ref;
13
+ const objectUrl = useObjectUrl(file);
14
+ const src = file ? objectUrl : imageUrl;
15
+ if (!src) return null;
9
16
  return _jsxs("div", {
10
17
  className: "ncua-image-file-input__preview-container",
11
18
  children: [_jsx("img", {
12
19
  className: "ncua-image-file-input__preview-image",
13
- src: URL.createObjectURL(file),
14
- alt: file.name
15
- }), _jsx(Button, {
20
+ src: src,
21
+ alt: alt ?? file?.name ?? ''
22
+ }), !disabled && _jsx(Button, {
16
23
  size: "sm",
17
24
  hierarchy: "text",
18
25
  onlyIcon: true,
@@ -0,0 +1,117 @@
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 { useObjectUrl } from '../useObjectUrl';
7
+ const originalCreateObjectURL = URL.createObjectURL;
8
+ const originalRevokeObjectURL = URL.revokeObjectURL;
9
+ let createObjectURL;
10
+ let revokeObjectURL;
11
+ beforeEach(() => {
12
+ let issued = 0;
13
+ createObjectURL = vi.fn(() => `blob:mock/${++issued}`);
14
+ revokeObjectURL = vi.fn();
15
+ URL.createObjectURL = createObjectURL;
16
+ URL.revokeObjectURL = revokeObjectURL;
17
+ });
18
+ const mounted = [];
19
+ afterEach(() => {
20
+ for (const {
21
+ root,
22
+ container
23
+ } of mounted.splice(0)) {
24
+ act(() => {
25
+ root.unmount();
26
+ });
27
+ container.remove();
28
+ }
29
+ URL.createObjectURL = originalCreateObjectURL;
30
+ URL.revokeObjectURL = originalRevokeObjectURL;
31
+ });
32
+ /** 내용은 검증 대상이 아니므로 최소 크기만 채운다 */
33
+ const DUMMY_FILE_BYTES = 4;
34
+ const makeFile = name => new File([new Uint8Array(DUMMY_FILE_BYTES)], name, {
35
+ type: 'image/jpeg'
36
+ });
37
+ /** 훅 결과를 data-url 속성으로 노출하는 관찰용 컴포넌트 */
38
+ const Probe = _ref => {
39
+ let {
40
+ file
41
+ } = _ref;
42
+ return /*#__PURE__*/createElement('span', {
43
+ 'data-url': useObjectUrl(file) ?? ''
44
+ });
45
+ };
46
+ function renderProbe(file) {
47
+ const container = document.createElement('div');
48
+ document.body.appendChild(container);
49
+ const root = createRoot(container);
50
+ const render = nextFile => {
51
+ act(() => {
52
+ root.render(/*#__PURE__*/createElement(Probe, {
53
+ file: nextFile
54
+ }));
55
+ });
56
+ };
57
+ render(file);
58
+ mounted.push({
59
+ root,
60
+ container
61
+ });
62
+ const currentUrl = () => container.querySelector('span')?.getAttribute('data-url');
63
+ const unmount = () => {
64
+ act(() => {
65
+ root.unmount();
66
+ });
67
+ const index = mounted.findIndex(entry => entry.root === root);
68
+ mounted.splice(index, 1);
69
+ container.remove();
70
+ };
71
+ return {
72
+ render,
73
+ currentUrl,
74
+ unmount
75
+ };
76
+ }
77
+ describe('useObjectUrl', () => {
78
+ it('파일을 주면 object URL을 만들어 반환한다', () => {
79
+ const probe = renderProbe(makeFile('a.jpg'));
80
+ expect(createObjectURL).toHaveBeenCalledTimes(1);
81
+ expect(probe.currentUrl()).toBe('blob:mock/1');
82
+ });
83
+ it('같은 파일로 여러 번 리렌더해도 URL을 한 번만 만든다', () => {
84
+ const file = makeFile('a.jpg');
85
+ const probe = renderProbe(file);
86
+ probe.render(file);
87
+ probe.render(file);
88
+ probe.render(file);
89
+ expect(createObjectURL).toHaveBeenCalledTimes(1);
90
+ expect(revokeObjectURL).not.toHaveBeenCalled();
91
+ });
92
+ it('언마운트하면 만들었던 URL을 해제한다', () => {
93
+ const probe = renderProbe(makeFile('a.jpg'));
94
+ expect(revokeObjectURL).not.toHaveBeenCalled();
95
+ probe.unmount();
96
+ expect(revokeObjectURL).toHaveBeenCalledTimes(1);
97
+ expect(revokeObjectURL).toHaveBeenCalledWith('blob:mock/1');
98
+ });
99
+ it('파일이 바뀌면 이전 URL을 해제하고 새로 만든다', () => {
100
+ const probe = renderProbe(makeFile('a.jpg'));
101
+ probe.render(makeFile('b.jpg'));
102
+ expect(createObjectURL).toHaveBeenCalledTimes(2);
103
+ expect(revokeObjectURL).toHaveBeenCalledWith('blob:mock/1');
104
+ expect(probe.currentUrl()).toBe('blob:mock/2');
105
+ });
106
+ it('파일이 없으면 URL을 만들지 않는다', () => {
107
+ const probe = renderProbe(undefined);
108
+ expect(createObjectURL).not.toHaveBeenCalled();
109
+ expect(probe.currentUrl()).toBe('');
110
+ });
111
+ it('파일을 없애면 이전 URL을 해제한다', () => {
112
+ const probe = renderProbe(makeFile('a.jpg'));
113
+ probe.render(undefined);
114
+ expect(revokeObjectURL).toHaveBeenCalledWith('blob:mock/1');
115
+ expect(probe.currentUrl()).toBe('');
116
+ });
117
+ });
@@ -5,4 +5,5 @@ export * from './useCallbackRef';
5
5
  export * from './useFloatingPosition';
6
6
  export * from './useIsomorphicLayoutEffect';
7
7
  export * from './useMediaQuery';
8
- export * from './useMergeRefs';
8
+ export * from './useMergeRefs';
9
+ export * from './useObjectUrl';