@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
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// @vitest-environment jsdom
|
|
2
|
-
import { createElement } from 'react';
|
|
2
|
+
import { createElement, useState } from 'react';
|
|
3
3
|
import { createRoot } from 'react-dom/client';
|
|
4
4
|
import { act } from 'react-dom/test-utils';
|
|
5
5
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
@@ -40,3 +40,192 @@ describe('#6 periodItems 에 없는 periodKey 방어', () => {
|
|
|
40
40
|
expect(container.textContent).toContain('오늘');
|
|
41
41
|
});
|
|
42
42
|
});
|
|
43
|
+
describe('#9 allowInput 타이핑 → onDateValidation (#410)', () => {
|
|
44
|
+
const START_DATE = '2026-03-10';
|
|
45
|
+
const END_DATE = '2026-03-20';
|
|
46
|
+
/** 시작일보다 이전 — 종료일에 넣으면 overlap */
|
|
47
|
+
const TYPED_END_DATE = '2026-03-01';
|
|
48
|
+
/** 실제 사용처처럼 부모가 state 를 들고 controlled 로 쓰는 래퍼.
|
|
49
|
+
* bump() 로 날짜와 무관한 리렌더를 임의 시점에 끼워 넣을 수 있다. */
|
|
50
|
+
function mountHarness(onDateValidation) {
|
|
51
|
+
let bump;
|
|
52
|
+
const Harness = () => {
|
|
53
|
+
const [start, setStart] = useState(START_DATE);
|
|
54
|
+
const [end, setEnd] = useState(END_DATE);
|
|
55
|
+
const [, setTick] = useState(0);
|
|
56
|
+
const [buttonId, setButtonId] = useState('NONE');
|
|
57
|
+
bump = () => setTick((tick) => tick + 1);
|
|
58
|
+
return createElement(RangeDatePickerWithButtons, {
|
|
59
|
+
currentButtonId: buttonId,
|
|
60
|
+
setCurrentButtonId: setButtonId,
|
|
61
|
+
periodKeys: ['ENTIRE', 'TODAY'],
|
|
62
|
+
startDateOptions: { currentDate: start, onChangeDate: setStart, datePickerOptions: { allowInput: true } },
|
|
63
|
+
endDateOptions: { currentDate: end, onChangeDate: setEnd, datePickerOptions: { allowInput: true } },
|
|
64
|
+
onDateValidation,
|
|
65
|
+
});
|
|
66
|
+
};
|
|
67
|
+
const container = document.createElement('div');
|
|
68
|
+
document.body.appendChild(container);
|
|
69
|
+
const root = createRoot(container);
|
|
70
|
+
act(() => {
|
|
71
|
+
root.render(createElement(Harness));
|
|
72
|
+
});
|
|
73
|
+
mounted.push({ root, container });
|
|
74
|
+
const inputs = Array.from(container.querySelectorAll('input.flatpickr-input'));
|
|
75
|
+
return { endInput: inputs[1], bump: () => bump?.() };
|
|
76
|
+
}
|
|
77
|
+
const typeChar = (input, char) => {
|
|
78
|
+
act(() => {
|
|
79
|
+
input.value = `${input.value}${char}`;
|
|
80
|
+
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
81
|
+
});
|
|
82
|
+
};
|
|
83
|
+
it('종료일에 시작일 이전 날짜를 타이핑하면 overlap 을 통지한다', () => {
|
|
84
|
+
const onDateValidation = vi.fn();
|
|
85
|
+
const { endInput } = mountHarness(onDateValidation);
|
|
86
|
+
onDateValidation.mockClear();
|
|
87
|
+
endInput.value = '';
|
|
88
|
+
for (const char of TYPED_END_DATE) {
|
|
89
|
+
typeChar(endInput, char);
|
|
90
|
+
}
|
|
91
|
+
expect(onDateValidation).toHaveBeenCalledWith({
|
|
92
|
+
type: 'end',
|
|
93
|
+
errorType: 'overlap',
|
|
94
|
+
newDate: START_DATE,
|
|
95
|
+
currentDate: TYPED_END_DATE,
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
it('blur 없이 통지하므로, 통지 이후 부모가 리렌더돼도 결과가 뒤집히지 않는다', () => {
|
|
99
|
+
const onDateValidation = vi.fn();
|
|
100
|
+
const { endInput, bump } = mountHarness(onDateValidation);
|
|
101
|
+
onDateValidation.mockClear();
|
|
102
|
+
endInput.value = '';
|
|
103
|
+
for (const char of TYPED_END_DATE) {
|
|
104
|
+
typeChar(endInput, char);
|
|
105
|
+
}
|
|
106
|
+
onDateValidation.mockClear();
|
|
107
|
+
// 날짜와 무관한 리렌더가 뒤따라도 통지된 값이 유지된다
|
|
108
|
+
act(() => {
|
|
109
|
+
bump();
|
|
110
|
+
});
|
|
111
|
+
expect(endInput.value).toBe(TYPED_END_DATE);
|
|
112
|
+
});
|
|
113
|
+
/**
|
|
114
|
+
* 알려진 한계: 날짜를 다 치기 전에 리렌더가 끼면 react-flatpickr 가 input 을 부모 값으로 덮는다.
|
|
115
|
+
* (static:true 라 wrapper 를 헐고 다시 만들면서 input DOM 이 이동해 포커스까지 빠진다.)
|
|
116
|
+
* 통지 자체는 blur 를 안 기다리므로 위 케이스와 무관하지만, 이 구간은 여전히 취약하다.
|
|
117
|
+
*/
|
|
118
|
+
it('입력이 완성되기 전 리렌더가 끼면 입력값이 부모 값으로 되돌아간다 (알려진 한계)', () => {
|
|
119
|
+
const onDateValidation = vi.fn();
|
|
120
|
+
const { endInput, bump } = mountHarness(onDateValidation);
|
|
121
|
+
endInput.value = '';
|
|
122
|
+
for (const char of TYPED_END_DATE.slice(0, -1)) {
|
|
123
|
+
typeChar(endInput, char);
|
|
124
|
+
}
|
|
125
|
+
act(() => {
|
|
126
|
+
bump();
|
|
127
|
+
});
|
|
128
|
+
expect(endInput.value).toBe(END_DATE);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
describe('#10 같은 날짜를 다시 입력해도 통지된다 (#410 재보고)', () => {
|
|
132
|
+
const START_DATE = '2026-08-10';
|
|
133
|
+
const END_DATE = '2026-08-20';
|
|
134
|
+
/** 시작일 이전 — overlap */
|
|
135
|
+
const TYPED_END_DATE = '2026-08-06';
|
|
136
|
+
const typeChars = (input, text) => {
|
|
137
|
+
input.value = '';
|
|
138
|
+
for (const char of text) {
|
|
139
|
+
act(() => {
|
|
140
|
+
input.value = `${input.value}${char}`;
|
|
141
|
+
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
it('overlap 통지를 받고 종료일을 되돌린 뒤 같은 날짜를 다시 쳐도 매번 통지한다', () => {
|
|
146
|
+
const onDateValidation = vi.fn();
|
|
147
|
+
// 실제 사용처 패턴: overlap 을 받으면 알럿 대신 종료일을 원래대로 되돌린다
|
|
148
|
+
const Harness = () => {
|
|
149
|
+
const [start, setStart] = useState(START_DATE);
|
|
150
|
+
const [end, setEnd] = useState(END_DATE);
|
|
151
|
+
const [buttonId, setButtonId] = useState('NONE');
|
|
152
|
+
return createElement(RangeDatePickerWithButtons, {
|
|
153
|
+
currentButtonId: buttonId,
|
|
154
|
+
setCurrentButtonId: setButtonId,
|
|
155
|
+
periodKeys: ['ENTIRE', 'TODAY'],
|
|
156
|
+
startDateOptions: { currentDate: start, onChangeDate: setStart, datePickerOptions: { allowInput: true } },
|
|
157
|
+
endDateOptions: { currentDate: end, onChangeDate: setEnd, datePickerOptions: { allowInput: true } },
|
|
158
|
+
onDateValidation: (params) => {
|
|
159
|
+
onDateValidation(params);
|
|
160
|
+
if (params.type === 'end' && params.errorType === 'overlap')
|
|
161
|
+
setEnd(END_DATE);
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
};
|
|
165
|
+
const container = document.createElement('div');
|
|
166
|
+
document.body.appendChild(container);
|
|
167
|
+
const root = createRoot(container);
|
|
168
|
+
act(() => {
|
|
169
|
+
root.render(createElement(Harness));
|
|
170
|
+
});
|
|
171
|
+
mounted.push({ root, container });
|
|
172
|
+
const endInput = Array.from(container.querySelectorAll('input.flatpickr-input'))[1];
|
|
173
|
+
onDateValidation.mockClear();
|
|
174
|
+
// 종전에는 lastNotifiedDate 가 부모 갱신 후에도 남아 있어 2회차부터 통지가 끊겼다
|
|
175
|
+
const ATTEMPT_COUNT = 3;
|
|
176
|
+
const firedPerAttempt = [];
|
|
177
|
+
for (let attempt = 0; attempt < ATTEMPT_COUNT; attempt++) {
|
|
178
|
+
const before = onDateValidation.mock.calls.length;
|
|
179
|
+
typeChars(endInput, TYPED_END_DATE);
|
|
180
|
+
firedPerAttempt.push(onDateValidation.mock.calls.length - before);
|
|
181
|
+
}
|
|
182
|
+
expect(firedPerAttempt).toEqual([1, 1, 1]);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
describe('#11 통지 후 사용처가 날짜를 되돌려도 입력 칸의 포커스가 유지된다 (#416)', () => {
|
|
186
|
+
const START_DATE = '2026-08-10';
|
|
187
|
+
const END_DATE = '2026-08-20';
|
|
188
|
+
/** 시작일 이전 — overlap */
|
|
189
|
+
const TYPED_END_DATE = '2026-08-06';
|
|
190
|
+
it('overlap 통지 → 되돌리기 후에도 input 이 포커스를 잃지 않는다', () => {
|
|
191
|
+
const Harness = () => {
|
|
192
|
+
const [start, setStart] = useState(START_DATE);
|
|
193
|
+
const [end, setEnd] = useState(END_DATE);
|
|
194
|
+
const [buttonId, setButtonId] = useState('NONE');
|
|
195
|
+
return createElement(RangeDatePickerWithButtons, {
|
|
196
|
+
currentButtonId: buttonId,
|
|
197
|
+
setCurrentButtonId: setButtonId,
|
|
198
|
+
periodKeys: ['ENTIRE', 'TODAY'],
|
|
199
|
+
startDateOptions: { currentDate: start, onChangeDate: setStart, datePickerOptions: { allowInput: true } },
|
|
200
|
+
endDateOptions: { currentDate: end, onChangeDate: setEnd, datePickerOptions: { allowInput: true } },
|
|
201
|
+
onDateValidation: (params) => {
|
|
202
|
+
// 알럿 후 날짜를 되돌리는 사용처 패턴
|
|
203
|
+
if (params.type === 'end' && params.errorType === 'overlap')
|
|
204
|
+
setEnd(END_DATE);
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
};
|
|
208
|
+
const container = document.createElement('div');
|
|
209
|
+
document.body.appendChild(container);
|
|
210
|
+
const root = createRoot(container);
|
|
211
|
+
act(() => {
|
|
212
|
+
root.render(createElement(Harness));
|
|
213
|
+
});
|
|
214
|
+
mounted.push({ root, container });
|
|
215
|
+
const endInput = Array.from(container.querySelectorAll('input.flatpickr-input'))[1];
|
|
216
|
+
// 사용자가 칸을 클릭해 포커스를 쥔 상태로 입력한다
|
|
217
|
+
endInput.focus();
|
|
218
|
+
expect(document.activeElement).toBe(endInput);
|
|
219
|
+
endInput.value = '';
|
|
220
|
+
for (const char of TYPED_END_DATE) {
|
|
221
|
+
act(() => {
|
|
222
|
+
endInput.value = `${endInput.value}${char}`;
|
|
223
|
+
endInput.dispatchEvent(new Event('input', { bubbles: true }));
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
// 되돌리기 리렌더에서 flatpickr 가 wrapper 를 재생성하며 input 노드를 옮겨 포커스가 빠졌다.
|
|
227
|
+
// 사용자는 알럿을 닫고 이어서 타이핑하는데 입력이 들어가지 않아 콜백이 끊긴 것처럼 보였다.
|
|
228
|
+
expect(endInput.value).toBe(END_DATE);
|
|
229
|
+
expect(document.activeElement).toBe(endInput);
|
|
230
|
+
});
|
|
231
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,96 @@
|
|
|
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 { root, container } of mounted.splice(0)) {
|
|
21
|
+
act(() => {
|
|
22
|
+
root.unmount();
|
|
23
|
+
});
|
|
24
|
+
container.remove();
|
|
25
|
+
}
|
|
26
|
+
URL.createObjectURL = originalCreateObjectURL;
|
|
27
|
+
URL.revokeObjectURL = originalRevokeObjectURL;
|
|
28
|
+
});
|
|
29
|
+
/** 내용은 검증 대상이 아니므로 최소 크기만 채운다 */
|
|
30
|
+
const DUMMY_FILE_BYTES = 4;
|
|
31
|
+
const makeFile = (name) => new File([new Uint8Array(DUMMY_FILE_BYTES)], name, { type: 'image/jpeg' });
|
|
32
|
+
/** 훅 결과를 data-url 속성으로 노출하는 관찰용 컴포넌트 */
|
|
33
|
+
const Probe = ({ file }) => createElement('span', { 'data-url': useObjectUrl(file) ?? '' });
|
|
34
|
+
function renderProbe(file) {
|
|
35
|
+
const container = document.createElement('div');
|
|
36
|
+
document.body.appendChild(container);
|
|
37
|
+
const root = createRoot(container);
|
|
38
|
+
const render = (nextFile) => {
|
|
39
|
+
act(() => {
|
|
40
|
+
root.render(createElement(Probe, { file: nextFile }));
|
|
41
|
+
});
|
|
42
|
+
};
|
|
43
|
+
render(file);
|
|
44
|
+
mounted.push({ root, container });
|
|
45
|
+
const currentUrl = () => container.querySelector('span')?.getAttribute('data-url');
|
|
46
|
+
const unmount = () => {
|
|
47
|
+
act(() => {
|
|
48
|
+
root.unmount();
|
|
49
|
+
});
|
|
50
|
+
const index = mounted.findIndex((entry) => entry.root === root);
|
|
51
|
+
mounted.splice(index, 1);
|
|
52
|
+
container.remove();
|
|
53
|
+
};
|
|
54
|
+
return { render, currentUrl, unmount };
|
|
55
|
+
}
|
|
56
|
+
describe('useObjectUrl', () => {
|
|
57
|
+
it('파일을 주면 object URL을 만들어 반환한다', () => {
|
|
58
|
+
const probe = renderProbe(makeFile('a.jpg'));
|
|
59
|
+
expect(createObjectURL).toHaveBeenCalledTimes(1);
|
|
60
|
+
expect(probe.currentUrl()).toBe('blob:mock/1');
|
|
61
|
+
});
|
|
62
|
+
it('같은 파일로 여러 번 리렌더해도 URL을 한 번만 만든다', () => {
|
|
63
|
+
const file = makeFile('a.jpg');
|
|
64
|
+
const probe = renderProbe(file);
|
|
65
|
+
probe.render(file);
|
|
66
|
+
probe.render(file);
|
|
67
|
+
probe.render(file);
|
|
68
|
+
expect(createObjectURL).toHaveBeenCalledTimes(1);
|
|
69
|
+
expect(revokeObjectURL).not.toHaveBeenCalled();
|
|
70
|
+
});
|
|
71
|
+
it('언마운트하면 만들었던 URL을 해제한다', () => {
|
|
72
|
+
const probe = renderProbe(makeFile('a.jpg'));
|
|
73
|
+
expect(revokeObjectURL).not.toHaveBeenCalled();
|
|
74
|
+
probe.unmount();
|
|
75
|
+
expect(revokeObjectURL).toHaveBeenCalledTimes(1);
|
|
76
|
+
expect(revokeObjectURL).toHaveBeenCalledWith('blob:mock/1');
|
|
77
|
+
});
|
|
78
|
+
it('파일이 바뀌면 이전 URL을 해제하고 새로 만든다', () => {
|
|
79
|
+
const probe = renderProbe(makeFile('a.jpg'));
|
|
80
|
+
probe.render(makeFile('b.jpg'));
|
|
81
|
+
expect(createObjectURL).toHaveBeenCalledTimes(2);
|
|
82
|
+
expect(revokeObjectURL).toHaveBeenCalledWith('blob:mock/1');
|
|
83
|
+
expect(probe.currentUrl()).toBe('blob:mock/2');
|
|
84
|
+
});
|
|
85
|
+
it('파일이 없으면 URL을 만들지 않는다', () => {
|
|
86
|
+
const probe = renderProbe(undefined);
|
|
87
|
+
expect(createObjectURL).not.toHaveBeenCalled();
|
|
88
|
+
expect(probe.currentUrl()).toBe('');
|
|
89
|
+
});
|
|
90
|
+
it('파일을 없애면 이전 URL을 해제한다', () => {
|
|
91
|
+
const probe = renderProbe(makeFile('a.jpg'));
|
|
92
|
+
probe.render(undefined);
|
|
93
|
+
expect(revokeObjectURL).toHaveBeenCalledWith('blob:mock/1');
|
|
94
|
+
expect(probe.currentUrl()).toBe('');
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Creates an object URL for a file and revokes it when the file changes
|
|
3
|
+
* or the component unmounts.
|
|
4
|
+
*
|
|
5
|
+
* Calling `URL.createObjectURL` inline while rendering mints a new blob URL on
|
|
6
|
+
* every re-render and never releases it, so the browser holds every one of them
|
|
7
|
+
* for the lifetime of the document. Creating it in an effect gives us a cleanup
|
|
8
|
+
* hook to revoke on.
|
|
9
|
+
*
|
|
10
|
+
* A layout effect is used so the URL is in place before the browser paints —
|
|
11
|
+
* with a plain effect the image would blink out for a frame whenever the
|
|
12
|
+
* component remounts (for example when a sibling is removed and keys shift).
|
|
13
|
+
*
|
|
14
|
+
* Returns `null` while no file is given.
|
|
15
|
+
*/
|
|
16
|
+
export declare const useObjectUrl: (file?: File) => string | null;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { useIsomorphicLayoutEffect } from './useIsomorphicLayoutEffect';
|
|
3
|
+
/**
|
|
4
|
+
* Creates an object URL for a file and revokes it when the file changes
|
|
5
|
+
* or the component unmounts.
|
|
6
|
+
*
|
|
7
|
+
* Calling `URL.createObjectURL` inline while rendering mints a new blob URL on
|
|
8
|
+
* every re-render and never releases it, so the browser holds every one of them
|
|
9
|
+
* for the lifetime of the document. Creating it in an effect gives us a cleanup
|
|
10
|
+
* hook to revoke on.
|
|
11
|
+
*
|
|
12
|
+
* A layout effect is used so the URL is in place before the browser paints —
|
|
13
|
+
* with a plain effect the image would blink out for a frame whenever the
|
|
14
|
+
* component remounts (for example when a sibling is removed and keys shift).
|
|
15
|
+
*
|
|
16
|
+
* Returns `null` while no file is given.
|
|
17
|
+
*/
|
|
18
|
+
export const useObjectUrl = (file) => {
|
|
19
|
+
const [objectUrl, setObjectUrl] = useState(null);
|
|
20
|
+
useIsomorphicLayoutEffect(() => {
|
|
21
|
+
if (!file) {
|
|
22
|
+
setObjectUrl(null);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
const createdUrl = URL.createObjectURL(file);
|
|
26
|
+
setObjectUrl(createdUrl);
|
|
27
|
+
return () => {
|
|
28
|
+
URL.revokeObjectURL(createdUrl);
|
|
29
|
+
};
|
|
30
|
+
}, [file]);
|
|
31
|
+
return objectUrl;
|
|
32
|
+
};
|
|
@@ -1,4 +1,9 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
1
2
|
import { defineConfig } from 'vitest/config';
|
|
3
|
+
// lcov 의 SF 경로를 레포 루트 기준으로 기록한다. sonar-scanner 는 레포 루트를 단일
|
|
4
|
+
// projectBaseDir 로 잡으므로, 패키지 루트 기준 상대경로(SF:src/foo.ts)로 두면 경로가
|
|
5
|
+
// 해석되지 않아 커버리지가 통째로 무시된다.
|
|
6
|
+
const REPO_ROOT = path.resolve(__dirname, '../..');
|
|
2
7
|
export default defineConfig({
|
|
3
8
|
test: {
|
|
4
9
|
include: [
|
|
@@ -7,5 +12,24 @@ export default defineConfig({
|
|
|
7
12
|
'assets/scripts/**/__tests__/**/*.test.ts',
|
|
8
13
|
],
|
|
9
14
|
environment: 'node',
|
|
15
|
+
coverage: {
|
|
16
|
+
provider: 'v8',
|
|
17
|
+
// 테스트가 존재하는 3개 루트를 분모로 잡는다 (test.include 와 동일 범위).
|
|
18
|
+
include: ['src/**/*.{ts,tsx}', 'scripts/**/*.ts', 'assets/scripts/**/*.ts'],
|
|
19
|
+
exclude: [
|
|
20
|
+
'**/__tests__/**',
|
|
21
|
+
'**/*.d.ts',
|
|
22
|
+
// ui-admin 의 index.ts 81개는 전부 `export * from ...` barrel 이라 측정 의미가 없다.
|
|
23
|
+
// (step-guide 의 src/index.ts 는 824줄 본체 구현이므로 그쪽에서는 제외하지 않는다.)
|
|
24
|
+
'**/index.ts',
|
|
25
|
+
// MCP 가 읽는 컴포넌트 메타데이터 선언 45개 — 실행 분기 없는 순수 데이터.
|
|
26
|
+
'**/*.meta.ts',
|
|
27
|
+
'src/types/**',
|
|
28
|
+
'src/constant/**',
|
|
29
|
+
],
|
|
30
|
+
// 'text' 미포함 — 미커버 파일이 수백 개라 파일별 상세 출력이 CI 로그를 덮는다.
|
|
31
|
+
// 'lcov' 대신 'lcovonly' — 'lcov' 는 HTML 리포트까지 생성한다.
|
|
32
|
+
reporter: ['text-summary', ['lcovonly', { projectRoot: REPO_ROOT }]],
|
|
33
|
+
},
|
|
10
34
|
},
|
|
11
35
|
});
|
|
@@ -57,5 +57,13 @@ export interface FileInputProps extends Omit<InputBaseProps, 'clearText' | 'onCl
|
|
|
57
57
|
* Hint text to display
|
|
58
58
|
*/
|
|
59
59
|
hintText?: string;
|
|
60
|
+
/**
|
|
61
|
+
* Whether to render the built-in file tag list
|
|
62
|
+
*
|
|
63
|
+
* Set to `false` when the file list is rendered outside the component —
|
|
64
|
+
* the selected files still arrive through `onChange` / `onFileSelect`, and
|
|
65
|
+
* file count/duplicate validation keeps working.
|
|
66
|
+
*/
|
|
67
|
+
showFileTags?: boolean;
|
|
60
68
|
}
|
|
61
69
|
export declare const FileInput: import("react").ForwardRefExoticComponent<FileInputProps & import("react").RefAttributes<HTMLInputElement>>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -1,5 +1,17 @@
|
|
|
1
|
-
import { type InvalidFile } from '../file-input/FileInput';
|
|
1
|
+
import { FileInputErrorType as ImageFileInputErrorType, type InvalidFile } from '../file-input/FileInput';
|
|
2
2
|
import type { InputBaseProps } from '../input-base/InputBase';
|
|
3
|
+
/**
|
|
4
|
+
* An image already stored on the server, identified by its URL.
|
|
5
|
+
*
|
|
6
|
+
* Mirrors the vanilla implementation's `UploadedFile`
|
|
7
|
+
* (assets/scripts/imageFileInput/const/types.ts), with two deliberate differences:
|
|
8
|
+
* the name is prefixed because this one is re-exported from the package root,
|
|
9
|
+
* and `fileImageUrl` is required — a preview entry without a URL has nothing to render.
|
|
10
|
+
*/
|
|
11
|
+
export type ImageUploadedFile = {
|
|
12
|
+
fileName: string;
|
|
13
|
+
fileImageUrl: string;
|
|
14
|
+
};
|
|
3
15
|
export interface ImageFileInputProps extends Omit<InputBaseProps, 'clearText' | 'onClearText' | 'hintText' | 'value' | 'onChange'> {
|
|
4
16
|
/**
|
|
5
17
|
* Accepted file types
|
|
@@ -22,6 +34,19 @@ export interface ImageFileInputProps extends Omit<InputBaseProps, 'clearText' |
|
|
|
22
34
|
* Callback when files change (controlled mode)
|
|
23
35
|
*/
|
|
24
36
|
onChange?: (files: File[]) => void;
|
|
37
|
+
/**
|
|
38
|
+
* Images already stored on the server, shown before newly selected files.
|
|
39
|
+
* Rendered straight from their URL, so no fetch or CORS handling is needed.
|
|
40
|
+
*/
|
|
41
|
+
uploadedFiles?: ImageUploadedFile[];
|
|
42
|
+
/**
|
|
43
|
+
* Callback when a stored image is removed. Receives the remaining list.
|
|
44
|
+
*
|
|
45
|
+
* `uploadedFiles` is a controlled prop — just like `value`/`onChange`, removal
|
|
46
|
+
* only takes effect once the caller applies this list back. Passing
|
|
47
|
+
* `uploadedFiles` without this callback leaves the previews frozen.
|
|
48
|
+
*/
|
|
49
|
+
onUploadedFilesChange?: (uploadedFiles: ImageUploadedFile[]) => void;
|
|
25
50
|
/**
|
|
26
51
|
* Callback when files are selected (uncontrolled mode)
|
|
27
52
|
*/
|
|
@@ -68,3 +93,10 @@ export interface ImageFileInputProps extends Omit<InputBaseProps, 'clearText' |
|
|
|
68
93
|
showFileInput?: boolean;
|
|
69
94
|
}
|
|
70
95
|
export declare const ImageFileInput: import("react").ForwardRefExoticComponent<ImageFileInputProps & import("react").RefAttributes<HTMLInputElement>>;
|
|
96
|
+
/**
|
|
97
|
+
* Rejection reasons reported through `onFail`.
|
|
98
|
+
*
|
|
99
|
+
* Re-exported under the ImageFileInput name so callers do not have to reach
|
|
100
|
+
* into FileInput for it. Same enum, same values.
|
|
101
|
+
*/
|
|
102
|
+
export { ImageFileInputErrorType };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/types/src/components/forms-and-input/image-file-input/components/ImagePreview.d.ts
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
export interface ImagePreviewProps {
|
|
2
|
-
|
|
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;
|
|
@@ -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;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Creates an object URL for a file and revokes it when the file changes
|
|
3
|
+
* or the component unmounts.
|
|
4
|
+
*
|
|
5
|
+
* Calling `URL.createObjectURL` inline while rendering mints a new blob URL on
|
|
6
|
+
* every re-render and never releases it, so the browser holds every one of them
|
|
7
|
+
* for the lifetime of the document. Creating it in an effect gives us a cleanup
|
|
8
|
+
* hook to revoke on.
|
|
9
|
+
*
|
|
10
|
+
* A layout effect is used so the URL is in place before the browser paints —
|
|
11
|
+
* with a plain effect the image would blink out for a frame whenever the
|
|
12
|
+
* component remounts (for example when a sibling is removed and keys shift).
|
|
13
|
+
*
|
|
14
|
+
* Returns `null` while no file is given.
|
|
15
|
+
*/
|
|
16
|
+
export declare const useObjectUrl: (file?: File) => string | null;
|
|
@@ -973,7 +973,7 @@ button {
|
|
|
973
973
|
background-color: var(--gray-100);
|
|
974
974
|
border-radius: 4px;
|
|
975
975
|
}
|
|
976
|
-
.ncua-checkbox-input:has(input:checked)::before, .ncua-checkbox-input.has-indeterminate::before {
|
|
976
|
+
.ncua-checkbox-input:has(input:checked)::before, .ncua-checkbox-input:has(input:indeterminate)::before, .ncua-checkbox-input.has-indeterminate::before {
|
|
977
977
|
position: absolute;
|
|
978
978
|
top: 50%;
|
|
979
979
|
left: 50%;
|
|
@@ -992,14 +992,14 @@ button {
|
|
|
992
992
|
.ncua-checkbox-input:has(input:disabled:checked)::before {
|
|
993
993
|
background-image: var(--check-icon-disabled);
|
|
994
994
|
}
|
|
995
|
-
.ncua-checkbox-input.has-indeterminate :where(input) {
|
|
995
|
+
.ncua-checkbox-input:has(input:indeterminate) :where(input), .ncua-checkbox-input.has-indeterminate :where(input) {
|
|
996
996
|
border-color: var(--gray-700);
|
|
997
997
|
background-color: var(--gray-50);
|
|
998
998
|
}
|
|
999
|
-
.ncua-checkbox-input.has-indeterminate::before {
|
|
999
|
+
.ncua-checkbox-input:has(input:indeterminate)::before, .ncua-checkbox-input.has-indeterminate::before {
|
|
1000
1000
|
background-image: var(--indeterminate-icon);
|
|
1001
1001
|
}
|
|
1002
|
-
.ncua-checkbox-input.has-indeterminate:has(input:disabled)::before {
|
|
1002
|
+
.ncua-checkbox-input:has(input:indeterminate):has(input:disabled)::before, .ncua-checkbox-input.has-indeterminate:has(input:disabled)::before {
|
|
1003
1003
|
background-image: var(--indeterminate-icon-disabled);
|
|
1004
1004
|
}
|
|
1005
1005
|
|
|
@@ -6764,6 +6764,14 @@ button {
|
|
|
6764
6764
|
line-height: var(--line-heights-xs);
|
|
6765
6765
|
color: var(--gray-500);
|
|
6766
6766
|
}
|
|
6767
|
+
.ncua-data-grid__summary-count {
|
|
6768
|
+
color: var(--primary-red-500);
|
|
6769
|
+
font-weight: 700;
|
|
6770
|
+
}
|
|
6771
|
+
.ncua-data-grid__summary-actions {
|
|
6772
|
+
display: flex;
|
|
6773
|
+
gap: var(--spacing-xs);
|
|
6774
|
+
}
|
|
6767
6775
|
.ncua-data-grid__table-wrapper {
|
|
6768
6776
|
border: 1px solid var(--gray-100);
|
|
6769
6777
|
border-radius: var(--border-radius-md);
|
|
@@ -6811,6 +6819,14 @@ button {
|
|
|
6811
6819
|
.ncua-data-grid__action-bar--space-between {
|
|
6812
6820
|
justify-content: space-between;
|
|
6813
6821
|
}
|
|
6822
|
+
.ncua-data-grid__action-bar-group {
|
|
6823
|
+
display: flex;
|
|
6824
|
+
align-items: center;
|
|
6825
|
+
gap: var(--spacing-s);
|
|
6826
|
+
}
|
|
6827
|
+
.ncua-data-grid__action-bar-group--right {
|
|
6828
|
+
gap: var(--spacing-xs);
|
|
6829
|
+
}
|
|
6814
6830
|
.ncua-data-grid__table {
|
|
6815
6831
|
width: 100%;
|
|
6816
6832
|
position: relative;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ncds/ui-admin",
|
|
3
|
-
"version": "1.8.
|
|
3
|
+
"version": "1.8.18",
|
|
4
4
|
"description": "nhn-commerce의 어드민 디자인 시스템입니다.",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"barrel": "node barrel.js",
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
"start": "webpack --node-env development",
|
|
23
23
|
"check:meta": "tsx scripts/check-meta.ts",
|
|
24
24
|
"test": "vitest run --config vitest.config.ts",
|
|
25
|
+
"test:coverage": "vitest run --coverage --config vitest.config.ts",
|
|
25
26
|
"tsc": "tsc --pretty --listFiles"
|
|
26
27
|
},
|
|
27
28
|
"author": {
|
|
@@ -72,7 +73,7 @@
|
|
|
72
73
|
"@atlaskit/pragmatic-drag-and-drop-react-accessibility": "1.1.4",
|
|
73
74
|
"@ncds/ui-admin-icon": "0.1.12",
|
|
74
75
|
"classnames": "2.5.1",
|
|
75
|
-
"dompurify": "3.4.
|
|
76
|
+
"dompurify": "3.4.12",
|
|
76
77
|
"flatpickr": "4.6.13",
|
|
77
78
|
"lodash": "4.18.1",
|
|
78
79
|
"lodash-es": "4.18.1",
|
|
@@ -92,6 +93,7 @@
|
|
|
92
93
|
"@types/react": "18.3.28",
|
|
93
94
|
"@types/react-dom": "18.3.7",
|
|
94
95
|
"@types/react-flatpickr": "3.8.11",
|
|
96
|
+
"@vitest/coverage-v8": "^4.1.10",
|
|
95
97
|
"babel-loader": "9.2.1",
|
|
96
98
|
"babel-plugin-named-exports-order": "0.0.2",
|
|
97
99
|
"commander": "11.1.0",
|
|
@@ -118,7 +120,7 @@
|
|
|
118
120
|
"ts-node": "10.9.2",
|
|
119
121
|
"tsx": "^4.21.0",
|
|
120
122
|
"typescript": "5.0.4",
|
|
121
|
-
"vitest": "^4.1.
|
|
123
|
+
"vitest": "^4.1.10",
|
|
122
124
|
"webpack": "5.105.4",
|
|
123
125
|
"webpack-cli": "5.1.4",
|
|
124
126
|
"webpack-dev-server": "5.2.3"
|