@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.
- package/dist/cjs/src/components/forms-and-input/date-picker/DatePicker.js +174 -38
- package/dist/cjs/src/components/forms-and-input/date-picker/__tests__/DatePicker.test.js +410 -0
- 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/components/forms-and-input/range-date-picker/RangeDatePicker.js +9 -6
- package/dist/cjs/src/components/forms-and-input/range-date-picker/__tests__/RangeDatePicker.test.js +61 -0
- package/dist/cjs/src/components/forms-and-input/range-date-picker-with-buttons/__tests__/RangeDatePickerWithButtons.test.js +249 -0
- 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/cjs/vitest.config.js +23 -1
- package/dist/esm/src/components/forms-and-input/date-picker/DatePicker.js +174 -38
- package/dist/esm/src/components/forms-and-input/date-picker/__tests__/DatePicker.test.js +410 -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/components/forms-and-input/range-date-picker/RangeDatePicker.js +9 -6
- package/dist/esm/src/components/forms-and-input/range-date-picker/__tests__/RangeDatePicker.test.js +61 -0
- package/dist/esm/src/components/forms-and-input/range-date-picker-with-buttons/__tests__/RangeDatePickerWithButtons.test.js +250 -1
- 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/esm/vitest.config.js +22 -1
- package/dist/temp/src/components/forms-and-input/date-picker/DatePicker.js +184 -36
- package/dist/temp/src/components/forms-and-input/date-picker/__tests__/DatePicker.test.js +323 -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/components/forms-and-input/range-date-picker/RangeDatePicker.d.ts +9 -0
- package/dist/temp/src/components/forms-and-input/range-date-picker/RangeDatePicker.js +9 -6
- package/dist/temp/src/components/forms-and-input/range-date-picker/__tests__/RangeDatePicker.test.js +40 -0
- package/dist/temp/src/components/forms-and-input/range-date-picker-with-buttons/__tests__/RangeDatePickerWithButtons.test.js +190 -1
- 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/temp/vitest.config.js +24 -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/components/forms-and-input/range-date-picker/RangeDatePicker.d.ts +9 -0
- 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 +5 -3
|
@@ -123,4 +123,414 @@ describe('#3 onValidationError.previousDate', () => {
|
|
|
123
123
|
expect(arg.previousDate?.getDate()).toBe(LAST_VALID_DAY); // 직전 유효 날짜 (≠ 위반 날짜)
|
|
124
124
|
expect(arg.previousDate?.getTime()).not.toBe(arg.date.getTime());
|
|
125
125
|
});
|
|
126
|
+
});
|
|
127
|
+
describe('#5 blur 동기화 — flatpickr가 통지하지 않은 값 보정 (#410)', () => {
|
|
128
|
+
const getInstance = container => {
|
|
129
|
+
const input = container.querySelector('input');
|
|
130
|
+
expect(input._flatpickr).toBeTruthy();
|
|
131
|
+
return {
|
|
132
|
+
input,
|
|
133
|
+
instance: input._flatpickr
|
|
134
|
+
};
|
|
135
|
+
};
|
|
136
|
+
/** blur 동기화는 한 틱 뒤에 실행되므로 매크로태스크를 한 번 흘려보낸다 */
|
|
137
|
+
const flushBlurSync = async () => {
|
|
138
|
+
await act(async () => {
|
|
139
|
+
await new Promise(resolve => setTimeout(resolve, 0));
|
|
140
|
+
});
|
|
141
|
+
};
|
|
142
|
+
it('onChange 없이 값만 반영된 채 blur 되면 onChangeDate 로 통지한다', async () => {
|
|
143
|
+
const onChangeDate = vi.fn();
|
|
144
|
+
const container = mount({
|
|
145
|
+
currentDate: '2024-03-01',
|
|
146
|
+
onChangeDate,
|
|
147
|
+
datePickerOptions: {
|
|
148
|
+
allowInput: true
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
const {
|
|
152
|
+
input,
|
|
153
|
+
instance
|
|
154
|
+
} = getInstance(container);
|
|
155
|
+
// flatpickr documentClick 이 하는 것과 동일하게, onChange 를 발화시키지 않고 값만 반영한다
|
|
156
|
+
act(() => {
|
|
157
|
+
instance.setDate('2024-03-20', false);
|
|
158
|
+
});
|
|
159
|
+
onChangeDate.mockClear();
|
|
160
|
+
act(() => {
|
|
161
|
+
input.dispatchEvent(new Event('blur'));
|
|
162
|
+
});
|
|
163
|
+
await flushBlurSync();
|
|
164
|
+
expect(onChangeDate).toHaveBeenCalledWith('2024-03-20');
|
|
165
|
+
});
|
|
166
|
+
it('flatpickr가 이미 onChange 로 통지했으면 blur 가 중복 통지하지 않는다', async () => {
|
|
167
|
+
const onChangeDate = vi.fn();
|
|
168
|
+
const container = mount({
|
|
169
|
+
currentDate: '2024-03-01',
|
|
170
|
+
onChangeDate,
|
|
171
|
+
datePickerOptions: {
|
|
172
|
+
allowInput: true
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
const {
|
|
176
|
+
input,
|
|
177
|
+
instance
|
|
178
|
+
} = getInstance(container);
|
|
179
|
+
act(() => {
|
|
180
|
+
instance.setDate('2024-03-20', true);
|
|
181
|
+
});
|
|
182
|
+
expect(onChangeDate).toHaveBeenCalledTimes(1);
|
|
183
|
+
act(() => {
|
|
184
|
+
input.dispatchEvent(new Event('blur'));
|
|
185
|
+
});
|
|
186
|
+
await flushBlurSync();
|
|
187
|
+
expect(onChangeDate).toHaveBeenCalledTimes(1);
|
|
188
|
+
});
|
|
189
|
+
it('min/max 위반 값은 blur 로도 통지하지 않고 onValidationError 로 보고한다', async () => {
|
|
190
|
+
const onChangeDate = vi.fn();
|
|
191
|
+
const onValidationError = vi.fn();
|
|
192
|
+
const container = mount({
|
|
193
|
+
currentDate: '2024-03-15',
|
|
194
|
+
onChangeDate,
|
|
195
|
+
onValidationError,
|
|
196
|
+
datePickerOptions: {
|
|
197
|
+
minDate: '2024-03-10',
|
|
198
|
+
maxDate: '2024-03-25',
|
|
199
|
+
allowInput: true
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
const {
|
|
203
|
+
input,
|
|
204
|
+
instance
|
|
205
|
+
} = getInstance(container);
|
|
206
|
+
// 범위 밖 날짜가 onChange 없이 값만 반영된 상태 (마우스로 캘린더 밖 클릭 시 flatpickr 동작)
|
|
207
|
+
act(() => {
|
|
208
|
+
instance.setDate('2024-04-30', false);
|
|
209
|
+
});
|
|
210
|
+
onChangeDate.mockClear();
|
|
211
|
+
onValidationError.mockClear();
|
|
212
|
+
act(() => {
|
|
213
|
+
input.dispatchEvent(new Event('blur'));
|
|
214
|
+
});
|
|
215
|
+
await flushBlurSync();
|
|
216
|
+
expect(onChangeDate).not.toHaveBeenCalled();
|
|
217
|
+
expect(onValidationError).toHaveBeenCalledTimes(1);
|
|
218
|
+
expect(onValidationError.mock.calls[0][0].violations).toContain('maxDate');
|
|
219
|
+
});
|
|
220
|
+
it('enableTime 모드에서 값 변경 없이 blur 해도 통지하지 않는다', async () => {
|
|
221
|
+
const onChangeDate = vi.fn();
|
|
222
|
+
const container = mount({
|
|
223
|
+
currentDate: '2024-03-20',
|
|
224
|
+
onChangeDate,
|
|
225
|
+
datePickerOptions: {
|
|
226
|
+
enableTime: true,
|
|
227
|
+
dateFormat: 'Y-m-d H:i',
|
|
228
|
+
allowInput: true
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
const {
|
|
232
|
+
input
|
|
233
|
+
} = getInstance(container);
|
|
234
|
+
onChangeDate.mockClear();
|
|
235
|
+
act(() => {
|
|
236
|
+
input.dispatchEvent(new Event('blur'));
|
|
237
|
+
});
|
|
238
|
+
await flushBlurSync();
|
|
239
|
+
expect(onChangeDate).not.toHaveBeenCalled();
|
|
240
|
+
});
|
|
241
|
+
it('noCalendar(시간 전용) 모드에서 값 변경 없이 blur 해도 통지하지 않는다', async () => {
|
|
242
|
+
const onChangeDate = vi.fn();
|
|
243
|
+
const container = mount({
|
|
244
|
+
currentDate: '',
|
|
245
|
+
onChangeDate,
|
|
246
|
+
datePickerOptions: {
|
|
247
|
+
enableTime: true,
|
|
248
|
+
noCalendar: true,
|
|
249
|
+
dateFormat: 'H:i',
|
|
250
|
+
allowInput: true
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
const {
|
|
254
|
+
input
|
|
255
|
+
} = getInstance(container);
|
|
256
|
+
onChangeDate.mockClear();
|
|
257
|
+
act(() => {
|
|
258
|
+
input.dispatchEvent(new Event('blur'));
|
|
259
|
+
});
|
|
260
|
+
await flushBlurSync();
|
|
261
|
+
expect(onChangeDate).not.toHaveBeenCalled();
|
|
262
|
+
});
|
|
263
|
+
it('noCalendar(시간 전용) 모드에서 통지 없이 시간만 바뀐 채 blur 되면 통지한다', async () => {
|
|
264
|
+
const onChangeDate = vi.fn();
|
|
265
|
+
const container = mount({
|
|
266
|
+
currentDate: '',
|
|
267
|
+
onChangeDate,
|
|
268
|
+
datePickerOptions: {
|
|
269
|
+
enableTime: true,
|
|
270
|
+
noCalendar: true,
|
|
271
|
+
dateFormat: 'H:i',
|
|
272
|
+
allowInput: true
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
const {
|
|
276
|
+
input,
|
|
277
|
+
instance
|
|
278
|
+
} = getInstance(container);
|
|
279
|
+
act(() => {
|
|
280
|
+
instance.setDate('14:30', false);
|
|
281
|
+
});
|
|
282
|
+
onChangeDate.mockClear();
|
|
283
|
+
act(() => {
|
|
284
|
+
input.dispatchEvent(new Event('blur'));
|
|
285
|
+
});
|
|
286
|
+
await flushBlurSync();
|
|
287
|
+
expect(onChangeDate).toHaveBeenCalledWith('14:30');
|
|
288
|
+
});
|
|
289
|
+
it('portal 모드에서도 통지 없이 값만 반영된 채 blur 되면 통지한다', async () => {
|
|
290
|
+
const onChangeDate = vi.fn();
|
|
291
|
+
const container = mount({
|
|
292
|
+
currentDate: '2024-03-01',
|
|
293
|
+
onChangeDate,
|
|
294
|
+
portal: true,
|
|
295
|
+
datePickerOptions: {
|
|
296
|
+
allowInput: true
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
const {
|
|
300
|
+
input,
|
|
301
|
+
instance
|
|
302
|
+
} = getInstance(container);
|
|
303
|
+
act(() => {
|
|
304
|
+
instance.setDate('2024-03-20', false);
|
|
305
|
+
});
|
|
306
|
+
onChangeDate.mockClear();
|
|
307
|
+
act(() => {
|
|
308
|
+
input.dispatchEvent(new Event('blur'));
|
|
309
|
+
});
|
|
310
|
+
await flushBlurSync();
|
|
311
|
+
expect(onChangeDate).toHaveBeenCalledWith('2024-03-20');
|
|
312
|
+
});
|
|
313
|
+
it('값이 바뀌지 않았으면 blur 만으로는 통지하지 않는다', async () => {
|
|
314
|
+
const onChangeDate = vi.fn();
|
|
315
|
+
const container = mount({
|
|
316
|
+
currentDate: '2024-03-01',
|
|
317
|
+
onChangeDate,
|
|
318
|
+
datePickerOptions: {
|
|
319
|
+
allowInput: true
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
const {
|
|
323
|
+
input
|
|
324
|
+
} = getInstance(container);
|
|
325
|
+
onChangeDate.mockClear();
|
|
326
|
+
act(() => {
|
|
327
|
+
input.dispatchEvent(new Event('blur'));
|
|
328
|
+
});
|
|
329
|
+
await flushBlurSync();
|
|
330
|
+
expect(onChangeDate).not.toHaveBeenCalled();
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
describe('#7 타이핑 즉시 통지 — blur 를 기다리지 않는다 (#410)', () => {
|
|
334
|
+
const getInput = container => container.querySelector('input.flatpickr-input');
|
|
335
|
+
/** 사용자가 한 글자씩 타이핑하는 것과 동일하게 input 이벤트를 발생시킨다 */
|
|
336
|
+
const typeInto = (input, text) => {
|
|
337
|
+
input.value = '';
|
|
338
|
+
for (const char of text) {
|
|
339
|
+
act(() => {
|
|
340
|
+
input.value = `${input.value}${char}`;
|
|
341
|
+
input.dispatchEvent(new Event('input', {
|
|
342
|
+
bubbles: true
|
|
343
|
+
}));
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
};
|
|
347
|
+
const flushBlurSync = async () => {
|
|
348
|
+
await act(async () => {
|
|
349
|
+
await new Promise(resolve => setTimeout(resolve, 0));
|
|
350
|
+
});
|
|
351
|
+
};
|
|
352
|
+
it('완성된 유효 날짜를 타이핑하면 blur 전에 통지한다', () => {
|
|
353
|
+
const onChangeDate = vi.fn();
|
|
354
|
+
const container = mount({
|
|
355
|
+
currentDate: '2024-03-01',
|
|
356
|
+
onChangeDate,
|
|
357
|
+
datePickerOptions: {
|
|
358
|
+
allowInput: true
|
|
359
|
+
}
|
|
360
|
+
});
|
|
361
|
+
typeInto(getInput(container), '2024-03-20');
|
|
362
|
+
expect(onChangeDate).toHaveBeenCalledWith('2024-03-20');
|
|
363
|
+
});
|
|
364
|
+
it('구분자 없이 8자리 숫자만 타이핑해도 통지한다', () => {
|
|
365
|
+
const onChangeDate = vi.fn();
|
|
366
|
+
const container = mount({
|
|
367
|
+
currentDate: '2024-03-01',
|
|
368
|
+
onChangeDate,
|
|
369
|
+
datePickerOptions: {
|
|
370
|
+
allowInput: true
|
|
371
|
+
}
|
|
372
|
+
});
|
|
373
|
+
// 8자리를 다 치는 순간 핸들러가 값을 '2024-03-20' 으로 다시 쓴다.
|
|
374
|
+
// 그 뒤로는 브라우저가 input 이벤트를 더 주지 않으므로 이 시점에 통지해야 한다.
|
|
375
|
+
typeInto(getInput(container), '20240320');
|
|
376
|
+
expect(onChangeDate).toHaveBeenCalledWith('2024-03-20');
|
|
377
|
+
});
|
|
378
|
+
it('아직 완성되지 않은 입력은 통지하지 않는다', () => {
|
|
379
|
+
const onChangeDate = vi.fn();
|
|
380
|
+
const container = mount({
|
|
381
|
+
currentDate: '2024-03-01',
|
|
382
|
+
onChangeDate,
|
|
383
|
+
datePickerOptions: {
|
|
384
|
+
allowInput: true
|
|
385
|
+
}
|
|
386
|
+
});
|
|
387
|
+
typeInto(getInput(container), '2024-03-');
|
|
388
|
+
expect(onChangeDate).not.toHaveBeenCalled();
|
|
389
|
+
});
|
|
390
|
+
it('타이핑으로 이미 통지했으면 blur 가 중복 통지하지 않는다', async () => {
|
|
391
|
+
const onChangeDate = vi.fn();
|
|
392
|
+
const container = mount({
|
|
393
|
+
currentDate: '2024-03-01',
|
|
394
|
+
onChangeDate,
|
|
395
|
+
datePickerOptions: {
|
|
396
|
+
allowInput: true
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
const input = getInput(container);
|
|
400
|
+
typeInto(input, '2024-03-20');
|
|
401
|
+
expect(onChangeDate).toHaveBeenCalledTimes(1);
|
|
402
|
+
act(() => {
|
|
403
|
+
input.dispatchEvent(new Event('blur'));
|
|
404
|
+
});
|
|
405
|
+
await flushBlurSync();
|
|
406
|
+
expect(onChangeDate).toHaveBeenCalledTimes(1);
|
|
407
|
+
});
|
|
408
|
+
it('min/max 범위 밖 날짜는 타이핑 중에 통지하지 않는다 (blur 시점에 한 번만 보고)', () => {
|
|
409
|
+
const onChangeDate = vi.fn();
|
|
410
|
+
const onValidationError = vi.fn();
|
|
411
|
+
const container = mount({
|
|
412
|
+
currentDate: '2024-03-15',
|
|
413
|
+
onChangeDate,
|
|
414
|
+
onValidationError,
|
|
415
|
+
datePickerOptions: {
|
|
416
|
+
minDate: '2024-03-10',
|
|
417
|
+
maxDate: '2024-03-25',
|
|
418
|
+
allowInput: true
|
|
419
|
+
}
|
|
420
|
+
});
|
|
421
|
+
typeInto(getInput(container), '2024-04-30');
|
|
422
|
+
expect(onChangeDate).not.toHaveBeenCalled();
|
|
423
|
+
expect(onValidationError).not.toHaveBeenCalled();
|
|
424
|
+
});
|
|
425
|
+
it('같은 값은 경로가 달라도 두 번 통지하지 않는다', () => {
|
|
426
|
+
const onChangeDate = vi.fn();
|
|
427
|
+
const container = mount({
|
|
428
|
+
currentDate: '2024-03-01',
|
|
429
|
+
onChangeDate,
|
|
430
|
+
datePickerOptions: {
|
|
431
|
+
allowInput: true
|
|
432
|
+
}
|
|
433
|
+
});
|
|
434
|
+
const input = getInput(container);
|
|
435
|
+
// 타이핑으로 통지한 뒤, 캘린더가 같은 날짜로 onChange 를 다시 발화시키는 상황
|
|
436
|
+
typeInto(input, '2024-03-20');
|
|
437
|
+
act(() => {
|
|
438
|
+
input._flatpickr.setDate('2024-03-20', true);
|
|
439
|
+
});
|
|
440
|
+
expect(onChangeDate).toHaveBeenCalledTimes(1);
|
|
441
|
+
});
|
|
442
|
+
it('enableTime 모드에서 날짜만 입력한 상태는 타이핑 중에 통지하지 않는다', () => {
|
|
443
|
+
const onChangeDate = vi.fn();
|
|
444
|
+
const container = mount({
|
|
445
|
+
currentDate: '2024-03-01 00:00',
|
|
446
|
+
onChangeDate,
|
|
447
|
+
datePickerOptions: {
|
|
448
|
+
enableTime: true,
|
|
449
|
+
dateFormat: 'Y-m-d H:i',
|
|
450
|
+
allowInput: true
|
|
451
|
+
}
|
|
452
|
+
});
|
|
453
|
+
// '2024-03-20' 은 최종 표기('2024-03-20 00:00')와 다르다.
|
|
454
|
+
// 여기서 통지하면 setDate 가 input 을 다시 써서 타이핑 중 커서가 튄다.
|
|
455
|
+
typeInto(getInput(container), '2024-03-20');
|
|
456
|
+
expect(onChangeDate).not.toHaveBeenCalled();
|
|
457
|
+
});
|
|
458
|
+
});
|
|
459
|
+
describe('#8 완성 판정·파싱을 dateFormat 기준으로 한다', () => {
|
|
460
|
+
const getInput = container => container.querySelector('input.flatpickr-input');
|
|
461
|
+
const typeInto = (input, text) => {
|
|
462
|
+
input.value = '';
|
|
463
|
+
for (const char of text) {
|
|
464
|
+
act(() => {
|
|
465
|
+
input.value = `${input.value}${char}`;
|
|
466
|
+
input.dispatchEvent(new Event('input', {
|
|
467
|
+
bubbles: true
|
|
468
|
+
}));
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
};
|
|
472
|
+
it('enableTime 에서 한 글자씩 쳐도 입력이 유지되고 통지된다', () => {
|
|
473
|
+
const onChangeDate = vi.fn();
|
|
474
|
+
const container = mount({
|
|
475
|
+
currentDate: '2026-03-20 23:59',
|
|
476
|
+
onChangeDate,
|
|
477
|
+
datePickerOptions: {
|
|
478
|
+
allowInput: true,
|
|
479
|
+
enableTime: true,
|
|
480
|
+
dateFormat: 'Y-m-d H:i'
|
|
481
|
+
}
|
|
482
|
+
});
|
|
483
|
+
const input = getInput(container);
|
|
484
|
+
// 완성 길이를 10 으로 고정하면 '2026-03-01 0' 을 완성으로 오판해
|
|
485
|
+
// moment 검증에 걸리고, 입력이 이전 값('2026-03-20 23:59')으로 되돌아갔다
|
|
486
|
+
typeInto(input, '2026-03-01 00:00');
|
|
487
|
+
expect(input.value).toBe('2026-03-01 00:00');
|
|
488
|
+
expect(onChangeDate).toHaveBeenCalledWith('2026-03-01 00:00');
|
|
489
|
+
});
|
|
490
|
+
it('enableTime 에서 시간을 아직 안 친 중간 상태는 통지하지 않는다', () => {
|
|
491
|
+
const onChangeDate = vi.fn();
|
|
492
|
+
const container = mount({
|
|
493
|
+
currentDate: '2026-03-20 23:59',
|
|
494
|
+
onChangeDate,
|
|
495
|
+
datePickerOptions: {
|
|
496
|
+
allowInput: true,
|
|
497
|
+
enableTime: true,
|
|
498
|
+
dateFormat: 'Y-m-d H:i'
|
|
499
|
+
}
|
|
500
|
+
});
|
|
501
|
+
typeInto(getInput(container), '2026-03-01 00');
|
|
502
|
+
expect(onChangeDate).not.toHaveBeenCalled();
|
|
503
|
+
});
|
|
504
|
+
it('시간 전용(noCalendar) 모드에서 타이핑한 값이 되돌려지지 않는다', () => {
|
|
505
|
+
const onChangeDate = vi.fn();
|
|
506
|
+
const container = mount({
|
|
507
|
+
currentDate: '23:59',
|
|
508
|
+
onChangeDate,
|
|
509
|
+
datePickerOptions: {
|
|
510
|
+
allowInput: true,
|
|
511
|
+
enableTime: true,
|
|
512
|
+
noCalendar: true,
|
|
513
|
+
dateFormat: 'H:i'
|
|
514
|
+
}
|
|
515
|
+
});
|
|
516
|
+
const input = getInput(container);
|
|
517
|
+
// 포맷 없이 moment('14:30') 으로 파싱하면 invalid 라 이전 값으로 복원됐다
|
|
518
|
+
typeInto(input, '14:30');
|
|
519
|
+
expect(input.value).toBe('14:30');
|
|
520
|
+
expect(onChangeDate).toHaveBeenCalledWith('14:30');
|
|
521
|
+
});
|
|
522
|
+
it('완성 길이에 도달했지만 실재하지 않는 날짜는 이전 값으로 복원한다', () => {
|
|
523
|
+
const onChangeDate = vi.fn();
|
|
524
|
+
const container = mount({
|
|
525
|
+
currentDate: '2026-03-20',
|
|
526
|
+
onChangeDate,
|
|
527
|
+
datePickerOptions: {
|
|
528
|
+
allowInput: true
|
|
529
|
+
}
|
|
530
|
+
});
|
|
531
|
+
const input = getInput(container);
|
|
532
|
+
typeInto(input, '2026-13-45');
|
|
533
|
+
expect(input.value).not.toBe('2026-13-45');
|
|
534
|
+
expect(onChangeDate).not.toHaveBeenCalledWith('2026-13-45');
|
|
535
|
+
});
|
|
126
536
|
});
|
|
@@ -2,16 +2,35 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import { HelpCircle, Upload01 } from '@ncds/ui-admin-icon';
|
|
3
3
|
import classNames from 'classnames';
|
|
4
4
|
import { forwardRef, useEffect, useRef, useState } from 'react';
|
|
5
|
+
import { useObjectUrl } from '../../../hooks/useObjectUrl';
|
|
5
6
|
import { Button } from '../../action/button';
|
|
6
7
|
import { Tag } from '../../feedback-and-status/tag/Tag';
|
|
7
8
|
import { HintText, Label } from '../../shared';
|
|
9
|
+
/**
|
|
10
|
+
* Thumbnail next to a file tag.
|
|
11
|
+
*
|
|
12
|
+
* Split out so the object URL can live in a hook — hooks cannot be called
|
|
13
|
+
* from inside the file list's map callback.
|
|
14
|
+
*/
|
|
15
|
+
const FileTagImage = _ref2 => {
|
|
16
|
+
let {
|
|
17
|
+
file
|
|
18
|
+
} = _ref2;
|
|
19
|
+
const objectUrl = useObjectUrl(file);
|
|
20
|
+
if (!objectUrl) return null;
|
|
21
|
+
return _jsx("img", {
|
|
22
|
+
className: "ncua-file-input__file-image",
|
|
23
|
+
src: objectUrl,
|
|
24
|
+
alt: file.name
|
|
25
|
+
});
|
|
26
|
+
};
|
|
8
27
|
export var FileInputErrorType;
|
|
9
28
|
(function (FileInputErrorType) {
|
|
10
29
|
FileInputErrorType["ALREADY_UPLOADED"] = "ALREADY_UPLOADED";
|
|
11
30
|
FileInputErrorType["EXCEED_MAX_FILE_SIZE"] = "EXCEED_MAX_FILE_SIZE";
|
|
12
31
|
FileInputErrorType["EXCEED_MAX_FILE_COUNT"] = "EXCEED_MAX_FILE_COUNT";
|
|
13
32
|
})(FileInputErrorType || (FileInputErrorType = {}));
|
|
14
|
-
export const FileInput = /*#__PURE__*/forwardRef((
|
|
33
|
+
export const FileInput = /*#__PURE__*/forwardRef((_ref3, _ref) => {
|
|
15
34
|
let {
|
|
16
35
|
size = 'xs',
|
|
17
36
|
accept,
|
|
@@ -31,8 +50,9 @@ export const FileInput = /*#__PURE__*/forwardRef((_ref2, _ref) => {
|
|
|
31
50
|
isRequired,
|
|
32
51
|
showHelpIcon,
|
|
33
52
|
hintText,
|
|
53
|
+
showFileTags = true,
|
|
34
54
|
...props
|
|
35
|
-
} =
|
|
55
|
+
} = _ref3;
|
|
36
56
|
const fileInputRef = useRef(null);
|
|
37
57
|
const [internalFiles, setInternalFiles] = useState([]);
|
|
38
58
|
// Determine if component is controlled or uncontrolled
|
|
@@ -108,7 +128,7 @@ export const FileInput = /*#__PURE__*/forwardRef((_ref2, _ref) => {
|
|
|
108
128
|
updateFiles(newFiles);
|
|
109
129
|
};
|
|
110
130
|
const renderFileTagList = () => {
|
|
111
|
-
if (files.length === 0) return null;
|
|
131
|
+
if (!showFileTags || files.length === 0) return null;
|
|
112
132
|
return _jsx("div", {
|
|
113
133
|
className: "ncua-file-input__file-tags",
|
|
114
134
|
children: files.map((file, index) => _jsxs("div", {
|
|
@@ -118,10 +138,8 @@ export const FileInput = /*#__PURE__*/forwardRef((_ref2, _ref) => {
|
|
|
118
138
|
size: size === 'xs' ? 'sm' : 'md',
|
|
119
139
|
close: true,
|
|
120
140
|
onButtonClick: () => handleRemoveFile(index)
|
|
121
|
-
}), file.type.startsWith('image/') && _jsx(
|
|
122
|
-
|
|
123
|
-
src: URL.createObjectURL(file),
|
|
124
|
-
alt: file.name
|
|
141
|
+
}), file.type.startsWith('image/') && _jsx(FileTagImage, {
|
|
142
|
+
file: file
|
|
125
143
|
})]
|
|
126
144
|
}, `${file.name}-${index}`))
|
|
127
145
|
});
|
|
@@ -0,0 +1,165 @@
|
|
|
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 { FileInput } from '../FileInput';
|
|
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
|
+
afterEach(() => {
|
|
19
|
+
URL.createObjectURL = originalCreateObjectURL;
|
|
20
|
+
URL.revokeObjectURL = originalRevokeObjectURL;
|
|
21
|
+
});
|
|
22
|
+
/** 내용은 검증 대상이 아니므로 최소 크기만 채운다 */
|
|
23
|
+
const DUMMY_FILE_BYTES = 4;
|
|
24
|
+
const makeFile = (name, type) => new File([new Uint8Array(DUMMY_FILE_BYTES)], name, {
|
|
25
|
+
type
|
|
26
|
+
});
|
|
27
|
+
function mountFileInput(files) {
|
|
28
|
+
let extraProps = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
|
|
29
|
+
const container = document.createElement('div');
|
|
30
|
+
document.body.appendChild(container);
|
|
31
|
+
const root = createRoot(container);
|
|
32
|
+
const render = () => {
|
|
33
|
+
act(() => {
|
|
34
|
+
root.render(/*#__PURE__*/createElement(FileInput, {
|
|
35
|
+
value: files,
|
|
36
|
+
onChange: () => undefined,
|
|
37
|
+
...extraProps
|
|
38
|
+
}));
|
|
39
|
+
});
|
|
40
|
+
};
|
|
41
|
+
render();
|
|
42
|
+
const unmount = () => {
|
|
43
|
+
act(() => {
|
|
44
|
+
root.unmount();
|
|
45
|
+
});
|
|
46
|
+
container.remove();
|
|
47
|
+
};
|
|
48
|
+
return {
|
|
49
|
+
container,
|
|
50
|
+
render,
|
|
51
|
+
unmount
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
describe('FileInput — 이미지 썸네일 object URL 해제', () => {
|
|
55
|
+
it('이미지 파일의 썸네일을 렌더한다', () => {
|
|
56
|
+
const view = mountFileInput([makeFile('a.jpg', 'image/jpeg')]);
|
|
57
|
+
const img = view.container.querySelector('.ncua-file-input__file-image');
|
|
58
|
+
expect(img).not.toBeNull();
|
|
59
|
+
expect(img?.getAttribute('src')).toBe('blob:mock/1');
|
|
60
|
+
expect(img?.getAttribute('alt')).toBe('a.jpg');
|
|
61
|
+
view.unmount();
|
|
62
|
+
});
|
|
63
|
+
it('리렌더를 반복해도 파일당 object URL을 한 번만 만든다', () => {
|
|
64
|
+
const view = mountFileInput([makeFile('a.jpg', 'image/jpeg')]);
|
|
65
|
+
view.render();
|
|
66
|
+
view.render();
|
|
67
|
+
view.render();
|
|
68
|
+
expect(createObjectURL).toHaveBeenCalledTimes(1);
|
|
69
|
+
view.unmount();
|
|
70
|
+
});
|
|
71
|
+
it('언마운트하면 만들었던 object URL을 해제한다', () => {
|
|
72
|
+
const view = mountFileInput([makeFile('a.jpg', 'image/jpeg')]);
|
|
73
|
+
expect(revokeObjectURL).not.toHaveBeenCalled();
|
|
74
|
+
view.unmount();
|
|
75
|
+
expect(revokeObjectURL).toHaveBeenCalledTimes(1);
|
|
76
|
+
expect(revokeObjectURL).toHaveBeenCalledWith('blob:mock/1');
|
|
77
|
+
});
|
|
78
|
+
it('이미지가 아닌 파일에는 object URL을 만들지 않는다', () => {
|
|
79
|
+
const view = mountFileInput([makeFile('doc.pdf', 'application/pdf')]);
|
|
80
|
+
expect(createObjectURL).not.toHaveBeenCalled();
|
|
81
|
+
expect(view.container.querySelector('.ncua-file-input__file-image')).toBeNull();
|
|
82
|
+
view.unmount();
|
|
83
|
+
});
|
|
84
|
+
it('이미지가 여러 개면 각각 하나씩만 만들고 언마운트 시 모두 해제한다', () => {
|
|
85
|
+
const view = mountFileInput([makeFile('a.jpg', 'image/jpeg'), makeFile('b.png', 'image/png')]);
|
|
86
|
+
expect(createObjectURL).toHaveBeenCalledTimes(2);
|
|
87
|
+
view.unmount();
|
|
88
|
+
expect(revokeObjectURL).toHaveBeenCalledTimes(2);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
/** 숨겨진 file input 에 파일을 넣고 change 를 발생시킨다 */
|
|
92
|
+
function selectFiles(container, files) {
|
|
93
|
+
const input = container.querySelector('input[type="file"]');
|
|
94
|
+
if (!input) throw new Error('file input을 찾을 수 없다');
|
|
95
|
+
Object.defineProperty(input, 'files', {
|
|
96
|
+
value: files,
|
|
97
|
+
configurable: true
|
|
98
|
+
});
|
|
99
|
+
act(() => {
|
|
100
|
+
input.dispatchEvent(new Event('change', {
|
|
101
|
+
bubbles: true
|
|
102
|
+
}));
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
describe('FileInput — showFileTags', () => {
|
|
106
|
+
it('기본값에서는 파일 태그 목록을 렌더한다', () => {
|
|
107
|
+
const view = mountFileInput([makeFile('doc.pdf', 'application/pdf')]);
|
|
108
|
+
expect(view.container.querySelector('.ncua-file-input__file-tags')).not.toBeNull();
|
|
109
|
+
expect(view.container.querySelector('.ncua-tag')).not.toBeNull();
|
|
110
|
+
view.unmount();
|
|
111
|
+
});
|
|
112
|
+
it('false면 파일 태그 목록을 렌더하지 않는다', () => {
|
|
113
|
+
const view = mountFileInput([makeFile('doc.pdf', 'application/pdf')], {
|
|
114
|
+
showFileTags: false
|
|
115
|
+
});
|
|
116
|
+
expect(view.container.querySelector('.ncua-file-input__file-tags')).toBeNull();
|
|
117
|
+
expect(view.container.querySelector('.ncua-tag')).toBeNull();
|
|
118
|
+
view.unmount();
|
|
119
|
+
});
|
|
120
|
+
it('false여도 파일 찾기 버튼과 안내 문구는 그대로 렌더한다', () => {
|
|
121
|
+
const view = mountFileInput([makeFile('doc.pdf', 'application/pdf')], {
|
|
122
|
+
showFileTags: false,
|
|
123
|
+
hintItems: ['파일은 5MB 이내입니다.']
|
|
124
|
+
});
|
|
125
|
+
expect(view.container.querySelector('.ncua-btn')).not.toBeNull();
|
|
126
|
+
expect(view.container.querySelector('.ncua-file-input__hint-list')?.textContent).toContain('5MB');
|
|
127
|
+
view.unmount();
|
|
128
|
+
});
|
|
129
|
+
it('false면 이미지 썸네일 object URL을 만들지 않는다', () => {
|
|
130
|
+
const view = mountFileInput([makeFile('a.jpg', 'image/jpeg')], {
|
|
131
|
+
showFileTags: false
|
|
132
|
+
});
|
|
133
|
+
expect(createObjectURL).not.toHaveBeenCalled();
|
|
134
|
+
expect(view.container.querySelector('.ncua-file-input__file-image')).toBeNull();
|
|
135
|
+
view.unmount();
|
|
136
|
+
});
|
|
137
|
+
it('false여도 선택한 파일을 onChange로 전달한다', () => {
|
|
138
|
+
const onChange = vi.fn();
|
|
139
|
+
const view = mountFileInput([], {
|
|
140
|
+
showFileTags: false,
|
|
141
|
+
onChange
|
|
142
|
+
});
|
|
143
|
+
const picked = makeFile('a.pdf', 'application/pdf');
|
|
144
|
+
selectFiles(view.container, [picked]);
|
|
145
|
+
expect(onChange).toHaveBeenCalledTimes(1);
|
|
146
|
+
expect(onChange).toHaveBeenCalledWith([picked]);
|
|
147
|
+
view.unmount();
|
|
148
|
+
});
|
|
149
|
+
it('false여도 이미 등록된 파일은 중복으로 걸러 onFail로 알린다', () => {
|
|
150
|
+
const already = makeFile('a.pdf', 'application/pdf');
|
|
151
|
+
const onChange = vi.fn();
|
|
152
|
+
const onFail = vi.fn();
|
|
153
|
+
const view = mountFileInput([already], {
|
|
154
|
+
showFileTags: false,
|
|
155
|
+
onChange,
|
|
156
|
+
onFail
|
|
157
|
+
});
|
|
158
|
+
selectFiles(view.container, [makeFile('a.pdf', 'application/pdf')]);
|
|
159
|
+
expect(onChange).toHaveBeenCalledWith([already]);
|
|
160
|
+
expect(onFail).toHaveBeenCalledTimes(1);
|
|
161
|
+
expect(onFail.mock.calls[0][0]).toHaveLength(1);
|
|
162
|
+
expect(onFail.mock.calls[0][0][0].errorType).toBe('ALREADY_UPLOADED');
|
|
163
|
+
view.unmount();
|
|
164
|
+
});
|
|
165
|
+
});
|