@object-ui/plugin-grid 0.5.0 → 3.0.0
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/.turbo/turbo-build.log +51 -6
- package/CHANGELOG.md +37 -0
- package/README.md +97 -0
- package/dist/index.js +994 -584
- package/dist/index.umd.cjs +3 -3
- package/dist/packages/plugin-grid/src/InlineEditing.d.ts +28 -0
- package/dist/packages/plugin-grid/src/ListColumnExtensions.test.d.ts +0 -0
- package/dist/packages/plugin-grid/src/ListColumnSchema.test.d.ts +1 -0
- package/dist/packages/plugin-grid/src/ObjectGrid.EdgeCases.stories.d.ts +25 -0
- package/dist/packages/plugin-grid/src/ObjectGrid.d.ts +7 -1
- package/dist/packages/plugin-grid/src/ObjectGrid.stories.d.ts +33 -0
- package/dist/packages/plugin-grid/src/__tests__/InlineEditing.test.d.ts +0 -0
- package/dist/packages/plugin-grid/src/__tests__/VirtualGrid.test.d.ts +0 -0
- package/dist/packages/plugin-grid/src/__tests__/accessibility.test.d.ts +0 -0
- package/dist/packages/plugin-grid/src/__tests__/performance-benchmark.test.d.ts +0 -0
- package/dist/packages/plugin-grid/src/__tests__/view-states.test.d.ts +0 -0
- package/dist/packages/plugin-grid/src/index.d.ts +5 -0
- package/dist/packages/plugin-grid/src/useGroupedData.d.ts +30 -0
- package/dist/packages/plugin-grid/src/useRowColor.d.ts +8 -0
- package/package.json +11 -10
- package/src/InlineEditing.tsx +235 -0
- package/src/ListColumnExtensions.test.tsx +374 -0
- package/src/ListColumnSchema.test.ts +88 -0
- package/src/ObjectGrid.EdgeCases.stories.tsx +147 -0
- package/src/ObjectGrid.msw.test.tsx +24 -1
- package/src/ObjectGrid.stories.tsx +139 -0
- package/src/ObjectGrid.tsx +409 -113
- package/src/__tests__/InlineEditing.test.tsx +360 -0
- package/src/__tests__/VirtualGrid.test.tsx +438 -0
- package/src/__tests__/accessibility.test.tsx +254 -0
- package/src/__tests__/performance-benchmark.test.tsx +182 -0
- package/src/__tests__/view-states.test.tsx +203 -0
- package/src/index.tsx +17 -2
- package/src/useGroupedData.ts +122 -0
- package/src/useRowColor.ts +74 -0
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* InlineEditing Component Tests
|
|
3
|
+
*
|
|
4
|
+
* Tests for the grid inline editing component covering save/cancel,
|
|
5
|
+
* validation feedback, keyboard navigation, and display modes.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
9
|
+
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
|
10
|
+
import userEvent from '@testing-library/user-event';
|
|
11
|
+
import '@testing-library/jest-dom';
|
|
12
|
+
import { InlineEditing } from '../InlineEditing';
|
|
13
|
+
|
|
14
|
+
// Mock lucide-react icons
|
|
15
|
+
vi.mock('lucide-react', () => ({
|
|
16
|
+
Check: (props: any) => <svg data-testid="icon-check" {...props} />,
|
|
17
|
+
X: (props: any) => <svg data-testid="icon-x" {...props} />,
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
// Mock cn utility to pass through classes
|
|
21
|
+
vi.mock('@object-ui/components', () => ({
|
|
22
|
+
cn: (...args: any[]) => args.filter(Boolean).join(' '),
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
describe('InlineEditing', () => {
|
|
26
|
+
describe('Display mode', () => {
|
|
27
|
+
it('renders the value in display mode by default', () => {
|
|
28
|
+
render(<InlineEditing value="Hello" onSave={vi.fn()} />);
|
|
29
|
+
|
|
30
|
+
expect(screen.getByText('Hello')).toBeInTheDocument();
|
|
31
|
+
// Should not show an input
|
|
32
|
+
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('shows placeholder when value is empty', () => {
|
|
36
|
+
render(<InlineEditing value="" onSave={vi.fn()} placeholder="Enter text" />);
|
|
37
|
+
|
|
38
|
+
expect(screen.getByText('Enter text')).toBeInTheDocument();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('shows default placeholder when value is null', () => {
|
|
42
|
+
render(<InlineEditing value={null} onSave={vi.fn()} />);
|
|
43
|
+
|
|
44
|
+
expect(screen.getByText('Click to edit')).toBeInTheDocument();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('has data-slot="inline-editing" on root', () => {
|
|
48
|
+
const { container } = render(<InlineEditing value="test" onSave={vi.fn()} />);
|
|
49
|
+
expect(container.querySelector('[data-slot="inline-editing"]')).toBeInTheDocument();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('applies custom className', () => {
|
|
53
|
+
const { container } = render(
|
|
54
|
+
<InlineEditing value="test" onSave={vi.fn()} className="custom-class" />,
|
|
55
|
+
);
|
|
56
|
+
const root = container.querySelector('[data-slot="inline-editing"]');
|
|
57
|
+
expect(root?.className).toContain('custom-class');
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe('Entering edit mode', () => {
|
|
62
|
+
it('enters edit mode on click', async () => {
|
|
63
|
+
const user = userEvent.setup();
|
|
64
|
+
render(<InlineEditing value="Hello" onSave={vi.fn()} />);
|
|
65
|
+
|
|
66
|
+
await user.click(screen.getByRole('button'));
|
|
67
|
+
|
|
68
|
+
expect(screen.getByRole('textbox')).toBeInTheDocument();
|
|
69
|
+
expect((screen.getByRole('textbox') as HTMLInputElement).value).toBe('Hello');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('enters edit mode on Enter key press', () => {
|
|
73
|
+
render(<InlineEditing value="Hello" onSave={vi.fn()} />);
|
|
74
|
+
|
|
75
|
+
fireEvent.keyDown(screen.getByRole('button'), { key: 'Enter' });
|
|
76
|
+
|
|
77
|
+
expect(screen.getByRole('textbox')).toBeInTheDocument();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('enters edit mode on Space key press', () => {
|
|
81
|
+
render(<InlineEditing value="Hello" onSave={vi.fn()} />);
|
|
82
|
+
|
|
83
|
+
fireEvent.keyDown(screen.getByRole('button'), { key: ' ' });
|
|
84
|
+
|
|
85
|
+
expect(screen.getByRole('textbox')).toBeInTheDocument();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('starts in edit mode when editing prop is true', () => {
|
|
89
|
+
render(<InlineEditing value="Hello" onSave={vi.fn()} editing={true} />);
|
|
90
|
+
|
|
91
|
+
expect(screen.getByRole('textbox')).toBeInTheDocument();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('does not enter edit mode when disabled', async () => {
|
|
95
|
+
const user = userEvent.setup();
|
|
96
|
+
render(<InlineEditing value="Hello" onSave={vi.fn()} disabled />);
|
|
97
|
+
|
|
98
|
+
await user.click(screen.getByText('Hello'));
|
|
99
|
+
|
|
100
|
+
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe('Save', () => {
|
|
105
|
+
it('saves on Enter key press', async () => {
|
|
106
|
+
const user = userEvent.setup();
|
|
107
|
+
const onSave = vi.fn();
|
|
108
|
+
render(<InlineEditing value="Hello" onSave={onSave} editing={true} />);
|
|
109
|
+
|
|
110
|
+
const input = screen.getByRole('textbox');
|
|
111
|
+
await user.clear(input);
|
|
112
|
+
await user.type(input, 'World');
|
|
113
|
+
await user.keyboard('{Enter}');
|
|
114
|
+
|
|
115
|
+
await waitFor(() => {
|
|
116
|
+
expect(onSave).toHaveBeenCalledWith('World');
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('saves on save button click', async () => {
|
|
121
|
+
const user = userEvent.setup();
|
|
122
|
+
const onSave = vi.fn();
|
|
123
|
+
render(<InlineEditing value="Hello" onSave={onSave} editing={true} />);
|
|
124
|
+
|
|
125
|
+
const input = screen.getByRole('textbox');
|
|
126
|
+
await user.clear(input);
|
|
127
|
+
await user.type(input, 'Updated');
|
|
128
|
+
|
|
129
|
+
await user.click(screen.getByLabelText('Save'));
|
|
130
|
+
|
|
131
|
+
await waitFor(() => {
|
|
132
|
+
expect(onSave).toHaveBeenCalledWith('Updated');
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('coerces value to number when type is number', async () => {
|
|
137
|
+
const user = userEvent.setup();
|
|
138
|
+
const onSave = vi.fn();
|
|
139
|
+
render(
|
|
140
|
+
<InlineEditing value={42} onSave={onSave} type="number" editing={true} />,
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
const input = screen.getByRole('spinbutton');
|
|
144
|
+
await user.clear(input);
|
|
145
|
+
await user.type(input, '99');
|
|
146
|
+
await user.click(screen.getByLabelText('Save'));
|
|
147
|
+
|
|
148
|
+
await waitFor(() => {
|
|
149
|
+
expect(onSave).toHaveBeenCalledWith(99);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('exits edit mode after successful save', async () => {
|
|
154
|
+
const user = userEvent.setup();
|
|
155
|
+
const onSave = vi.fn();
|
|
156
|
+
render(<InlineEditing value="Hello" onSave={onSave} editing={true} />);
|
|
157
|
+
|
|
158
|
+
await user.click(screen.getByLabelText('Save'));
|
|
159
|
+
|
|
160
|
+
await waitFor(() => {
|
|
161
|
+
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
describe('Cancel', () => {
|
|
167
|
+
it('cancels on Escape key press', async () => {
|
|
168
|
+
const user = userEvent.setup();
|
|
169
|
+
const onCancel = vi.fn();
|
|
170
|
+
render(
|
|
171
|
+
<InlineEditing value="Hello" onSave={vi.fn()} onCancel={onCancel} editing={true} />,
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
const input = screen.getByRole('textbox');
|
|
175
|
+
await user.type(input, ' extra');
|
|
176
|
+
await user.keyboard('{Escape}');
|
|
177
|
+
|
|
178
|
+
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
|
|
179
|
+
expect(onCancel).toHaveBeenCalled();
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('cancels on cancel button click', async () => {
|
|
183
|
+
const user = userEvent.setup();
|
|
184
|
+
const onCancel = vi.fn();
|
|
185
|
+
render(
|
|
186
|
+
<InlineEditing value="Hello" onSave={vi.fn()} onCancel={onCancel} editing={true} />,
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
await user.click(screen.getByLabelText('Cancel'));
|
|
190
|
+
|
|
191
|
+
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
|
|
192
|
+
expect(onCancel).toHaveBeenCalled();
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('restores original value after cancel', async () => {
|
|
196
|
+
const user = userEvent.setup();
|
|
197
|
+
render(
|
|
198
|
+
<InlineEditing value="Original" onSave={vi.fn()} editing={true} />,
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
const input = screen.getByRole('textbox');
|
|
202
|
+
await user.clear(input);
|
|
203
|
+
await user.type(input, 'Changed');
|
|
204
|
+
await user.keyboard('{Escape}');
|
|
205
|
+
|
|
206
|
+
// Back to display mode, showing original value
|
|
207
|
+
expect(screen.getByText('Original')).toBeInTheDocument();
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
describe('Validation', () => {
|
|
212
|
+
it('shows validation error and prevents save', async () => {
|
|
213
|
+
const user = userEvent.setup();
|
|
214
|
+
const onSave = vi.fn();
|
|
215
|
+
const validate = (val: any) =>
|
|
216
|
+
String(val).trim() === '' ? 'Required field' : undefined;
|
|
217
|
+
|
|
218
|
+
render(
|
|
219
|
+
<InlineEditing
|
|
220
|
+
value="Hello"
|
|
221
|
+
onSave={onSave}
|
|
222
|
+
validate={validate}
|
|
223
|
+
editing={true}
|
|
224
|
+
/>,
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
const input = screen.getByRole('textbox');
|
|
228
|
+
await user.clear(input);
|
|
229
|
+
await user.click(screen.getByLabelText('Save'));
|
|
230
|
+
|
|
231
|
+
expect(screen.getByRole('alert')).toHaveTextContent('Required field');
|
|
232
|
+
expect(onSave).not.toHaveBeenCalled();
|
|
233
|
+
// Should still be in edit mode
|
|
234
|
+
expect(screen.getByRole('textbox')).toBeInTheDocument();
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('clears validation error on input change', async () => {
|
|
238
|
+
const user = userEvent.setup();
|
|
239
|
+
const validate = (val: any) =>
|
|
240
|
+
String(val).trim() === '' ? 'Required field' : undefined;
|
|
241
|
+
|
|
242
|
+
render(
|
|
243
|
+
<InlineEditing
|
|
244
|
+
value="Hello"
|
|
245
|
+
onSave={vi.fn()}
|
|
246
|
+
validate={validate}
|
|
247
|
+
editing={true}
|
|
248
|
+
/>,
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
const input = screen.getByRole('textbox');
|
|
252
|
+
await user.clear(input);
|
|
253
|
+
await user.click(screen.getByLabelText('Save'));
|
|
254
|
+
|
|
255
|
+
expect(screen.getByRole('alert')).toBeInTheDocument();
|
|
256
|
+
|
|
257
|
+
// Start typing to clear error
|
|
258
|
+
await user.type(input, 'A');
|
|
259
|
+
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('shows error returned from onSave', async () => {
|
|
263
|
+
const user = userEvent.setup();
|
|
264
|
+
const onSave = vi.fn().mockReturnValue('Server error');
|
|
265
|
+
|
|
266
|
+
render(
|
|
267
|
+
<InlineEditing value="Hello" onSave={onSave} editing={true} />,
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
await user.click(screen.getByLabelText('Save'));
|
|
271
|
+
|
|
272
|
+
await waitFor(() => {
|
|
273
|
+
expect(screen.getByRole('alert')).toHaveTextContent('Server error');
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('marks input as aria-invalid when error is present', async () => {
|
|
278
|
+
const user = userEvent.setup();
|
|
279
|
+
const validate = () => 'Error';
|
|
280
|
+
|
|
281
|
+
render(
|
|
282
|
+
<InlineEditing value="" onSave={vi.fn()} validate={validate} editing={true} />,
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
await user.click(screen.getByLabelText('Save'));
|
|
286
|
+
|
|
287
|
+
expect(screen.getByRole('textbox')).toHaveAttribute('aria-invalid', 'true');
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
describe('Async save', () => {
|
|
292
|
+
it('handles async onSave', async () => {
|
|
293
|
+
const user = userEvent.setup();
|
|
294
|
+
const onSave = vi.fn().mockResolvedValue(undefined);
|
|
295
|
+
|
|
296
|
+
render(<InlineEditing value="Hello" onSave={onSave} editing={true} />);
|
|
297
|
+
|
|
298
|
+
await user.click(screen.getByLabelText('Save'));
|
|
299
|
+
|
|
300
|
+
await waitFor(() => {
|
|
301
|
+
expect(onSave).toHaveBeenCalledWith('Hello');
|
|
302
|
+
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it('shows error when async onSave throws', async () => {
|
|
307
|
+
const user = userEvent.setup();
|
|
308
|
+
const onSave = vi.fn().mockRejectedValue(new Error('Network error'));
|
|
309
|
+
|
|
310
|
+
render(<InlineEditing value="Hello" onSave={onSave} editing={true} />);
|
|
311
|
+
|
|
312
|
+
await user.click(screen.getByLabelText('Save'));
|
|
313
|
+
|
|
314
|
+
await waitFor(() => {
|
|
315
|
+
expect(screen.getByRole('alert')).toHaveTextContent('Network error');
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
// Should still be in edit mode
|
|
319
|
+
expect(screen.getByRole('textbox')).toBeInTheDocument();
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
describe('Input types', () => {
|
|
324
|
+
it('renders text input by default', () => {
|
|
325
|
+
render(<InlineEditing value="text" onSave={vi.fn()} editing={true} />);
|
|
326
|
+
expect(screen.getByRole('textbox')).toHaveAttribute('type', 'text');
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it('renders number input when type is number', () => {
|
|
330
|
+
render(<InlineEditing value={42} onSave={vi.fn()} type="number" editing={true} />);
|
|
331
|
+
expect(screen.getByRole('spinbutton')).toHaveAttribute('type', 'number');
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it('renders email input when type is email', () => {
|
|
335
|
+
render(<InlineEditing value="a@b.com" onSave={vi.fn()} type="email" editing={true} />);
|
|
336
|
+
const input = screen.getByDisplayValue('a@b.com');
|
|
337
|
+
expect(input).toHaveAttribute('type', 'email');
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
describe('data-slot attributes', () => {
|
|
342
|
+
it('has correct data-slot attributes in display mode', () => {
|
|
343
|
+
const { container } = render(<InlineEditing value="test" onSave={vi.fn()} />);
|
|
344
|
+
|
|
345
|
+
expect(container.querySelector('[data-slot="inline-editing"]')).toBeInTheDocument();
|
|
346
|
+
expect(container.querySelector('[data-slot="inline-editing-display"]')).toBeInTheDocument();
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it('has correct data-slot attributes in edit mode', () => {
|
|
350
|
+
const { container } = render(
|
|
351
|
+
<InlineEditing value="test" onSave={vi.fn()} editing={true} />,
|
|
352
|
+
);
|
|
353
|
+
|
|
354
|
+
expect(container.querySelector('[data-slot="inline-editing"]')).toBeInTheDocument();
|
|
355
|
+
expect(container.querySelector('[data-slot="inline-editing-input"]')).toBeInTheDocument();
|
|
356
|
+
expect(container.querySelector('[data-slot="inline-editing-save"]')).toBeInTheDocument();
|
|
357
|
+
expect(container.querySelector('[data-slot="inline-editing-cancel"]')).toBeInTheDocument();
|
|
358
|
+
});
|
|
359
|
+
});
|
|
360
|
+
});
|