@jasperoosthoek/react-toolbox 0.8.0 → 0.9.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.
Files changed (63) hide show
  1. package/change-log.md +330 -309
  2. package/dist/components/buttons/ConfirmButton.d.ts +2 -2
  3. package/dist/components/buttons/DeleteConfirmButton.d.ts +2 -2
  4. package/dist/components/buttons/IconButtons.d.ts +40 -41
  5. package/dist/components/errors/Errors.d.ts +1 -2
  6. package/dist/components/forms/FormField.d.ts +22 -0
  7. package/dist/components/forms/FormFields.d.ts +1 -56
  8. package/dist/components/forms/FormModal.d.ts +7 -34
  9. package/dist/components/forms/FormModalProvider.d.ts +19 -26
  10. package/dist/components/forms/FormProvider.d.ts +66 -0
  11. package/dist/components/forms/fields/FormBadgesSelection.d.ts +26 -0
  12. package/dist/components/forms/fields/FormCheckbox.d.ts +7 -0
  13. package/dist/components/forms/fields/FormDropdown.d.ts +19 -0
  14. package/dist/components/forms/fields/FormInput.d.ts +17 -0
  15. package/dist/components/forms/fields/FormSelect.d.ts +12 -0
  16. package/dist/components/forms/fields/index.d.ts +5 -0
  17. package/dist/components/indicators/CheckIndicator.d.ts +1 -2
  18. package/dist/components/indicators/LoadingIndicator.d.ts +4 -4
  19. package/dist/components/login/LoginPage.d.ts +1 -1
  20. package/dist/components/tables/DataTable.d.ts +2 -2
  21. package/dist/components/tables/DragAndDropList.d.ts +2 -2
  22. package/dist/components/tables/SearchBox.d.ts +2 -2
  23. package/dist/index.d.ts +4 -1
  24. package/dist/index.js +2 -2
  25. package/dist/index.js.LICENSE.txt +0 -4
  26. package/dist/localization/LocalizationContext.d.ts +1 -1
  27. package/dist/utils/hooks.d.ts +1 -1
  28. package/dist/utils/timeAndDate.d.ts +5 -2
  29. package/dist/utils/utils.d.ts +3 -3
  30. package/package.json +10 -11
  31. package/src/__tests__/buttons.test.tsx +545 -0
  32. package/src/__tests__/errors.test.tsx +339 -0
  33. package/src/__tests__/forms.test.tsx +3021 -0
  34. package/src/__tests__/hooks.test.tsx +413 -0
  35. package/src/__tests__/indicators.test.tsx +284 -0
  36. package/src/__tests__/localization.test.tsx +462 -0
  37. package/src/__tests__/login.test.tsx +417 -0
  38. package/src/__tests__/setupTests.ts +328 -0
  39. package/src/__tests__/tables.test.tsx +609 -0
  40. package/src/__tests__/timeAndDate.test.tsx +308 -0
  41. package/src/__tests__/utils.test.tsx +422 -0
  42. package/src/components/forms/FormField.tsx +92 -0
  43. package/src/components/forms/FormFields.tsx +3 -423
  44. package/src/components/forms/FormModal.tsx +168 -243
  45. package/src/components/forms/FormModalProvider.tsx +141 -95
  46. package/src/components/forms/FormProvider.tsx +218 -0
  47. package/src/components/forms/fields/FormBadgesSelection.tsx +108 -0
  48. package/src/components/forms/fields/FormCheckbox.tsx +76 -0
  49. package/src/components/forms/fields/FormDropdown.tsx +123 -0
  50. package/src/components/forms/fields/FormInput.tsx +114 -0
  51. package/src/components/forms/fields/FormSelect.tsx +47 -0
  52. package/src/components/forms/fields/index.ts +6 -0
  53. package/src/index.ts +32 -28
  54. package/src/localization/LocalizationContext.tsx +156 -131
  55. package/src/localization/localization.ts +131 -131
  56. package/src/utils/hooks.ts +108 -94
  57. package/src/utils/timeAndDate.ts +33 -4
  58. package/src/utils/utils.ts +74 -66
  59. package/dist/components/forms/CreateEditModal.d.ts +0 -41
  60. package/dist/components/forms/CreateEditModalProvider.d.ts +0 -41
  61. package/dist/components/forms/FormFields.test.d.ts +0 -4
  62. package/dist/login/Login.d.ts +0 -70
  63. package/src/components/forms/FormFields.test.tsx +0 -107
@@ -0,0 +1,422 @@
1
+ import {
2
+ isEmpty,
3
+ snakeToCamelCase,
4
+ camelToSnakeCase,
5
+ pluralToSingle,
6
+ arrayToObject,
7
+ roundFixed,
8
+ round,
9
+ downloadFile,
10
+ } from '../utils/utils';
11
+
12
+ // Mock fetch
13
+ const mockFetch = jest.fn();
14
+ global.fetch = mockFetch;
15
+
16
+ // Mock DOM APIs
17
+ Object.defineProperty(window, 'URL', {
18
+ value: {
19
+ createObjectURL: jest.fn(() => 'mock-blob-url'),
20
+ revokeObjectURL: jest.fn(),
21
+ },
22
+ });
23
+
24
+ describe('Utils - General Utilities Tests', () => {
25
+ beforeEach(() => {
26
+ jest.clearAllMocks();
27
+ });
28
+
29
+ describe('isEmpty', () => {
30
+ it('should return true for undefined', () => {
31
+ expect(isEmpty(undefined)).toBe(true);
32
+ });
33
+
34
+ it('should return true for null', () => {
35
+ expect(isEmpty(null)).toBe(true);
36
+ });
37
+
38
+ it('should return true for false', () => {
39
+ expect(isEmpty(false)).toBe(true);
40
+ });
41
+
42
+ it('should return true for empty object', () => {
43
+ expect(isEmpty({})).toBe(true);
44
+ });
45
+
46
+ it('should return true for empty string', () => {
47
+ expect(isEmpty('')).toBe(true);
48
+ expect(isEmpty(' ')).toBe(true); // whitespace only
49
+ });
50
+
51
+ it('should return false for non-empty values', () => {
52
+ expect(isEmpty(0)).toBe(false);
53
+ expect(isEmpty(true)).toBe(false);
54
+ expect(isEmpty('hello')).toBe(false);
55
+ expect(isEmpty('0')).toBe(false);
56
+ expect(isEmpty({ key: 'value' })).toBe(false);
57
+ expect(isEmpty([1, 2, 3])).toBe(false);
58
+ expect(isEmpty([])).toBe(true); // empty array should be considered empty
59
+ });
60
+
61
+ it('should handle edge cases', () => {
62
+ expect(isEmpty(0)).toBe(false);
63
+ expect(isEmpty(-1)).toBe(false);
64
+ expect(isEmpty(NaN)).toBe(false);
65
+ expect(isEmpty(Infinity)).toBe(false);
66
+ });
67
+ });
68
+
69
+ describe('snakeToCamelCase', () => {
70
+ it('should convert snake_case to camelCase', () => {
71
+ expect(snakeToCamelCase('snake_case')).toBe('snakeCase');
72
+ expect(snakeToCamelCase('multiple_word_example')).toBe('multipleWordExample');
73
+ });
74
+
75
+ it('should convert kebab-case to camelCase', () => {
76
+ expect(snakeToCamelCase('kebab-case')).toBe('kebabCase');
77
+ expect(snakeToCamelCase('multiple-word-example')).toBe('multipleWordExample');
78
+ });
79
+
80
+ it('should handle mixed cases', () => {
81
+ expect(snakeToCamelCase('mixed_case-example')).toBe('mixedCaseExample');
82
+ });
83
+
84
+ it('should handle single words', () => {
85
+ expect(snakeToCamelCase('word')).toBe('word');
86
+ });
87
+
88
+ it('should handle empty string', () => {
89
+ expect(snakeToCamelCase('')).toBe('');
90
+ });
91
+
92
+ it('should preserve already camelCase strings', () => {
93
+ expect(snakeToCamelCase('alreadyCamelCase')).toBe('alreadyCamelCase');
94
+ });
95
+ });
96
+
97
+ describe('camelToSnakeCase', () => {
98
+ it('should convert camelCase to SNAKE_CASE', () => {
99
+ expect(camelToSnakeCase('camelCase')).toBe('CAMEL_CASE');
100
+ expect(camelToSnakeCase('multipleWordExample')).toBe('MULTIPLE_WORD_EXAMPLE');
101
+ });
102
+
103
+ it('should handle single words', () => {
104
+ expect(camelToSnakeCase('word')).toBe('WORD');
105
+ });
106
+
107
+ it('should handle empty string', () => {
108
+ expect(camelToSnakeCase('')).toBe('');
109
+ });
110
+
111
+ it('should handle already uppercase strings', () => {
112
+ expect(camelToSnakeCase('ALREADY_UPPER')).toBe('ALREADY_UPPER');
113
+ });
114
+
115
+ it('should handle strings with numbers', () => {
116
+ expect(camelToSnakeCase('test123')).toBe('TEST_123');
117
+ expect(camelToSnakeCase('test123Word')).toBe('TEST_123_WORD');
118
+ });
119
+ });
120
+
121
+ describe('pluralToSingle', () => {
122
+ it('should convert standard plurals to singular', () => {
123
+ expect(pluralToSingle('cats')).toBe('cat');
124
+ expect(pluralToSingle('dogs')).toBe('dog');
125
+ expect(pluralToSingle('items')).toBe('item');
126
+ });
127
+
128
+ it('should handle words ending in "ies"', () => {
129
+ expect(pluralToSingle('categories')).toBe('category');
130
+ expect(pluralToSingle('stories')).toBe('story');
131
+ expect(pluralToSingle('companies')).toBe('company');
132
+ });
133
+
134
+ it('should handle uppercase "IES"', () => {
135
+ expect(pluralToSingle('CATEGORIES')).toBe('CATEGORY');
136
+ expect(pluralToSingle('STORIES')).toBe('STORY');
137
+ });
138
+
139
+ it('should leave non-plural words unchanged', () => {
140
+ expect(pluralToSingle('cat')).toBe('cat');
141
+ expect(pluralToSingle('item')).toBe('item');
142
+ expect(pluralToSingle('mouse')).toBe('mouse');
143
+ });
144
+
145
+ it('should handle edge cases', () => {
146
+ expect(pluralToSingle('')).toBe('');
147
+ expect(pluralToSingle('s')).toBe('');
148
+ expect(pluralToSingle('ies')).toBe('y');
149
+ expect(pluralToSingle('IES')).toBe('Y'); // now works correctly
150
+ });
151
+ });
152
+
153
+ describe('arrayToObject', () => {
154
+ it('should convert array to object using specified key', () => {
155
+ const array = [
156
+ { id: 1, name: 'Alice' },
157
+ { id: 2, name: 'Bob' },
158
+ { id: 3, name: 'Charlie' },
159
+ ];
160
+
161
+ const result = arrayToObject(array, 'id');
162
+
163
+ expect(result).toEqual({
164
+ 1: { id: 1, name: 'Alice' },
165
+ 2: { id: 2, name: 'Bob' },
166
+ 3: { id: 3, name: 'Charlie' },
167
+ });
168
+ });
169
+
170
+ it('should work with string keys', () => {
171
+ const array = [
172
+ { code: 'US', name: 'United States' },
173
+ { code: 'CA', name: 'Canada' },
174
+ { code: 'MX', name: 'Mexico' },
175
+ ];
176
+
177
+ const result = arrayToObject(array, 'code');
178
+
179
+ expect(result).toEqual({
180
+ US: { code: 'US', name: 'United States' },
181
+ CA: { code: 'CA', name: 'Canada' },
182
+ MX: { code: 'MX', name: 'Mexico' },
183
+ });
184
+ });
185
+
186
+ it('should handle empty array', () => {
187
+ const result = arrayToObject([], 'id');
188
+ expect(result).toEqual({});
189
+ });
190
+
191
+ it('should handle duplicate keys (last one wins)', () => {
192
+ const array = [
193
+ { id: 1, name: 'First' },
194
+ { id: 1, name: 'Second' },
195
+ ];
196
+
197
+ const result = arrayToObject(array, 'id');
198
+
199
+ expect(result).toEqual({
200
+ 1: { id: 1, name: 'Second' },
201
+ });
202
+ });
203
+ });
204
+
205
+ describe('roundFixed', () => {
206
+ it('should round numbers to specified decimal places as string', () => {
207
+ expect(roundFixed(3.14159, 2)).toBe('3.14');
208
+ expect(roundFixed(3.14159, 0)).toBe('3');
209
+ expect(roundFixed(3.14159, 4)).toBe('3.1416');
210
+ });
211
+
212
+ it('should handle string input', () => {
213
+ expect(roundFixed('3.14159', 2)).toBe('3.14');
214
+ expect(roundFixed('10', 2)).toBe('10.00');
215
+ });
216
+
217
+ it('should use 0 decimals as default', () => {
218
+ expect(roundFixed(3.14159)).toBe('3');
219
+ expect(roundFixed(3.9)).toBe('4');
220
+ });
221
+
222
+ it('should handle negative numbers', () => {
223
+ expect(roundFixed(-3.14159, 2)).toBe('-3.14');
224
+ expect(roundFixed(-3.9)).toBe('-4');
225
+ });
226
+
227
+ it('should handle zero', () => {
228
+ expect(roundFixed(0, 2)).toBe('0.00');
229
+ expect(roundFixed('0', 3)).toBe('0.000');
230
+ });
231
+ });
232
+
233
+ describe('round', () => {
234
+ it('should round numbers to specified decimal places as number', () => {
235
+ expect(round(3.14159, 2)).toBe(3.14);
236
+ expect(round(3.14159, 0)).toBe(3);
237
+ expect(round(3.14159, 4)).toBe(3.1416);
238
+ });
239
+
240
+ it('should handle string input', () => {
241
+ expect(round('3.14159', 2)).toBe(3.14);
242
+ expect(round('10', 2)).toBe(10);
243
+ });
244
+
245
+ it('should use 0 decimals as default', () => {
246
+ expect(round(3.14159)).toBe(3);
247
+ expect(round(3.9)).toBe(4);
248
+ });
249
+
250
+ it('should handle negative numbers', () => {
251
+ expect(round(-3.14159, 2)).toBe(-3.14);
252
+ expect(round(-3.9)).toBe(-4);
253
+ });
254
+
255
+ it('should handle zero', () => {
256
+ expect(round(0, 2)).toBe(0);
257
+ expect(round('0', 3)).toBe(0);
258
+ });
259
+
260
+ it('should return number type not string', () => {
261
+ const result = round(3.14159, 2);
262
+ expect(typeof result).toBe('number');
263
+ expect(result).toBe(3.14);
264
+ });
265
+ });
266
+
267
+ describe('downloadFile', () => {
268
+ let mockLink: any;
269
+ let createElementSpy: jest.SpyInstance;
270
+ let appendChildSpy: jest.SpyInstance;
271
+ let removeChildSpy: jest.SpyInstance;
272
+
273
+ beforeEach(() => {
274
+ mockLink = {
275
+ href: '',
276
+ setAttribute: jest.fn(),
277
+ click: jest.fn(),
278
+ };
279
+
280
+ createElementSpy = jest.spyOn(document, 'createElement').mockReturnValue(mockLink);
281
+ appendChildSpy = jest.spyOn(document.body, 'appendChild').mockImplementation();
282
+ removeChildSpy = jest.spyOn(document.body, 'removeChild').mockImplementation();
283
+
284
+ // Mock successful fetch response
285
+ mockFetch.mockResolvedValue({
286
+ ok: true,
287
+ status: 200,
288
+ blob: () => Promise.resolve(new Blob(['test content'], { type: 'text/plain' })),
289
+ });
290
+ });
291
+
292
+ afterEach(() => {
293
+ createElementSpy.mockRestore();
294
+ appendChildSpy.mockRestore();
295
+ removeChildSpy.mockRestore();
296
+ mockFetch.mockClear();
297
+ });
298
+
299
+ it('should download file using default fetch', async () => {
300
+ await downloadFile('http://example.com/file.txt', 'download.txt');
301
+
302
+ expect(mockFetch).toHaveBeenCalledWith('http://example.com/file.txt', {
303
+ method: 'GET',
304
+ headers: {},
305
+ });
306
+
307
+ expect(window.URL.createObjectURL).toHaveBeenCalled();
308
+ expect(createElementSpy).toHaveBeenCalledWith('a');
309
+ expect(mockLink.setAttribute).toHaveBeenCalledWith('download', 'download.txt');
310
+ expect(appendChildSpy).toHaveBeenCalledWith(mockLink);
311
+ expect(mockLink.click).toHaveBeenCalled();
312
+ expect(removeChildSpy).toHaveBeenCalledWith(mockLink);
313
+ expect(window.URL.revokeObjectURL).toHaveBeenCalledWith('mock-blob-url');
314
+ });
315
+
316
+ it('should download file using custom fetch function', async () => {
317
+ const customFetch = jest.fn().mockResolvedValue({
318
+ ok: true,
319
+ status: 200,
320
+ blob: () => Promise.resolve(new Blob(['custom content'], { type: 'text/plain' })),
321
+ });
322
+
323
+ await downloadFile('http://example.com/file.txt', 'download.txt', {
324
+ fetchFn: customFetch
325
+ });
326
+
327
+ expect(customFetch).toHaveBeenCalledWith('http://example.com/file.txt', {
328
+ method: 'GET',
329
+ headers: {},
330
+ });
331
+ });
332
+
333
+ it('should set correct href and download attributes', async () => {
334
+ await downloadFile('http://example.com/test.pdf', 'my-file.pdf');
335
+
336
+ expect(mockLink.href).toBe('mock-blob-url');
337
+ expect(mockLink.setAttribute).toHaveBeenCalledWith('download', 'my-file.pdf');
338
+ });
339
+
340
+ it('should handle fetch errors', async () => {
341
+ const error = new Error('Network error');
342
+ mockFetch.mockRejectedValue(error);
343
+
344
+ await expect(
345
+ downloadFile('http://example.com/file.txt', 'download.txt')
346
+ ).rejects.toThrow('Network error');
347
+ });
348
+
349
+ it('should create blob URL and trigger download', async () => {
350
+ const mockBlob = new Blob(['test content'], { type: 'application/pdf' });
351
+ mockFetch.mockResolvedValue({
352
+ ok: true,
353
+ status: 200,
354
+ blob: () => Promise.resolve(mockBlob),
355
+ });
356
+
357
+ await downloadFile('http://example.com/document.pdf', 'document.pdf');
358
+
359
+ expect(window.URL.createObjectURL).toHaveBeenCalledWith(mockBlob);
360
+ expect(mockLink.href).toBe('mock-blob-url');
361
+ expect(mockLink.click).toHaveBeenCalled();
362
+ });
363
+
364
+ it('should handle HTTP errors', async () => {
365
+ mockFetch.mockResolvedValue({
366
+ ok: false,
367
+ status: 404,
368
+ blob: () => Promise.resolve(new Blob()),
369
+ });
370
+
371
+ await expect(
372
+ downloadFile('http://example.com/file.txt', 'download.txt')
373
+ ).rejects.toThrow('HTTP error! status: 404');
374
+ });
375
+
376
+ it('should use custom headers when provided', async () => {
377
+ await downloadFile('http://example.com/file.txt', 'download.txt', {
378
+ headers: { 'Authorization': 'Bearer token123' }
379
+ });
380
+
381
+ expect(mockFetch).toHaveBeenCalledWith('http://example.com/file.txt', {
382
+ method: 'GET',
383
+ headers: { 'Authorization': 'Bearer token123' },
384
+ });
385
+ });
386
+ });
387
+
388
+ describe('Edge Cases and Error Handling', () => {
389
+ it('should handle null and undefined inputs gracefully', () => {
390
+ expect(isEmpty(null)).toBe(true);
391
+ expect(isEmpty(undefined)).toBe(true);
392
+
393
+ expect(snakeToCamelCase('')).toBe('');
394
+ expect(camelToSnakeCase('')).toBe('');
395
+ expect(pluralToSingle('')).toBe('');
396
+ });
397
+
398
+ it('should handle special characters in string conversions', () => {
399
+ expect(snakeToCamelCase('test_with_123')).toBe('testWith123');
400
+ expect(snakeToCamelCase('test-with-special')).toBe('testWithSpecial');
401
+ expect(camelToSnakeCase('testWith123')).toBe('TEST_WITH_123');
402
+ });
403
+
404
+ it('should handle floating point precision', () => {
405
+ expect(round(0.1 + 0.2, 1)).toBe(0.3);
406
+ expect(roundFixed(0.1 + 0.2, 1)).toBe('0.3');
407
+ });
408
+ });
409
+
410
+ describe('Function Export Verification', () => {
411
+ it('should export all utility functions', () => {
412
+ expect(typeof isEmpty).toBe('function');
413
+ expect(typeof snakeToCamelCase).toBe('function');
414
+ expect(typeof camelToSnakeCase).toBe('function');
415
+ expect(typeof pluralToSingle).toBe('function');
416
+ expect(typeof arrayToObject).toBe('function');
417
+ expect(typeof roundFixed).toBe('function');
418
+ expect(typeof round).toBe('function');
419
+ expect(typeof downloadFile).toBe('function');
420
+ });
421
+ });
422
+ });
@@ -0,0 +1,92 @@
1
+ import React from 'react';
2
+ import { useForm } from './FormProvider';
3
+ import { FormValue } from './FormFields';
4
+ import { FormInput, FormInputProps } from './fields/FormInput';
5
+
6
+ // FormField is now just a convenience wrapper around FormInput
7
+ export interface FormFieldProps extends FormInputProps {
8
+ children?: React.ReactNode;
9
+ }
10
+
11
+ export const FormField = ({ children, ...props }: FormFieldProps) => {
12
+ const { formFields, hasProvider } = useForm();
13
+
14
+ if (!hasProvider || !formFields) {
15
+ console.error('FormField must be used within a FormProvider');
16
+ return null;
17
+ }
18
+
19
+ const fieldConfig = formFields[props.name];
20
+ if (!fieldConfig) {
21
+ console.error(`FormField: No field configuration found for "${props.name}"`);
22
+ return null;
23
+ }
24
+
25
+ // If children are provided, render a custom wrapper
26
+ if (children) {
27
+ return (
28
+ <div className="form-group">
29
+ {children}
30
+ </div>
31
+ );
32
+ }
33
+
34
+ // Otherwise, render as FormInput
35
+ return <FormInput {...props} />;
36
+ };
37
+
38
+ // Hook to get form field value and setter for a specific field
39
+ export const useFormField = (componentProps: { name: string; label?: any; required?: boolean; [key: string]: any }) => {
40
+ const { name, label: propLabel, required: propRequired, ...htmlProps } = componentProps;
41
+
42
+ const {
43
+ getValue,
44
+ setValue,
45
+ formFields,
46
+ validationErrors,
47
+ pristine,
48
+ validated,
49
+ submit,
50
+ hasProvider
51
+ } = useForm();
52
+
53
+ if (!hasProvider) {
54
+ console.error('useFormField must be used within a FormProvider');
55
+ return {
56
+ value: '',
57
+ onChange: () => {},
58
+ isInvalid: false,
59
+ error: null,
60
+ label: undefined,
61
+ required: false,
62
+ mergedProps: {},
63
+ submit: () => {},
64
+ };
65
+ }
66
+
67
+ const fieldConfig = formFields[name];
68
+ if (!fieldConfig) {
69
+ console.error(`useFormField: No field configuration found for "${name}"`);
70
+ }
71
+
72
+ const isInvalid = !pristine && !validated && !!validationErrors[name];
73
+ const error = isInvalid ? validationErrors[name] : null;
74
+
75
+ // Priority order: component props > config props > defaults
76
+ const label = propLabel !== undefined ? propLabel : fieldConfig?.label;
77
+ const required = propRequired !== undefined ? propRequired : fieldConfig?.required;
78
+
79
+ // Merge props: htmlProps override config.formProps
80
+ const mergedProps = { ...fieldConfig?.formProps, ...htmlProps };
81
+
82
+ return {
83
+ value: getValue(name),
84
+ onChange: (value: FormValue) => setValue(name, value),
85
+ isInvalid,
86
+ error,
87
+ label,
88
+ required,
89
+ mergedProps,
90
+ submit,
91
+ };
92
+ };