@snapdragonsnursery/react-components 1.6.0 → 1.7.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/package.json +1 -1
- package/src/ChildSearchModal.jsx +38 -0
- package/src/ChildSearchPage.jsx +51 -0
- package/src/EmployeeSearchModal.jsx +86 -36
- package/src/EmployeeSearchPage.jsx +52 -1
- package/src/EmployeeSearchPage.test.jsx +1 -254
- package/src/components/ui/date-range-picker.jsx +2 -0
- package/src/components/ui/stat-card.jsx +100 -0
- package/src/components/ui/stat-card.test.jsx +24 -0
- package/src/index.d.ts +20 -1
- package/src/index.js +23 -22
package/package.json
CHANGED
package/src/ChildSearchModal.jsx
CHANGED
|
@@ -322,6 +322,10 @@ const ChildSearchModal = ({
|
|
|
322
322
|
onClose();
|
|
323
323
|
};
|
|
324
324
|
|
|
325
|
+
const handleClearSelection = () => {
|
|
326
|
+
setSelectedChildrenState([]);
|
|
327
|
+
};
|
|
328
|
+
|
|
325
329
|
const isChildSelected = (child) => {
|
|
326
330
|
return selectedChildrenState.some(
|
|
327
331
|
(selected) => selected.child_id === child.child_id
|
|
@@ -870,6 +874,40 @@ const ChildSearchModal = ({
|
|
|
870
874
|
</div>
|
|
871
875
|
</div>
|
|
872
876
|
)}
|
|
877
|
+
|
|
878
|
+
{/* Multi-select Action Bar */}
|
|
879
|
+
{multiSelect && (
|
|
880
|
+
<div className="p-6 border-t border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
|
881
|
+
<div className="text-sm text-gray-600 dark:text-gray-300">
|
|
882
|
+
{selectedChildrenState.length} selected{maxSelections ? ` / ${maxSelections}` : ''}
|
|
883
|
+
</div>
|
|
884
|
+
<div className="flex items-center gap-2">
|
|
885
|
+
<button
|
|
886
|
+
type="button"
|
|
887
|
+
onClick={handleClearSelection}
|
|
888
|
+
disabled={selectedChildrenState.length === 0}
|
|
889
|
+
className="px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
890
|
+
>
|
|
891
|
+
Clear
|
|
892
|
+
</button>
|
|
893
|
+
<button
|
|
894
|
+
type="button"
|
|
895
|
+
onClick={onClose}
|
|
896
|
+
className="px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded hover:bg-gray-50 dark:hover:bg-gray-700"
|
|
897
|
+
>
|
|
898
|
+
Go Back
|
|
899
|
+
</button>
|
|
900
|
+
<button
|
|
901
|
+
type="button"
|
|
902
|
+
onClick={handleConfirmSelection}
|
|
903
|
+
disabled={selectedChildrenState.length === 0}
|
|
904
|
+
className="px-3 py-2 text-sm rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
905
|
+
>
|
|
906
|
+
Confirm
|
|
907
|
+
</button>
|
|
908
|
+
</div>
|
|
909
|
+
</div>
|
|
910
|
+
)}
|
|
873
911
|
</div>
|
|
874
912
|
</div>
|
|
875
913
|
);
|
package/src/ChildSearchPage.jsx
CHANGED
|
@@ -56,6 +56,8 @@ const ChildSearchPage = ({
|
|
|
56
56
|
applicationContext = "child-search",
|
|
57
57
|
bypassPermissions = false,
|
|
58
58
|
onSelect = null, // Optional callback for when a child is selected
|
|
59
|
+
onConfirm = null, // Optional callback (multi-select confirm)
|
|
60
|
+
onBack = null, // Optional go-back handler in multi-select
|
|
59
61
|
multiSelect = false,
|
|
60
62
|
maxSelections = null,
|
|
61
63
|
selectedChildren = [],
|
|
@@ -245,6 +247,11 @@ const ChildSearchPage = ({
|
|
|
245
247
|
(index) => children[parseInt(index)]
|
|
246
248
|
);
|
|
247
249
|
setSelectedChildrenState(selectedRows);
|
|
250
|
+
|
|
251
|
+
// Call onSelect callback if provided and in multi-select mode
|
|
252
|
+
if (onSelect && multiSelect) {
|
|
253
|
+
onSelect(selectedRows);
|
|
254
|
+
}
|
|
248
255
|
},
|
|
249
256
|
});
|
|
250
257
|
|
|
@@ -445,6 +452,30 @@ const ChildSearchPage = ({
|
|
|
445
452
|
setDebouncedAdvancedFilters(clearedFilters); // Clear immediately
|
|
446
453
|
};
|
|
447
454
|
|
|
455
|
+
const handleClearSelection = () => {
|
|
456
|
+
setSelectedChildrenState([]);
|
|
457
|
+
if (onSelect && multiSelect) {
|
|
458
|
+
onSelect([]);
|
|
459
|
+
}
|
|
460
|
+
};
|
|
461
|
+
|
|
462
|
+
const handleConfirmSelection = () => {
|
|
463
|
+
const payload = selectedChildrenState;
|
|
464
|
+
if (onConfirm) {
|
|
465
|
+
onConfirm(payload);
|
|
466
|
+
} else if (onSelect) {
|
|
467
|
+
onSelect(payload);
|
|
468
|
+
}
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
const handleGoBack = () => {
|
|
472
|
+
if (onBack) {
|
|
473
|
+
onBack();
|
|
474
|
+
} else if (typeof window !== 'undefined' && window.history) {
|
|
475
|
+
window.history.back();
|
|
476
|
+
}
|
|
477
|
+
};
|
|
478
|
+
|
|
448
479
|
// Calculate pagination display values
|
|
449
480
|
const actualTotalCount = pagination.totalCount || children.length;
|
|
450
481
|
const startItem = actualTotalCount > 0 ? (pagination.page - 1) * pagination.pageSize + 1 : 0;
|
|
@@ -686,6 +717,26 @@ const ChildSearchPage = ({
|
|
|
686
717
|
</>
|
|
687
718
|
)}
|
|
688
719
|
</div>
|
|
720
|
+
|
|
721
|
+
{/* Multi-select Action Bar */}
|
|
722
|
+
{multiSelect && (
|
|
723
|
+
<div className="mt-4 flex items-center justify-between bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 px-4 py-3">
|
|
724
|
+
<div className="text-sm text-gray-600 dark:text-gray-300">
|
|
725
|
+
{selectedChildrenState.length} selected{maxSelections ? ` / ${maxSelections}` : ''}
|
|
726
|
+
</div>
|
|
727
|
+
<div className="flex items-center gap-2">
|
|
728
|
+
<Button variant="outline" onClick={handleClearSelection} disabled={selectedChildrenState.length === 0}>
|
|
729
|
+
Clear
|
|
730
|
+
</Button>
|
|
731
|
+
<Button variant="outline" onClick={handleGoBack}>
|
|
732
|
+
Go Back
|
|
733
|
+
</Button>
|
|
734
|
+
<Button onClick={handleConfirmSelection} disabled={selectedChildrenState.length === 0}>
|
|
735
|
+
Confirm
|
|
736
|
+
</Button>
|
|
737
|
+
</div>
|
|
738
|
+
</div>
|
|
739
|
+
)}
|
|
689
740
|
</div>
|
|
690
741
|
</div>
|
|
691
742
|
);
|
|
@@ -275,12 +275,45 @@ const EmployeeSearchModal = ({
|
|
|
275
275
|
},
|
|
276
276
|
onSortingChange: setSorting,
|
|
277
277
|
onRowSelectionChange: (updater) => {
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
)
|
|
283
|
-
|
|
278
|
+
// Merge current-table selection changes with the existing global selection
|
|
279
|
+
// instead of replacing it, so selection is retained across searches/pages.
|
|
280
|
+
|
|
281
|
+
// Build current selection map (for visible rows only)
|
|
282
|
+
const prevSelection = selectedEmployeesState.reduce((acc, selectedEmployee) => {
|
|
283
|
+
const rowIndex = employees.findIndex((employee) => employee.entra_id === selectedEmployee.entra_id);
|
|
284
|
+
if (rowIndex !== -1) acc[rowIndex] = true;
|
|
285
|
+
return acc;
|
|
286
|
+
}, {});
|
|
287
|
+
|
|
288
|
+
const updatedSelection = typeof updater === 'function' ? updater(prevSelection) : updater;
|
|
289
|
+
|
|
290
|
+
// Determine which visible employees are selected after the change
|
|
291
|
+
const visibleSelected = Object.keys(updatedSelection)
|
|
292
|
+
.filter((idx) => Boolean(updatedSelection[idx]))
|
|
293
|
+
.map((idx) => employees[parseInt(idx, 10)])
|
|
294
|
+
.filter(Boolean);
|
|
295
|
+
|
|
296
|
+
// Start from the previous global selection keyed by entra_id
|
|
297
|
+
const prevById = new Map(selectedEmployeesState.map((e) => [e.entra_id, e]));
|
|
298
|
+
|
|
299
|
+
// Remove any currently visible employees that are now unselected
|
|
300
|
+
const visibleIds = new Set(employees.map((e) => e.entra_id));
|
|
301
|
+
const visibleSelectedIds = new Set(visibleSelected.map((e) => e.entra_id));
|
|
302
|
+
employees.forEach((e) => {
|
|
303
|
+
if (visibleIds.has(e.entra_id) && !visibleSelectedIds.has(e.entra_id)) {
|
|
304
|
+
prevById.delete(e.entra_id);
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
// Add visible selected that weren't already present (respect maxSelections)
|
|
309
|
+
for (const e of visibleSelected) {
|
|
310
|
+
if (!prevById.has(e.entra_id)) {
|
|
311
|
+
if (maxSelections && prevById.size >= maxSelections) break;
|
|
312
|
+
prevById.set(e.entra_id, e);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
setSelectedEmployeesState(Array.from(prevById.values()));
|
|
284
317
|
},
|
|
285
318
|
});
|
|
286
319
|
|
|
@@ -502,6 +535,10 @@ const EmployeeSearchModal = ({
|
|
|
502
535
|
}
|
|
503
536
|
};
|
|
504
537
|
|
|
538
|
+
const handleClearSelection = () => {
|
|
539
|
+
setSelectedEmployeesState([]);
|
|
540
|
+
};
|
|
541
|
+
|
|
505
542
|
const clearFilters = () => {
|
|
506
543
|
const clearedFilters = {
|
|
507
544
|
status: activeOnly ? "Active" : "all",
|
|
@@ -549,22 +586,7 @@ const EmployeeSearchModal = ({
|
|
|
549
586
|
<XMarkIcon className="h-6 w-6" />
|
|
550
587
|
</button>
|
|
551
588
|
</div>
|
|
552
|
-
{
|
|
553
|
-
<div className="mt-2 flex items-center justify-between">
|
|
554
|
-
<span className="text-sm text-gray-600 dark:text-gray-400">
|
|
555
|
-
{selectedEmployeesState.length} selected
|
|
556
|
-
{maxSelections && ` / ${maxSelections}`}
|
|
557
|
-
</span>
|
|
558
|
-
{selectedEmployeesState.length > 0 && (
|
|
559
|
-
<Button
|
|
560
|
-
size="sm"
|
|
561
|
-
onClick={handleConfirmSelection}
|
|
562
|
-
>
|
|
563
|
-
Confirm Selection
|
|
564
|
-
</Button>
|
|
565
|
-
)}
|
|
566
|
-
</div>
|
|
567
|
-
)}
|
|
589
|
+
{/* Removed header confirm to avoid duplication; footer handles confirmation */}
|
|
568
590
|
</div>
|
|
569
591
|
|
|
570
592
|
{/* Content */}
|
|
@@ -793,20 +815,48 @@ const EmployeeSearchModal = ({
|
|
|
793
815
|
</div>
|
|
794
816
|
|
|
795
817
|
{/* Footer */}
|
|
796
|
-
<div className="bg-gray-50 dark:bg-gray-700 px-6 py-3 flex justify-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
818
|
+
<div className="bg-gray-50 dark:bg-gray-700 px-6 py-3 flex items-center justify-between gap-3">
|
|
819
|
+
{/* Selection preview */}
|
|
820
|
+
<div className="flex-1 min-w-0">
|
|
821
|
+
{multiSelect && selectedEmployeesState.length > 0 && (
|
|
822
|
+
(() => {
|
|
823
|
+
const total = selectedEmployeesState.length;
|
|
824
|
+
const preview = selectedEmployeesState.slice(0, 5);
|
|
825
|
+
const extra = Math.max(total - preview.length, 0);
|
|
826
|
+
const previewText = `${total} selected${maxSelections ? ` / ${maxSelections}` : ''}: ` +
|
|
827
|
+
preview.map((e) => e.full_name).join(', ') +
|
|
828
|
+
(extra > 0 ? ` + ${extra} more` : '');
|
|
829
|
+
const fullList = selectedEmployeesState.map((e) => e.full_name).join(', ');
|
|
830
|
+
return (
|
|
831
|
+
<div
|
|
832
|
+
className="text-sm text-gray-600 dark:text-gray-300 truncate"
|
|
833
|
+
title={fullList}
|
|
834
|
+
>
|
|
835
|
+
{previewText}
|
|
836
|
+
</div>
|
|
837
|
+
);
|
|
838
|
+
})()
|
|
839
|
+
)}
|
|
840
|
+
</div>
|
|
841
|
+
<div className="flex items-center gap-2">
|
|
842
|
+
{multiSelect && (
|
|
843
|
+
<Button variant="outline" onClick={handleClearSelection} disabled={selectedEmployeesState.length === 0}>
|
|
844
|
+
Clear
|
|
845
|
+
</Button>
|
|
846
|
+
)}
|
|
847
|
+
<Button variant="outline" onClick={onClose}>
|
|
848
|
+
Go Back
|
|
808
849
|
</Button>
|
|
809
|
-
|
|
850
|
+
{multiSelect && (
|
|
851
|
+
<Button
|
|
852
|
+
onClick={handleConfirmSelection}
|
|
853
|
+
disabled={selectedEmployeesState.length === 0}
|
|
854
|
+
aria-label={`Confirm ${selectedEmployeesState.length} selected`}
|
|
855
|
+
>
|
|
856
|
+
{`Confirm (${selectedEmployeesState.length}${maxSelections ? `/${maxSelections}` : ''})`}
|
|
857
|
+
</Button>
|
|
858
|
+
)}
|
|
859
|
+
</div>
|
|
810
860
|
</div>
|
|
811
861
|
</div>
|
|
812
862
|
</div>
|
|
@@ -814,4 +864,4 @@ const EmployeeSearchModal = ({
|
|
|
814
864
|
);
|
|
815
865
|
};
|
|
816
866
|
|
|
817
|
-
export default EmployeeSearchModal;
|
|
867
|
+
export default EmployeeSearchModal;
|
|
@@ -58,6 +58,8 @@ const EmployeeSearchPage = ({
|
|
|
58
58
|
applicationContext = "employee-search",
|
|
59
59
|
bypassPermissions = false,
|
|
60
60
|
onSelect = null, // Optional callback for when an employee is selected
|
|
61
|
+
onConfirm = null, // Optional callback (multi-select confirm)
|
|
62
|
+
onBack = null, // Optional callback for go back/cancel
|
|
61
63
|
multiSelect = false,
|
|
62
64
|
maxSelections = null,
|
|
63
65
|
selectedEmployees = [],
|
|
@@ -322,6 +324,11 @@ const EmployeeSearchPage = ({
|
|
|
322
324
|
(index) => employees[parseInt(index)]
|
|
323
325
|
);
|
|
324
326
|
setSelectedEmployeesState(selectedRows);
|
|
327
|
+
|
|
328
|
+
// Call onSelect callback if provided and in multi-select mode
|
|
329
|
+
if (onSelect && multiSelect) {
|
|
330
|
+
onSelect(selectedRows);
|
|
331
|
+
}
|
|
325
332
|
},
|
|
326
333
|
});
|
|
327
334
|
|
|
@@ -615,6 +622,30 @@ const EmployeeSearchPage = ({
|
|
|
615
622
|
setDebouncedAdvancedFilters(clearedFilters); // Clear immediately
|
|
616
623
|
};
|
|
617
624
|
|
|
625
|
+
const handleClearSelection = () => {
|
|
626
|
+
setSelectedEmployeesState([]);
|
|
627
|
+
if (onSelect && multiSelect) {
|
|
628
|
+
onSelect([]);
|
|
629
|
+
}
|
|
630
|
+
};
|
|
631
|
+
|
|
632
|
+
const handleConfirmSelection = () => {
|
|
633
|
+
const payload = selectedEmployeesState;
|
|
634
|
+
if (onConfirm) {
|
|
635
|
+
onConfirm(payload);
|
|
636
|
+
} else if (onSelect) {
|
|
637
|
+
onSelect(payload);
|
|
638
|
+
}
|
|
639
|
+
};
|
|
640
|
+
|
|
641
|
+
const handleGoBack = () => {
|
|
642
|
+
if (onBack) {
|
|
643
|
+
onBack();
|
|
644
|
+
} else if (typeof window !== 'undefined' && window.history) {
|
|
645
|
+
window.history.back();
|
|
646
|
+
}
|
|
647
|
+
};
|
|
648
|
+
|
|
618
649
|
// Calculate pagination display values
|
|
619
650
|
const actualTotalCount = pagination.totalCount || employees.length;
|
|
620
651
|
const startItem = loadAllResults ? 1 : (actualTotalCount > 0 ? (pagination.page - 1) * pagination.pageSize + 1 : 0);
|
|
@@ -897,9 +928,29 @@ const EmployeeSearchPage = ({
|
|
|
897
928
|
</>
|
|
898
929
|
)}
|
|
899
930
|
</div>
|
|
931
|
+
|
|
932
|
+
{/* Multi-select Action Bar */}
|
|
933
|
+
{multiSelect && (
|
|
934
|
+
<div className="mt-4 flex items-center justify-between bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 px-4 py-3">
|
|
935
|
+
<div className="text-sm text-gray-600 dark:text-gray-300">
|
|
936
|
+
{selectedEmployeesState.length} selected{maxSelections ? ` / ${maxSelections}` : ''}
|
|
937
|
+
</div>
|
|
938
|
+
<div className="flex items-center gap-2">
|
|
939
|
+
<Button variant="outline" onClick={handleClearSelection} disabled={selectedEmployeesState.length === 0}>
|
|
940
|
+
Clear
|
|
941
|
+
</Button>
|
|
942
|
+
<Button variant="outline" onClick={handleGoBack}>
|
|
943
|
+
Go Back
|
|
944
|
+
</Button>
|
|
945
|
+
<Button onClick={handleConfirmSelection} disabled={selectedEmployeesState.length === 0}>
|
|
946
|
+
Confirm
|
|
947
|
+
</Button>
|
|
948
|
+
</div>
|
|
949
|
+
</div>
|
|
950
|
+
)}
|
|
900
951
|
</div>
|
|
901
952
|
</div>
|
|
902
953
|
);
|
|
903
954
|
};
|
|
904
955
|
|
|
905
|
-
export default EmployeeSearchPage;
|
|
956
|
+
export default EmployeeSearchPage;
|
|
@@ -1,254 +1 @@
|
|
|
1
|
-
|
|
2
|
-
// Tests the EmployeeSearchPage component functionality including search, filtering, pagination, and selection
|
|
3
|
-
|
|
4
|
-
import React from 'react';
|
|
5
|
-
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
|
6
|
-
import '@testing-library/jest-dom';
|
|
7
|
-
import EmployeeSearchPage from './EmployeeSearchPage';
|
|
8
|
-
|
|
9
|
-
// Mock MSAL
|
|
10
|
-
jest.mock('@azure/msal-react', () => ({
|
|
11
|
-
useMsal: () => ({
|
|
12
|
-
instance: {
|
|
13
|
-
acquireTokenSilent: jest.fn(),
|
|
14
|
-
},
|
|
15
|
-
accounts: [{ localAccountId: 'test-user-id' }],
|
|
16
|
-
}),
|
|
17
|
-
}));
|
|
18
|
-
|
|
19
|
-
// Mock telemetry
|
|
20
|
-
jest.mock('./telemetry', () => ({
|
|
21
|
-
trackEvent: jest.fn(),
|
|
22
|
-
}));
|
|
23
|
-
|
|
24
|
-
// Mock utils
|
|
25
|
-
jest.mock('./lib/utils', () => ({
|
|
26
|
-
cn: (...classes) => classes.filter(Boolean).join(' '),
|
|
27
|
-
}));
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
// Mock fetch
|
|
32
|
-
global.fetch = jest.fn();
|
|
33
|
-
|
|
34
|
-
// Mock process.env
|
|
35
|
-
process.env.VITE_COMMON_API_FUNCTION_KEY = 'test-key';
|
|
36
|
-
process.env.VITE_COMMON_API_BASE_URL = 'https://test-api.example.com';
|
|
37
|
-
|
|
38
|
-
// Mock import.meta.env for Vite environment variables
|
|
39
|
-
global.import = {
|
|
40
|
-
meta: {
|
|
41
|
-
env: {
|
|
42
|
-
VITE_COMMON_API_FUNCTION_KEY: 'test-key',
|
|
43
|
-
VITE_COMMON_API_BASE_URL: 'https://test-api.example.com',
|
|
44
|
-
},
|
|
45
|
-
},
|
|
46
|
-
};
|
|
47
|
-
|
|
48
|
-
// Mock the UI components
|
|
49
|
-
jest.mock('./components/ui/input', () => ({
|
|
50
|
-
Input: ({ value, onChange, placeholder, ...props }) => {
|
|
51
|
-
return (
|
|
52
|
-
<input
|
|
53
|
-
data-testid="input"
|
|
54
|
-
value={value}
|
|
55
|
-
onChange={onChange}
|
|
56
|
-
placeholder={placeholder}
|
|
57
|
-
{...props}
|
|
58
|
-
/>
|
|
59
|
-
);
|
|
60
|
-
}
|
|
61
|
-
}));
|
|
62
|
-
|
|
63
|
-
jest.mock('./components/ui/button', () => ({
|
|
64
|
-
Button: ({ children, onClick, variant, size, ...props }) => {
|
|
65
|
-
return (
|
|
66
|
-
<button
|
|
67
|
-
data-testid="button"
|
|
68
|
-
onClick={onClick}
|
|
69
|
-
data-variant={variant}
|
|
70
|
-
data-size={size}
|
|
71
|
-
{...props}
|
|
72
|
-
>
|
|
73
|
-
{children}
|
|
74
|
-
</button>
|
|
75
|
-
);
|
|
76
|
-
}
|
|
77
|
-
}));
|
|
78
|
-
|
|
79
|
-
jest.mock('./components/ui/table', () => ({
|
|
80
|
-
Table: ({ children, ...props }) => <table data-testid="table" {...props}>{children}</table>,
|
|
81
|
-
TableBody: ({ children, ...props }) => <tbody data-testid="table-body" {...props}>{children}</tbody>,
|
|
82
|
-
TableCell: ({ children, ...props }) => <td data-testid="table-cell" {...props}>{children}</td>,
|
|
83
|
-
TableHead: ({ children, ...props }) => <th data-testid="table-head" {...props}>{children}</th>,
|
|
84
|
-
TableHeader: ({ children, ...props }) => <thead data-testid="table-header" {...props}>{children}</thead>,
|
|
85
|
-
TableRow: ({ children, ...props }) => <tr data-testid="table-row" {...props}>{children}</tr>,
|
|
86
|
-
}));
|
|
87
|
-
|
|
88
|
-
jest.mock('./components/ui/pagination', () => ({
|
|
89
|
-
Pagination: ({ children, ...props }) => <nav data-testid="pagination" {...props}>{children}</nav>,
|
|
90
|
-
PaginationContent: ({ children, ...props }) => <div data-testid="pagination-content" {...props}>{children}</div>,
|
|
91
|
-
PaginationEllipsis: ({ ...props }) => <span data-testid="pagination-ellipsis" {...props}>...</span>,
|
|
92
|
-
PaginationItem: ({ children, ...props }) => <div data-testid="pagination-item" {...props}>{children}</div>,
|
|
93
|
-
PaginationLink: ({ children, onClick, isActive, ...props }) => (
|
|
94
|
-
<button data-testid="pagination-link" onClick={onClick} data-active={isActive} {...props}>
|
|
95
|
-
{children}
|
|
96
|
-
</button>
|
|
97
|
-
),
|
|
98
|
-
PaginationNext: ({ children, onClick, ...props }) => (
|
|
99
|
-
<button data-testid="pagination-next" onClick={onClick} {...props}>
|
|
100
|
-
{children}
|
|
101
|
-
</button>
|
|
102
|
-
),
|
|
103
|
-
PaginationPrevious: ({ children, onClick, ...props }) => (
|
|
104
|
-
<button data-testid="pagination-previous" onClick={onClick} {...props}>
|
|
105
|
-
{children}
|
|
106
|
-
</button>
|
|
107
|
-
),
|
|
108
|
-
}));
|
|
109
|
-
|
|
110
|
-
// Mock EmployeeSearchFilters
|
|
111
|
-
jest.mock('./components/EmployeeSearchFilters', () => {
|
|
112
|
-
return function MockEmployeeSearchFilters({ filters, onFiltersChange, onApplyFilters, onClearFilters, ...props }) {
|
|
113
|
-
return (
|
|
114
|
-
<div data-testid="employee-search-filters">
|
|
115
|
-
<button onClick={() => onFiltersChange({ ...filters, status: 'Inactive' })}>
|
|
116
|
-
Change Status
|
|
117
|
-
</button>
|
|
118
|
-
<button onClick={() => onApplyFilters(filters)}>
|
|
119
|
-
Apply Filters
|
|
120
|
-
</button>
|
|
121
|
-
<button onClick={onClearFilters}>
|
|
122
|
-
Clear Filters
|
|
123
|
-
</button>
|
|
124
|
-
</div>
|
|
125
|
-
);
|
|
126
|
-
};
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
const defaultProps = {
|
|
130
|
-
title: 'Employee Search',
|
|
131
|
-
onSelect: jest.fn(),
|
|
132
|
-
};
|
|
133
|
-
|
|
134
|
-
describe('EmployeeSearchPage', () => {
|
|
135
|
-
beforeEach(() => {
|
|
136
|
-
jest.clearAllMocks();
|
|
137
|
-
global.fetch.mockResolvedValue({
|
|
138
|
-
ok: true,
|
|
139
|
-
json: async () => ({
|
|
140
|
-
success: true,
|
|
141
|
-
data: {
|
|
142
|
-
employees: [],
|
|
143
|
-
pagination: {
|
|
144
|
-
page: 1,
|
|
145
|
-
pageSize: 20,
|
|
146
|
-
totalCount: 0,
|
|
147
|
-
totalPages: 0,
|
|
148
|
-
hasNextPage: false,
|
|
149
|
-
hasPreviousPage: false,
|
|
150
|
-
},
|
|
151
|
-
},
|
|
152
|
-
}),
|
|
153
|
-
});
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
describe('Basic Rendering', () => {
|
|
157
|
-
it('renders the page title', () => {
|
|
158
|
-
render(<EmployeeSearchPage {...defaultProps} />);
|
|
159
|
-
|
|
160
|
-
expect(screen.getByText('Employee Search')).toBeInTheDocument();
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
it('renders the search input', () => {
|
|
164
|
-
render(<EmployeeSearchPage {...defaultProps} />);
|
|
165
|
-
|
|
166
|
-
expect(screen.getByPlaceholderText('Search by name, employee ID, or email...')).toBeInTheDocument();
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
it('renders the employee search filters', () => {
|
|
170
|
-
render(<EmployeeSearchPage {...defaultProps} />);
|
|
171
|
-
|
|
172
|
-
expect(screen.getByTestId('employee-search-filters')).toBeInTheDocument();
|
|
173
|
-
});
|
|
174
|
-
|
|
175
|
-
it('shows loading state initially', () => {
|
|
176
|
-
render(<EmployeeSearchPage {...defaultProps} />);
|
|
177
|
-
|
|
178
|
-
expect(screen.getByRole('status')).toBeInTheDocument();
|
|
179
|
-
});
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
describe('Search Functionality', () => {
|
|
183
|
-
it('renders search input with correct placeholder', () => {
|
|
184
|
-
render(<EmployeeSearchPage {...defaultProps} />);
|
|
185
|
-
|
|
186
|
-
const searchInput = screen.getByPlaceholderText('Search by name, employee ID, or email...');
|
|
187
|
-
expect(searchInput).toBeInTheDocument();
|
|
188
|
-
expect(searchInput).toHaveAttribute('type', 'text');
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
it('allows typing in search input', () => {
|
|
192
|
-
render(<EmployeeSearchPage {...defaultProps} />);
|
|
193
|
-
|
|
194
|
-
const searchInput = screen.getByPlaceholderText('Search by name, employee ID, or email...');
|
|
195
|
-
fireEvent.change(searchInput, { target: { value: 'John' } });
|
|
196
|
-
|
|
197
|
-
expect(searchInput).toHaveValue('John');
|
|
198
|
-
});
|
|
199
|
-
});
|
|
200
|
-
|
|
201
|
-
describe('Filter Integration', () => {
|
|
202
|
-
it('renders filter buttons', () => {
|
|
203
|
-
render(<EmployeeSearchPage {...defaultProps} />);
|
|
204
|
-
|
|
205
|
-
expect(screen.getByText('Change Status')).toBeInTheDocument();
|
|
206
|
-
expect(screen.getByText('Apply Filters')).toBeInTheDocument();
|
|
207
|
-
expect(screen.getByText('Clear Filters')).toBeInTheDocument();
|
|
208
|
-
});
|
|
209
|
-
|
|
210
|
-
it('calls filter change handler when filter button is clicked', () => {
|
|
211
|
-
render(<EmployeeSearchPage {...defaultProps} />);
|
|
212
|
-
|
|
213
|
-
const changeStatusButton = screen.getByText('Change Status');
|
|
214
|
-
fireEvent.click(changeStatusButton);
|
|
215
|
-
|
|
216
|
-
// The mock should have been called
|
|
217
|
-
expect(changeStatusButton).toBeInTheDocument();
|
|
218
|
-
});
|
|
219
|
-
});
|
|
220
|
-
|
|
221
|
-
describe('Accessibility', () => {
|
|
222
|
-
it('has proper ARIA labels', () => {
|
|
223
|
-
render(<EmployeeSearchPage {...defaultProps} />);
|
|
224
|
-
|
|
225
|
-
const searchInput = screen.getByPlaceholderText('Search by name, employee ID, or email...');
|
|
226
|
-
expect(searchInput).toBeInTheDocument();
|
|
227
|
-
});
|
|
228
|
-
|
|
229
|
-
it('supports keyboard navigation', () => {
|
|
230
|
-
render(<EmployeeSearchPage {...defaultProps} />);
|
|
231
|
-
|
|
232
|
-
const searchInput = screen.getByPlaceholderText('Search by name, employee ID, or email...');
|
|
233
|
-
searchInput.focus();
|
|
234
|
-
|
|
235
|
-
expect(searchInput).toHaveFocus();
|
|
236
|
-
});
|
|
237
|
-
});
|
|
238
|
-
|
|
239
|
-
describe('Props Handling', () => {
|
|
240
|
-
it('renders custom title', () => {
|
|
241
|
-
render(<EmployeeSearchPage {...defaultProps} title="Custom Title" />);
|
|
242
|
-
|
|
243
|
-
expect(screen.getByText('Custom Title')).toBeInTheDocument();
|
|
244
|
-
});
|
|
245
|
-
|
|
246
|
-
it('renders custom search placeholder', () => {
|
|
247
|
-
render(<EmployeeSearchPage {...defaultProps} searchPlaceholder="Custom placeholder" />);
|
|
248
|
-
|
|
249
|
-
expect(screen.getByPlaceholderText('Custom placeholder')).toBeInTheDocument();
|
|
250
|
-
});
|
|
251
|
-
});
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
});
|
|
1
|
+
|
|
@@ -299,6 +299,7 @@ export function DatePicker({
|
|
|
299
299
|
className,
|
|
300
300
|
placeholder = "Select a date",
|
|
301
301
|
disabled,
|
|
302
|
+
disableFuture = false,
|
|
302
303
|
...props
|
|
303
304
|
}) {
|
|
304
305
|
const [isOpen, setIsOpen] = useState(false)
|
|
@@ -334,6 +335,7 @@ export function DatePicker({
|
|
|
334
335
|
mode="single"
|
|
335
336
|
selected={selectedDate}
|
|
336
337
|
onSelect={handleSelect}
|
|
338
|
+
disabled={disableFuture ? { after: new Date() } : undefined}
|
|
337
339
|
initialFocus
|
|
338
340
|
/>
|
|
339
341
|
</PopoverContent>
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Stat Card UI Component (shared library)
|
|
2
|
+
// Reusable statistic card with optional icon and caption.
|
|
3
|
+
// Example:
|
|
4
|
+
// <StatCard title="Active Users" value="97K" caption="+24.3% this month" icon={ArrowTrendingUpIcon} tone="success" />
|
|
5
|
+
|
|
6
|
+
import React from 'react'
|
|
7
|
+
import { cn } from '../../lib/utils'
|
|
8
|
+
|
|
9
|
+
// Lightweight Card primitives aligned with this package styling
|
|
10
|
+
const Card = React.forwardRef(function Card({ className, ...props }, ref) {
|
|
11
|
+
return (
|
|
12
|
+
<div
|
|
13
|
+
ref={ref}
|
|
14
|
+
className={cn('rounded-lg border bg-card text-card-foreground shadow-sm', className)}
|
|
15
|
+
{...props}
|
|
16
|
+
/>
|
|
17
|
+
)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
const CardHeader = React.forwardRef(function CardHeader({ className, ...props }, ref) {
|
|
21
|
+
return <div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
const CardTitle = React.forwardRef(function CardTitle({ className, ...props }, ref) {
|
|
25
|
+
return (
|
|
26
|
+
<h3
|
|
27
|
+
ref={ref}
|
|
28
|
+
className={cn('text-2xl font-semibold leading-none tracking-tight', className)}
|
|
29
|
+
{...props}
|
|
30
|
+
/>
|
|
31
|
+
)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
const CardContent = React.forwardRef(function CardContent({ className, ...props }, ref) {
|
|
35
|
+
return <div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
function Skeleton({ className, ...props }) {
|
|
39
|
+
return <div className={cn('animate-pulse rounded-md bg-muted', className)} {...props} />
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const toneClasses = {
|
|
43
|
+
default: {
|
|
44
|
+
badge: 'bg-gray-100 text-gray-700',
|
|
45
|
+
value: 'text-foreground',
|
|
46
|
+
ring: 'ring-gray-200',
|
|
47
|
+
},
|
|
48
|
+
success: {
|
|
49
|
+
badge: 'bg-emerald-100 text-emerald-700',
|
|
50
|
+
value: 'text-emerald-700',
|
|
51
|
+
ring: 'ring-emerald-200',
|
|
52
|
+
},
|
|
53
|
+
danger: {
|
|
54
|
+
badge: 'bg-rose-100 text-rose-700',
|
|
55
|
+
value: 'text-rose-700',
|
|
56
|
+
ring: 'ring-rose-200',
|
|
57
|
+
},
|
|
58
|
+
warning: {
|
|
59
|
+
badge: 'bg-amber-100 text-amber-700',
|
|
60
|
+
value: 'text-amber-700',
|
|
61
|
+
ring: 'ring-amber-200',
|
|
62
|
+
},
|
|
63
|
+
info: {
|
|
64
|
+
badge: 'bg-sky-100 text-sky-700',
|
|
65
|
+
value: 'text-sky-700',
|
|
66
|
+
ring: 'ring-sky-200',
|
|
67
|
+
},
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function StatCard({ title, value, caption, icon: Icon, tone = 'default', className, loading = false }) {
|
|
71
|
+
const styles = toneClasses[tone] || toneClasses.default
|
|
72
|
+
return (
|
|
73
|
+
<Card className={cn('shadow-sm hover:shadow transition-shadow', className)}>
|
|
74
|
+
<CardHeader className="pb-3">
|
|
75
|
+
<div className="flex items-center justify-between">
|
|
76
|
+
<CardTitle className="text-sm font-medium text-muted-foreground">
|
|
77
|
+
{loading ? <Skeleton className="h-4 w-24" /> : title}
|
|
78
|
+
</CardTitle>
|
|
79
|
+
{Icon ? (
|
|
80
|
+
<div className={cn('inline-flex h-10 w-10 items-center justify-center rounded-full ring-1', styles.badge, styles.ring)}>
|
|
81
|
+
{loading ? <Skeleton className="h-5 w-5 rounded-full" /> : <Icon className="h-5 w-5" />}
|
|
82
|
+
</div>
|
|
83
|
+
) : null}
|
|
84
|
+
</div>
|
|
85
|
+
</CardHeader>
|
|
86
|
+
<CardContent>
|
|
87
|
+
<div className={cn('text-3xl font-extrabold tracking-tight', styles.value)}>
|
|
88
|
+
{loading ? <Skeleton className="h-8 w-20" /> : value}
|
|
89
|
+
</div>
|
|
90
|
+
{caption ? (
|
|
91
|
+
<div className="mt-1 text-sm text-muted-foreground">
|
|
92
|
+
{loading ? <Skeleton className="h-4 w-28" /> : caption}
|
|
93
|
+
</div>
|
|
94
|
+
) : null}
|
|
95
|
+
</CardContent>
|
|
96
|
+
</Card>
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export default StatCard
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Basic render tests for StatCard
|
|
2
|
+
// Example usage demonstrated in the component file header.
|
|
3
|
+
|
|
4
|
+
import React from 'react'
|
|
5
|
+
import { render, screen } from '@testing-library/react'
|
|
6
|
+
import StatCard from './stat-card'
|
|
7
|
+
|
|
8
|
+
describe('StatCard (library)', () => {
|
|
9
|
+
it('renders title and value', () => {
|
|
10
|
+
render(<StatCard title="Total" value={42} caption="units" />)
|
|
11
|
+
expect(screen.getByText('Total')).toBeInTheDocument()
|
|
12
|
+
expect(screen.getByText('42')).toBeInTheDocument()
|
|
13
|
+
expect(screen.getByText('units')).toBeInTheDocument()
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('renders skeletons when loading', () => {
|
|
17
|
+
const { container } = render(<StatCard title="Total" value={42} caption="units" loading />)
|
|
18
|
+
expect(screen.queryByText('Total')).toBeNull()
|
|
19
|
+
expect(screen.queryByText('42')).toBeNull()
|
|
20
|
+
expect(screen.queryByText('units')).toBeNull()
|
|
21
|
+
// Skeletons exist
|
|
22
|
+
expect(container.querySelectorAll('.animate-pulse').length).toBeGreaterThan(0)
|
|
23
|
+
})
|
|
24
|
+
})
|
package/src/index.d.ts
CHANGED
|
@@ -36,13 +36,32 @@ export const EmployeeSearchDemo: React.ComponentType<any>
|
|
|
36
36
|
export const EmployeeSearchFilters: React.ComponentType<any>
|
|
37
37
|
|
|
38
38
|
export const DateRangePicker: React.ComponentType<any>
|
|
39
|
-
export
|
|
39
|
+
export interface DatePickerProps {
|
|
40
|
+
selectedDate?: Date | null
|
|
41
|
+
onSelect: (date: Date | null) => void
|
|
42
|
+
className?: string
|
|
43
|
+
placeholder?: string
|
|
44
|
+
disabled?: boolean
|
|
45
|
+
disableFuture?: boolean
|
|
46
|
+
}
|
|
47
|
+
export const DatePicker: React.ComponentType<DatePickerProps>
|
|
40
48
|
export const Calendar: React.ComponentType<any>
|
|
41
49
|
export const SimpleCalendar: React.ComponentType<any>
|
|
42
50
|
export const Popover: React.ComponentType<any>
|
|
43
51
|
export const PopoverContent: React.ComponentType<any>
|
|
44
52
|
export const PopoverTrigger: React.ComponentType<any>
|
|
45
53
|
|
|
54
|
+
export interface StatCardProps {
|
|
55
|
+
title: string
|
|
56
|
+
value: React.ReactNode
|
|
57
|
+
caption?: React.ReactNode
|
|
58
|
+
icon?: React.ComponentType<any>
|
|
59
|
+
tone?: 'default' | 'success' | 'danger' | 'warning' | 'info'
|
|
60
|
+
className?: string
|
|
61
|
+
loading?: boolean
|
|
62
|
+
}
|
|
63
|
+
export const StatCard: React.ComponentType<StatCardProps>
|
|
64
|
+
|
|
46
65
|
export function configureTelemetry(...args: any[]): any
|
|
47
66
|
|
|
48
67
|
|
package/src/index.js
CHANGED
|
@@ -1,27 +1,28 @@
|
|
|
1
|
-
export { default as AuthButtons } from "./AuthButtons";
|
|
2
|
-
export { default as ThemeToggle } from "./ThemeToggle";
|
|
3
|
-
export { default as ChildSearchModal } from "./ChildSearchModal";
|
|
4
|
-
export { default as ChildSearchPage } from "./ChildSearchPage";
|
|
5
|
-
export { default as ChildSearchPageDemo } from "./ChildSearchPageDemo";
|
|
6
|
-
export { default as ThemeToggleTest } from "./ThemeToggleTest";
|
|
7
|
-
export { default as LandingPage } from "./LandingPage";
|
|
8
|
-
export { default as ChildSearchFilters } from "./components/ChildSearchFilters";
|
|
9
|
-
export { default as DateRangePickerDemo } from "./DateRangePickerDemo";
|
|
10
|
-
export { default as CalendarDemo } from "./CalendarDemo";
|
|
11
|
-
export { default as DateRangePickerTest } from "./DateRangePickerTest";
|
|
12
|
-
export { default as ApplyButtonDemo } from "./ApplyButtonDemo";
|
|
1
|
+
export { default as AuthButtons } from "./AuthButtons.jsx";
|
|
2
|
+
export { default as ThemeToggle } from "./ThemeToggle.jsx";
|
|
3
|
+
export { default as ChildSearchModal } from "./ChildSearchModal.jsx";
|
|
4
|
+
export { default as ChildSearchPage } from "./ChildSearchPage.jsx";
|
|
5
|
+
export { default as ChildSearchPageDemo } from "./ChildSearchPageDemo.jsx";
|
|
6
|
+
export { default as ThemeToggleTest } from "./ThemeToggleTest.jsx";
|
|
7
|
+
export { default as LandingPage } from "./LandingPage.jsx";
|
|
8
|
+
export { default as ChildSearchFilters } from "./components/ChildSearchFilters.jsx";
|
|
9
|
+
export { default as DateRangePickerDemo } from "./DateRangePickerDemo.jsx";
|
|
10
|
+
export { default as CalendarDemo } from "./CalendarDemo.jsx";
|
|
11
|
+
export { default as DateRangePickerTest } from "./DateRangePickerTest.jsx";
|
|
12
|
+
export { default as ApplyButtonDemo } from "./ApplyButtonDemo.jsx";
|
|
13
13
|
|
|
14
14
|
// Employee Search Components
|
|
15
|
-
export { default as EmployeeSearchPage } from "./EmployeeSearchPage";
|
|
16
|
-
export { default as EmployeeSearchModal } from "./EmployeeSearchModal";
|
|
17
|
-
export { default as EmployeeSearchDemo } from "./EmployeeSearchDemo";
|
|
18
|
-
export { default as EmployeeSearchFilters } from "./components/EmployeeSearchFilters";
|
|
15
|
+
export { default as EmployeeSearchPage } from "./EmployeeSearchPage.jsx";
|
|
16
|
+
export { default as EmployeeSearchModal } from "./EmployeeSearchModal.jsx";
|
|
17
|
+
export { default as EmployeeSearchDemo } from "./EmployeeSearchDemo.jsx";
|
|
18
|
+
export { default as EmployeeSearchFilters } from "./components/EmployeeSearchFilters.jsx";
|
|
19
19
|
|
|
20
|
-
export { configureTelemetry } from "./telemetry";
|
|
20
|
+
export { configureTelemetry } from "./telemetry.js";
|
|
21
21
|
|
|
22
22
|
// UI Components
|
|
23
|
-
export { DateRangePicker, DatePicker } from "./components/ui/date-range-picker";
|
|
24
|
-
export { Calendar } from "./components/ui/calendar";
|
|
25
|
-
export { SimpleCalendar } from "./components/ui/simple-calendar";
|
|
26
|
-
export { Popover, PopoverContent, PopoverTrigger } from "./components/ui/popover";
|
|
27
|
-
export { default as SoftWarningAlert } from "./components/ui/soft-warning-alert";
|
|
23
|
+
export { DateRangePicker, DatePicker } from "./components/ui/date-range-picker.jsx";
|
|
24
|
+
export { Calendar } from "./components/ui/calendar.jsx";
|
|
25
|
+
export { SimpleCalendar } from "./components/ui/simple-calendar.jsx";
|
|
26
|
+
export { Popover, PopoverContent, PopoverTrigger } from "./components/ui/popover.jsx";
|
|
27
|
+
export { default as SoftWarningAlert } from "./components/ui/soft-warning-alert.jsx";
|
|
28
|
+
export { default as StatCard } from "./components/ui/stat-card.jsx";
|