@snapdragonsnursery/react-components 1.5.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/README.md +44 -6
- package/package.json +2 -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 +71 -4
- package/src/components/ui/date-range-picker.test.jsx +19 -1
- package/src/components/ui/soft-warning-alert.jsx +112 -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 +67 -0
- package/src/index.js +23 -21
package/README.md
CHANGED
|
@@ -6,13 +6,14 @@ A collection of reusable React components for Snapdragons Nursery applications.
|
|
|
6
6
|
|
|
7
7
|
- **ChildSearchModal**: Advanced child search and selection component with filtering, pagination, and multi-select capabilities
|
|
8
8
|
- **ChildSearchFilters**: Advanced filtering component with date range picker, status, site, and age filters (includes Apply button for better UX)
|
|
9
|
-
- **DateRangePicker**: Shadcn-style date range picker component
|
|
9
|
+
- **DateRangePicker**: Shadcn-style date range picker component (supports optional presets)
|
|
10
10
|
- **DatePicker**: Shadcn-style single date picker component
|
|
11
11
|
- **Calendar**: Official shadcn calendar component
|
|
12
12
|
- **Popover**: Official shadcn popover component
|
|
13
13
|
- **AuthButtons**: Authentication buttons for MSAL integration
|
|
14
14
|
- **ThemeToggle**: Dark/light theme toggle component
|
|
15
15
|
- **LandingPage**: Landing page component with authentication
|
|
16
|
+
- **SoftWarningAlert**: Soft-styled alert for non-blocking warnings with optional action
|
|
16
17
|
|
|
17
18
|
## Installation
|
|
18
19
|
|
|
@@ -39,6 +40,25 @@ function MyComponent() {
|
|
|
39
40
|
}
|
|
40
41
|
```
|
|
41
42
|
|
|
43
|
+
### SoftWarningAlert Example
|
|
44
|
+
|
|
45
|
+
```jsx
|
|
46
|
+
import { SoftWarningAlert } from '@snapdragonsnursery/react-components';
|
|
47
|
+
import { AlertTriangle } from 'lucide-react';
|
|
48
|
+
|
|
49
|
+
function Notice() {
|
|
50
|
+
return (
|
|
51
|
+
<SoftWarningAlert
|
|
52
|
+
icon={AlertTriangle}
|
|
53
|
+
title="Unsubmitted claims"
|
|
54
|
+
description="You have 3 unsubmitted mileage claims. Create a report to submit."
|
|
55
|
+
actionLabel="Create report"
|
|
56
|
+
onAction={() => console.log('clicked')}
|
|
57
|
+
/>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
42
62
|
## Shadcn Components
|
|
43
63
|
|
|
44
64
|
This package includes official shadcn components with proper styling. The components use shadcn CSS variables, so make sure your consuming project has the shadcn CSS variables defined in your CSS file.
|
|
@@ -118,11 +138,28 @@ function MyComponent() {
|
|
|
118
138
|
};
|
|
119
139
|
|
|
120
140
|
return (
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
141
|
+
<>
|
|
142
|
+
<DateRangePicker
|
|
143
|
+
selectedRange={selectedRange}
|
|
144
|
+
onSelect={handleDateRangeChange}
|
|
145
|
+
placeholder="Select a date range"
|
|
146
|
+
numberOfMonths={2}
|
|
147
|
+
/>
|
|
148
|
+
{/* With presets */}
|
|
149
|
+
<DateRangePicker
|
|
150
|
+
selectedRange={selectedRange}
|
|
151
|
+
onSelect={handleDateRangeChange}
|
|
152
|
+
presetsEnabled
|
|
153
|
+
presets={[
|
|
154
|
+
{ key: 'thisWeek', label: 'This week', getRange: () => ({ from: startOfWeek(new Date(), { weekStartsOn: 1 }), to: new Date() }) },
|
|
155
|
+
{ key: 'lastWeek', label: 'Last week', getRange: () => { const ref = subWeeks(new Date(), 1); return { from: startOfWeek(ref, { weekStartsOn: 1 }), to: endOfWeek(ref, { weekStartsOn: 1 }) } } },
|
|
156
|
+
{ key: 'thisMonth', label: 'This month', getRange: () => ({ from: startOfMonth(new Date()), to: new Date() }) },
|
|
157
|
+
{ key: 'lastMonth', label: 'Last month', getRange: () => { const ref = subMonths(new Date(), 1); return { from: startOfMonth(ref), to: endOfMonth(ref) } } },
|
|
158
|
+
{ key: 'thisYear', label: 'This year', getRange: () => ({ from: startOfYear(new Date()), to: new Date() }) },
|
|
159
|
+
{ key: 'lastYear', label: 'Last year', getRange: () => { const ref = subYears(new Date(), 1); return { from: startOfYear(ref), to: endOfYear(ref) } } },
|
|
160
|
+
]}
|
|
161
|
+
/>
|
|
162
|
+
</>
|
|
126
163
|
);
|
|
127
164
|
}
|
|
128
165
|
```
|
|
@@ -162,6 +199,7 @@ VITE_COMMON_API_BASE_URL=https://snaps-common-api.azurewebsites.net
|
|
|
162
199
|
- [ChildSearchModal Documentation](./CHILD_SEARCH_MODAL_DOCUMENTATION.md)
|
|
163
200
|
- [ChildSearchModal README](./CHILD_SEARCH_README.md)
|
|
164
201
|
- [Release Guide](./RELEASE.md)
|
|
202
|
+
- [SoftWarningAlert](./SOFT_WARNING_ALERT.md)
|
|
165
203
|
|
|
166
204
|
---
|
|
167
205
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@snapdragonsnursery/react-components",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.0",
|
|
4
4
|
"description": "",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -59,6 +59,7 @@
|
|
|
59
59
|
"react": "^18.0.0 || ^19.0.0"
|
|
60
60
|
},
|
|
61
61
|
"module": "src/index.js",
|
|
62
|
+
"types": "src/index.d.ts",
|
|
62
63
|
"files": [
|
|
63
64
|
"src",
|
|
64
65
|
"src/index.css"
|
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
|
+
|
|
@@ -4,19 +4,28 @@
|
|
|
4
4
|
// Usage: <DateRangePicker selectedRange={range} onSelect={setRange} />
|
|
5
5
|
|
|
6
6
|
import React, { useState, useEffect, useRef } from 'react'
|
|
7
|
-
import { format } from 'date-fns'
|
|
7
|
+
import { format, addDays, addMonths, endOfMonth, endOfYear, startOfMonth, startOfYear, subDays, subMonths, subYears } from 'date-fns'
|
|
8
8
|
import { CalendarIcon } from '@heroicons/react/24/outline'
|
|
9
9
|
import { Popover, PopoverContent, PopoverTrigger } from './popover'
|
|
10
10
|
import { Calendar } from './calendar'
|
|
11
11
|
import { Button } from './button'
|
|
12
12
|
import { cn } from '../../lib/utils'
|
|
13
13
|
|
|
14
|
+
// Optional preset type: { key: string, label: string, getRange: () => ({ from: Date, to: Date }) }
|
|
14
15
|
export function DateRangePicker({
|
|
15
16
|
selectedRange,
|
|
16
17
|
onSelect,
|
|
17
18
|
className,
|
|
18
19
|
placeholder = "Select a date range",
|
|
19
20
|
disabled,
|
|
21
|
+
// New: optional presets support (backwards compatible)
|
|
22
|
+
presetsEnabled = false,
|
|
23
|
+
presets,
|
|
24
|
+
// New: allow consumers to control month count
|
|
25
|
+
numberOfMonths = 2,
|
|
26
|
+
// New: allow styling popover content and calendar for layout control
|
|
27
|
+
contentClassName,
|
|
28
|
+
calendarClassName,
|
|
20
29
|
...props
|
|
21
30
|
}) {
|
|
22
31
|
const [isOpen, setIsOpen] = useState(false)
|
|
@@ -185,8 +194,8 @@ export function DateRangePicker({
|
|
|
185
194
|
)}
|
|
186
195
|
</Button>
|
|
187
196
|
</PopoverTrigger>
|
|
188
|
-
<PopoverContent className=
|
|
189
|
-
<div className="relative">
|
|
197
|
+
<PopoverContent className={cn('w-[90vw] p-3 sm:w-auto', contentClassName)} align="start">
|
|
198
|
+
<div className="relative w-full sm:w-[520px]">
|
|
190
199
|
<Calendar
|
|
191
200
|
mode="range"
|
|
192
201
|
defaultMonth={internalRange?.from}
|
|
@@ -197,8 +206,47 @@ export function DateRangePicker({
|
|
|
197
206
|
handleSelect(range)
|
|
198
207
|
}
|
|
199
208
|
}}
|
|
200
|
-
numberOfMonths={
|
|
209
|
+
numberOfMonths={numberOfMonths}
|
|
210
|
+
className={cn('w-full', calendarClassName)}
|
|
201
211
|
/>
|
|
212
|
+
{presetsEnabled && (
|
|
213
|
+
<div className="mt-2 flex flex-wrap gap-2 p-2">
|
|
214
|
+
{(presets && presets.length > 0 ? presets : defaultPresets()).map((p) => {
|
|
215
|
+
const r = p.getRange()
|
|
216
|
+
return (
|
|
217
|
+
<Button
|
|
218
|
+
key={p.key}
|
|
219
|
+
variant="outline"
|
|
220
|
+
size="sm"
|
|
221
|
+
onClick={() => {
|
|
222
|
+
setInternalRange(r)
|
|
223
|
+
isSelectingRange.current = false
|
|
224
|
+
onSelect(r)
|
|
225
|
+
}}
|
|
226
|
+
>
|
|
227
|
+
{p.label}
|
|
228
|
+
</Button>
|
|
229
|
+
)
|
|
230
|
+
})}
|
|
231
|
+
</div>
|
|
232
|
+
)}
|
|
233
|
+
{/* Always-present footer actions for clarity on mobile/desktop */}
|
|
234
|
+
<div className="mt-3 flex items-center justify-between px-2">
|
|
235
|
+
<Button
|
|
236
|
+
variant="ghost"
|
|
237
|
+
size="sm"
|
|
238
|
+
onClick={() => {
|
|
239
|
+
setInternalRange(null)
|
|
240
|
+
isSelectingRange.current = false
|
|
241
|
+
onSelect(null)
|
|
242
|
+
}}
|
|
243
|
+
>
|
|
244
|
+
Clear
|
|
245
|
+
</Button>
|
|
246
|
+
<Button size="sm" onClick={() => setIsOpen(false)}>
|
|
247
|
+
Done
|
|
248
|
+
</Button>
|
|
249
|
+
</div>
|
|
202
250
|
{internalRange?.from && (
|
|
203
251
|
<div className="absolute top-2 right-2 flex gap-1">
|
|
204
252
|
<button
|
|
@@ -227,6 +275,23 @@ export function DateRangePicker({
|
|
|
227
275
|
)
|
|
228
276
|
}
|
|
229
277
|
|
|
278
|
+
// Provide a default preset set similar to app usage, but minimal
|
|
279
|
+
function defaultPresets() {
|
|
280
|
+
const today = new Date()
|
|
281
|
+
const last7Days = { from: subDays(today, 6), to: today }
|
|
282
|
+
const monthToDate = { from: startOfMonth(today), to: today }
|
|
283
|
+
const yearToDate = { from: startOfYear(today), to: today }
|
|
284
|
+
const lastMonth = { from: startOfMonth(subMonths(today, 1)), to: endOfMonth(subMonths(today, 1)) }
|
|
285
|
+
const lastYear = { from: startOfYear(subYears(today, 1)), to: endOfYear(subYears(today, 1)) }
|
|
286
|
+
return [
|
|
287
|
+
{ key: 'last7', label: 'Last 7 days', getRange: () => last7Days },
|
|
288
|
+
{ key: 'mtd', label: 'Month to date', getRange: () => monthToDate },
|
|
289
|
+
{ key: 'lastMonth', label: 'Last month', getRange: () => lastMonth },
|
|
290
|
+
{ key: 'ytd', label: 'Year to date', getRange: () => yearToDate },
|
|
291
|
+
{ key: 'lastYear', label: 'Last year', getRange: () => lastYear },
|
|
292
|
+
]
|
|
293
|
+
}
|
|
294
|
+
|
|
230
295
|
// Single date picker variant
|
|
231
296
|
export function DatePicker({
|
|
232
297
|
selectedDate,
|
|
@@ -234,6 +299,7 @@ export function DatePicker({
|
|
|
234
299
|
className,
|
|
235
300
|
placeholder = "Select a date",
|
|
236
301
|
disabled,
|
|
302
|
+
disableFuture = false,
|
|
237
303
|
...props
|
|
238
304
|
}) {
|
|
239
305
|
const [isOpen, setIsOpen] = useState(false)
|
|
@@ -269,6 +335,7 @@ export function DatePicker({
|
|
|
269
335
|
mode="single"
|
|
270
336
|
selected={selectedDate}
|
|
271
337
|
onSelect={handleSelect}
|
|
338
|
+
disabled={disableFuture ? { after: new Date() } : undefined}
|
|
272
339
|
initialFocus
|
|
273
340
|
/>
|
|
274
341
|
</PopoverContent>
|
|
@@ -38,7 +38,7 @@ describe('DateRangePicker', () => {
|
|
|
38
38
|
fireEvent.click(button)
|
|
39
39
|
|
|
40
40
|
await waitFor(() => {
|
|
41
|
-
expect(screen.getAllByRole('grid')).toHaveLength(2) // Two months
|
|
41
|
+
expect(screen.getAllByRole('grid')).toHaveLength(2) // Two months default
|
|
42
42
|
})
|
|
43
43
|
})
|
|
44
44
|
|
|
@@ -64,6 +64,24 @@ describe('DateRangePicker', () => {
|
|
|
64
64
|
})
|
|
65
65
|
})
|
|
66
66
|
|
|
67
|
+
describe('DateRangePicker presets', () => {
|
|
68
|
+
it('renders presets when enabled and selects a range on click', async () => {
|
|
69
|
+
const onSelect = jest.fn()
|
|
70
|
+
render(<DateRangePicker selectedRange={null} onSelect={onSelect} presetsEnabled numberOfMonths={1} />)
|
|
71
|
+
// Open popover
|
|
72
|
+
fireEvent.click(screen.getByRole('button'))
|
|
73
|
+
// Default preset button e.g. Last 7 days should appear
|
|
74
|
+
await waitFor(() => {
|
|
75
|
+
expect(screen.getByText(/last 7 days/i)).toBeInTheDocument()
|
|
76
|
+
})
|
|
77
|
+
fireEvent.click(screen.getByText(/last 7 days/i))
|
|
78
|
+
expect(onSelect).toHaveBeenCalled()
|
|
79
|
+
const arg = onSelect.mock.calls[0][0]
|
|
80
|
+
expect(arg?.from).toBeInstanceOf(Date)
|
|
81
|
+
expect(arg?.to).toBeInstanceOf(Date)
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
|
|
67
85
|
describe('DatePicker', () => {
|
|
68
86
|
it('renders with placeholder text', () => {
|
|
69
87
|
const mockOnSelect = jest.fn()
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
//
|
|
2
|
+
// soft-warning-alert.jsx
|
|
3
|
+
// -----------------------
|
|
4
|
+
// A soft-styled warning alert built on shadcn-like primitives used in this package.
|
|
5
|
+
// Use this for non-blocking warnings with an optional action button.
|
|
6
|
+
//
|
|
7
|
+
// Example:
|
|
8
|
+
// import { SoftWarningAlert } from '@snapdragonsnursery/react-components'
|
|
9
|
+
// import { AlertTriangle } from 'lucide-react'
|
|
10
|
+
//
|
|
11
|
+
// export default function Example() {
|
|
12
|
+
// return (
|
|
13
|
+
// <SoftWarningAlert
|
|
14
|
+
// icon={AlertTriangle}
|
|
15
|
+
// title="Unsubmitted claims"
|
|
16
|
+
// description="You have 3 unsubmitted mileage claims. Create a report to submit."
|
|
17
|
+
// actionLabel="Create report"
|
|
18
|
+
// onAction={() => console.log('clicked')}
|
|
19
|
+
// />
|
|
20
|
+
// )
|
|
21
|
+
// }
|
|
22
|
+
|
|
23
|
+
import React from 'react'
|
|
24
|
+
import { cn } from '../../lib/utils'
|
|
25
|
+
|
|
26
|
+
// Lightweight Alert primitives compatible with package styling
|
|
27
|
+
function Alert({ className, children }) {
|
|
28
|
+
return (
|
|
29
|
+
<div
|
|
30
|
+
className={cn(
|
|
31
|
+
'w-full rounded-lg border p-4 text-sm',
|
|
32
|
+
'bg-amber-600/10 text-amber-700 border-amber-200',
|
|
33
|
+
'dark:bg-amber-400/10 dark:text-amber-300 dark:border-amber-300/20',
|
|
34
|
+
className
|
|
35
|
+
)}
|
|
36
|
+
role="alert"
|
|
37
|
+
>
|
|
38
|
+
{children}
|
|
39
|
+
</div>
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Prop types omitted to avoid runtime dependency
|
|
44
|
+
|
|
45
|
+
function AlertTitle({ className, children }) {
|
|
46
|
+
return <div className={cn('font-medium', className)}>{children}</div>
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Prop types omitted to avoid runtime dependency
|
|
50
|
+
|
|
51
|
+
function AlertDescription({ className, children }) {
|
|
52
|
+
return (
|
|
53
|
+
<div className={cn('mt-1 text-amber-700/80 dark:text-amber-300/80', className)}>
|
|
54
|
+
{children}
|
|
55
|
+
</div>
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Prop types omitted to avoid runtime dependency
|
|
60
|
+
|
|
61
|
+
function Button({ className, children, ...props }) {
|
|
62
|
+
return (
|
|
63
|
+
<button
|
|
64
|
+
type="button"
|
|
65
|
+
className={cn(
|
|
66
|
+
'inline-flex items-center rounded-md px-3 py-1.5 text-xs font-medium',
|
|
67
|
+
'bg-amber-600 text-white hover:bg-amber-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-500',
|
|
68
|
+
'dark:bg-amber-500 dark:text-black dark:hover:bg-amber-400',
|
|
69
|
+
className
|
|
70
|
+
)}
|
|
71
|
+
{...props}
|
|
72
|
+
>
|
|
73
|
+
{children}
|
|
74
|
+
</button>
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Prop types omitted to avoid runtime dependency
|
|
79
|
+
|
|
80
|
+
export function SoftWarningAlert({
|
|
81
|
+
title,
|
|
82
|
+
description,
|
|
83
|
+
icon: Icon,
|
|
84
|
+
className,
|
|
85
|
+
actionLabel,
|
|
86
|
+
onAction,
|
|
87
|
+
}) {
|
|
88
|
+
return (
|
|
89
|
+
<Alert className={className}>
|
|
90
|
+
<div className="flex items-start justify-between gap-3">
|
|
91
|
+
<div className="flex items-start gap-3">
|
|
92
|
+
{Icon ? <Icon className="h-5 w-5 mt-0.5 shrink-0" /> : null}
|
|
93
|
+
<div>
|
|
94
|
+
<AlertTitle>{title}</AlertTitle>
|
|
95
|
+
{description ? (
|
|
96
|
+
<AlertDescription>{description}</AlertDescription>
|
|
97
|
+
) : null}
|
|
98
|
+
</div>
|
|
99
|
+
</div>
|
|
100
|
+
{typeof onAction === 'function' && actionLabel ? (
|
|
101
|
+
<Button onClick={onAction}>{actionLabel}</Button>
|
|
102
|
+
) : null}
|
|
103
|
+
</div>
|
|
104
|
+
</Alert>
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Prop types omitted to avoid runtime dependency
|
|
109
|
+
|
|
110
|
+
export default SoftWarningAlert
|
|
111
|
+
|
|
112
|
+
|
|
@@ -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
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
//
|
|
2
|
+
// index.d.ts
|
|
3
|
+
// -------------
|
|
4
|
+
// TypeScript declarations for @snapdragonsnursery/react-components
|
|
5
|
+
// This provides minimal typings for components used in consuming TypeScript apps.
|
|
6
|
+
|
|
7
|
+
import * as React from 'react'
|
|
8
|
+
|
|
9
|
+
export interface SoftWarningAlertProps {
|
|
10
|
+
title: React.ReactNode
|
|
11
|
+
description?: React.ReactNode
|
|
12
|
+
icon?: React.ComponentType<any>
|
|
13
|
+
className?: string
|
|
14
|
+
actionLabel?: string
|
|
15
|
+
onAction?: () => void
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const SoftWarningAlert: React.FC<SoftWarningAlertProps>
|
|
19
|
+
|
|
20
|
+
// Existing components (typed as any for now)
|
|
21
|
+
export const AuthButtons: React.ComponentType<any>
|
|
22
|
+
export const ThemeToggle: React.ComponentType<any>
|
|
23
|
+
export const ChildSearchModal: React.ComponentType<any>
|
|
24
|
+
export const ChildSearchPage: React.ComponentType<any>
|
|
25
|
+
export const ChildSearchPageDemo: React.ComponentType<any>
|
|
26
|
+
export const ThemeToggleTest: React.ComponentType<any>
|
|
27
|
+
export const LandingPage: React.ComponentType<any>
|
|
28
|
+
export const ChildSearchFilters: React.ComponentType<any>
|
|
29
|
+
export const DateRangePickerDemo: React.ComponentType<any>
|
|
30
|
+
export const CalendarDemo: React.ComponentType<any>
|
|
31
|
+
export const DateRangePickerTest: React.ComponentType<any>
|
|
32
|
+
export const ApplyButtonDemo: React.ComponentType<any>
|
|
33
|
+
export const EmployeeSearchPage: React.ComponentType<any>
|
|
34
|
+
export const EmployeeSearchModal: React.ComponentType<any>
|
|
35
|
+
export const EmployeeSearchDemo: React.ComponentType<any>
|
|
36
|
+
export const EmployeeSearchFilters: React.ComponentType<any>
|
|
37
|
+
|
|
38
|
+
export const DateRangePicker: React.ComponentType<any>
|
|
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>
|
|
48
|
+
export const Calendar: React.ComponentType<any>
|
|
49
|
+
export const SimpleCalendar: React.ComponentType<any>
|
|
50
|
+
export const Popover: React.ComponentType<any>
|
|
51
|
+
export const PopoverContent: React.ComponentType<any>
|
|
52
|
+
export const PopoverTrigger: React.ComponentType<any>
|
|
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
|
+
|
|
65
|
+
export function configureTelemetry(...args: any[]): any
|
|
66
|
+
|
|
67
|
+
|
package/src/index.js
CHANGED
|
@@ -1,26 +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";
|
|
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";
|