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