@snapdragonsnursery/react-components 1.1.38 → 1.3.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 +115 -0
- package/package.json +23 -6
- package/src/ApplyButtonDemo.jsx +94 -0
- package/src/CalendarDemo.jsx +39 -0
- package/src/ChildSearchPage.jsx +100 -256
- package/src/DateRangePickerDebug.jsx +1 -0
- package/src/DateRangePickerDemo.jsx +58 -0
- package/src/DateRangePickerTest.jsx +71 -0
- package/src/components/ChildSearchFilters.jsx +237 -0
- package/src/components/ChildSearchFilters.test.jsx +280 -0
- package/src/components/ui/calendar.jsx +173 -0
- package/src/components/ui/date-range-picker.jsx +277 -0
- package/src/components/ui/date-range-picker.test.jsx +95 -0
- package/src/components/ui/popover.jsx +46 -0
- package/src/components/ui/simple-calendar.jsx +65 -0
- package/src/index.css +59 -0
- package/src/index.js +11 -0
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
// Child Search Filters Component
|
|
2
|
+
// Provides advanced filtering capabilities for child search including status, site, date of birth, age, and sorting options
|
|
3
|
+
// Usage: <ChildSearchFilters filters={filters} onFiltersChange={setFilters} sites={sites} />
|
|
4
|
+
|
|
5
|
+
import React from "react";
|
|
6
|
+
import { Input } from "./ui/input";
|
|
7
|
+
import { Select, SelectOption } from "./ui/select";
|
|
8
|
+
import { DateRangePicker } from "./ui/date-range-picker";
|
|
9
|
+
import { Button } from "./ui/button";
|
|
10
|
+
import {
|
|
11
|
+
FunnelIcon,
|
|
12
|
+
ChevronDownIcon,
|
|
13
|
+
ChevronUpIcon,
|
|
14
|
+
} from "@heroicons/react/24/outline";
|
|
15
|
+
|
|
16
|
+
const ChildSearchFilters = ({
|
|
17
|
+
filters,
|
|
18
|
+
onFiltersChange,
|
|
19
|
+
sites = null,
|
|
20
|
+
activeOnly = true,
|
|
21
|
+
isAdvancedFiltersOpen,
|
|
22
|
+
onToggleAdvancedFilters,
|
|
23
|
+
onClearFilters,
|
|
24
|
+
onApplyFilters,
|
|
25
|
+
}) => {
|
|
26
|
+
// Local state for filters that haven't been applied yet
|
|
27
|
+
const [localFilters, setLocalFilters] = React.useState(filters);
|
|
28
|
+
const [hasUnsavedChanges, setHasUnsavedChanges] = React.useState(false);
|
|
29
|
+
|
|
30
|
+
// Update local filters when props change
|
|
31
|
+
React.useEffect(() => {
|
|
32
|
+
setLocalFilters(filters);
|
|
33
|
+
setHasUnsavedChanges(false);
|
|
34
|
+
}, [filters]);
|
|
35
|
+
// Convert existing date strings to DateRangePicker format
|
|
36
|
+
const getDateRange = () => {
|
|
37
|
+
if (localFilters.dobFrom || localFilters.dobTo) {
|
|
38
|
+
return {
|
|
39
|
+
from: localFilters.dobFrom ? new Date(localFilters.dobFrom) : null,
|
|
40
|
+
to: localFilters.dobTo ? new Date(localFilters.dobTo) : null,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const handleDateRangeChange = (range) => {
|
|
47
|
+
// DateRangePicker now only calls onSelect when both dates are selected or when cleared
|
|
48
|
+
if (range?.from && range?.to) {
|
|
49
|
+
// Both dates selected - update local filters
|
|
50
|
+
setLocalFilters(prev => ({
|
|
51
|
+
...prev,
|
|
52
|
+
dobFrom: range.from.toISOString().split('T')[0],
|
|
53
|
+
dobTo: range.to.toISOString().split('T')[0],
|
|
54
|
+
}));
|
|
55
|
+
setHasUnsavedChanges(true);
|
|
56
|
+
} else if (!range?.from && !range?.to) {
|
|
57
|
+
// Range cleared - clear local filters
|
|
58
|
+
setLocalFilters(prev => ({
|
|
59
|
+
...prev,
|
|
60
|
+
dobFrom: '',
|
|
61
|
+
dobTo: '',
|
|
62
|
+
}));
|
|
63
|
+
setHasUnsavedChanges(true);
|
|
64
|
+
}
|
|
65
|
+
// No need to handle single date selection as DateRangePicker handles it internally
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const handleFilterChange = (key, value) => {
|
|
69
|
+
setLocalFilters(prev => ({
|
|
70
|
+
...prev,
|
|
71
|
+
[key]: value,
|
|
72
|
+
}));
|
|
73
|
+
setHasUnsavedChanges(true);
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const clearFilters = () => {
|
|
77
|
+
onClearFilters();
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const handleApplyFilters = () => {
|
|
81
|
+
onApplyFilters(localFilters);
|
|
82
|
+
setHasUnsavedChanges(false);
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const handleCancelChanges = () => {
|
|
86
|
+
setLocalFilters(filters);
|
|
87
|
+
setHasUnsavedChanges(false);
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
return (
|
|
91
|
+
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 mb-6">
|
|
92
|
+
<div className="p-6">
|
|
93
|
+
{/* Advanced Filters Toggle */}
|
|
94
|
+
<div className="flex items-center justify-between">
|
|
95
|
+
<button
|
|
96
|
+
onClick={onToggleAdvancedFilters}
|
|
97
|
+
className="flex items-center space-x-2 text-sm text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300"
|
|
98
|
+
>
|
|
99
|
+
<FunnelIcon className="h-4 w-4" />
|
|
100
|
+
<span>Advanced Filters</span>
|
|
101
|
+
{isAdvancedFiltersOpen ? (
|
|
102
|
+
<ChevronUpIcon className="h-4 w-4" />
|
|
103
|
+
) : (
|
|
104
|
+
<ChevronDownIcon className="h-4 w-4" />
|
|
105
|
+
)}
|
|
106
|
+
</button>
|
|
107
|
+
<button
|
|
108
|
+
onClick={clearFilters}
|
|
109
|
+
className="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200"
|
|
110
|
+
>
|
|
111
|
+
Clear Filters
|
|
112
|
+
</button>
|
|
113
|
+
</div>
|
|
114
|
+
|
|
115
|
+
{/* Advanced Filters */}
|
|
116
|
+
{isAdvancedFiltersOpen && (
|
|
117
|
+
<div
|
|
118
|
+
className="mt-4 p-4 rounded-lg"
|
|
119
|
+
style={{
|
|
120
|
+
backgroundColor: 'hsl(var(--card))',
|
|
121
|
+
border: '1px solid hsl(var(--border))'
|
|
122
|
+
}}
|
|
123
|
+
>
|
|
124
|
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
125
|
+
{/* Status Filter */}
|
|
126
|
+
<div>
|
|
127
|
+
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
128
|
+
Status
|
|
129
|
+
</label>
|
|
130
|
+
<Select
|
|
131
|
+
value={localFilters.status}
|
|
132
|
+
onChange={(e) => handleFilterChange("status", e.target.value)}
|
|
133
|
+
className="bg-white dark:bg-gray-800 border-gray-300 dark:border-gray-600 text-gray-900 dark:text-white"
|
|
134
|
+
>
|
|
135
|
+
<SelectOption value="all">All Children</SelectOption>
|
|
136
|
+
<SelectOption value="active">Active Only</SelectOption>
|
|
137
|
+
<SelectOption value="inactive">Inactive Only</SelectOption>
|
|
138
|
+
</Select>
|
|
139
|
+
</div>
|
|
140
|
+
|
|
141
|
+
{/* Site Filter */}
|
|
142
|
+
{sites && sites.length > 0 && (
|
|
143
|
+
<div>
|
|
144
|
+
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
145
|
+
Site
|
|
146
|
+
</label>
|
|
147
|
+
<Select
|
|
148
|
+
value={localFilters.selectedSiteId}
|
|
149
|
+
onChange={(e) => handleFilterChange("selectedSiteId", e.target.value)}
|
|
150
|
+
className="bg-white dark:bg-gray-800 border-gray-300 dark:border-gray-600 text-gray-900 dark:text-white"
|
|
151
|
+
>
|
|
152
|
+
<SelectOption value="">All Sites</SelectOption>
|
|
153
|
+
{sites.map((site) => (
|
|
154
|
+
<SelectOption key={site.site_id} value={site.site_id}>
|
|
155
|
+
{site.site_name}
|
|
156
|
+
</SelectOption>
|
|
157
|
+
))}
|
|
158
|
+
</Select>
|
|
159
|
+
</div>
|
|
160
|
+
)}
|
|
161
|
+
|
|
162
|
+
{/* Date of Birth Range */}
|
|
163
|
+
<div>
|
|
164
|
+
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
165
|
+
Date of Birth Range
|
|
166
|
+
</label>
|
|
167
|
+
<DateRangePicker
|
|
168
|
+
selectedRange={getDateRange()}
|
|
169
|
+
onSelect={handleDateRangeChange}
|
|
170
|
+
placeholder="Select date of birth range"
|
|
171
|
+
className="bg-white dark:bg-gray-800 border-gray-300 dark:border-gray-600 text-gray-900 dark:text-white"
|
|
172
|
+
/>
|
|
173
|
+
</div>
|
|
174
|
+
|
|
175
|
+
{/* Age Range */}
|
|
176
|
+
<div>
|
|
177
|
+
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
178
|
+
Age From (months)
|
|
179
|
+
</label>
|
|
180
|
+
<Input
|
|
181
|
+
type="number"
|
|
182
|
+
min="0"
|
|
183
|
+
value={localFilters.ageFrom}
|
|
184
|
+
onChange={(e) => handleFilterChange("ageFrom", e.target.value)}
|
|
185
|
+
placeholder="0"
|
|
186
|
+
className="bg-white dark:bg-gray-800 border-gray-300 dark:border-gray-600 text-gray-900 dark:text-white"
|
|
187
|
+
/>
|
|
188
|
+
</div>
|
|
189
|
+
|
|
190
|
+
<div>
|
|
191
|
+
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
192
|
+
Age To (months)
|
|
193
|
+
</label>
|
|
194
|
+
<Input
|
|
195
|
+
type="number"
|
|
196
|
+
min="0"
|
|
197
|
+
value={localFilters.ageTo}
|
|
198
|
+
onChange={(e) => handleFilterChange("ageTo", e.target.value)}
|
|
199
|
+
placeholder="60"
|
|
200
|
+
className="bg-white dark:bg-gray-800 border-gray-300 dark:border-gray-600 text-gray-900 dark:text-white"
|
|
201
|
+
/>
|
|
202
|
+
</div>
|
|
203
|
+
</div>
|
|
204
|
+
|
|
205
|
+
{/* Apply/Cancel Buttons */}
|
|
206
|
+
{hasUnsavedChanges && (
|
|
207
|
+
<div className="mt-6 pt-4 border-t border-gray-200 dark:border-gray-700">
|
|
208
|
+
<div className="flex items-center justify-between">
|
|
209
|
+
<span className="text-sm text-gray-600 dark:text-gray-400">
|
|
210
|
+
You have unsaved filter changes
|
|
211
|
+
</span>
|
|
212
|
+
<div className="flex items-center space-x-2">
|
|
213
|
+
<Button
|
|
214
|
+
variant="outline"
|
|
215
|
+
size="sm"
|
|
216
|
+
onClick={handleCancelChanges}
|
|
217
|
+
>
|
|
218
|
+
Cancel
|
|
219
|
+
</Button>
|
|
220
|
+
<Button
|
|
221
|
+
size="sm"
|
|
222
|
+
onClick={handleApplyFilters}
|
|
223
|
+
>
|
|
224
|
+
Apply Filters
|
|
225
|
+
</Button>
|
|
226
|
+
</div>
|
|
227
|
+
</div>
|
|
228
|
+
</div>
|
|
229
|
+
)}
|
|
230
|
+
</div>
|
|
231
|
+
)}
|
|
232
|
+
</div>
|
|
233
|
+
</div>
|
|
234
|
+
);
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
export default ChildSearchFilters;
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
// ChildSearchFilters Component Tests
|
|
2
|
+
// Tests the filter functionality including status, site, date of birth, and age options
|
|
3
|
+
|
|
4
|
+
import React from "react";
|
|
5
|
+
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
|
6
|
+
import "@testing-library/jest-dom";
|
|
7
|
+
import ChildSearchFilters from "./ChildSearchFilters";
|
|
8
|
+
|
|
9
|
+
// Mock the UI components
|
|
10
|
+
jest.mock("./ui/input", () => ({
|
|
11
|
+
__esModule: true,
|
|
12
|
+
Input: ({ value, onChange, placeholder, type, min, className, ...props }) => (
|
|
13
|
+
<input
|
|
14
|
+
data-testid="input"
|
|
15
|
+
value={value}
|
|
16
|
+
onChange={onChange}
|
|
17
|
+
placeholder={placeholder}
|
|
18
|
+
type={type}
|
|
19
|
+
min={min}
|
|
20
|
+
className={className}
|
|
21
|
+
{...props}
|
|
22
|
+
/>
|
|
23
|
+
),
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
jest.mock("./ui/select", () => ({
|
|
27
|
+
__esModule: true,
|
|
28
|
+
Select: ({ value, onChange, children, className, ...props }) => (
|
|
29
|
+
<select
|
|
30
|
+
data-testid="select"
|
|
31
|
+
value={value}
|
|
32
|
+
onChange={onChange}
|
|
33
|
+
className={className}
|
|
34
|
+
{...props}
|
|
35
|
+
>
|
|
36
|
+
{children}
|
|
37
|
+
</select>
|
|
38
|
+
),
|
|
39
|
+
SelectOption: ({ value, children, ...props }) => (
|
|
40
|
+
<option value={value} {...props}>
|
|
41
|
+
{children}
|
|
42
|
+
</option>
|
|
43
|
+
),
|
|
44
|
+
}));
|
|
45
|
+
|
|
46
|
+
jest.mock("./ui/date-range-picker", () => ({
|
|
47
|
+
__esModule: true,
|
|
48
|
+
DateRangePicker: ({ selectedRange, onSelect, placeholder, className, ...props }) => (
|
|
49
|
+
<div data-testid="date-range-picker" className={className}>
|
|
50
|
+
<button
|
|
51
|
+
onClick={() => onSelect && onSelect({ from: new Date('2020-01-01'), to: new Date('2020-12-31') })}
|
|
52
|
+
{...props}
|
|
53
|
+
>
|
|
54
|
+
{selectedRange?.from && selectedRange?.to
|
|
55
|
+
? `${selectedRange.from.toLocaleDateString()} - ${selectedRange.to.toLocaleDateString()}`
|
|
56
|
+
: placeholder || 'Select date range'
|
|
57
|
+
}
|
|
58
|
+
</button>
|
|
59
|
+
</div>
|
|
60
|
+
),
|
|
61
|
+
}));
|
|
62
|
+
|
|
63
|
+
describe("ChildSearchFilters", () => {
|
|
64
|
+
const mockFilters = {
|
|
65
|
+
status: "active",
|
|
66
|
+
selectedSiteId: "",
|
|
67
|
+
dobFrom: "",
|
|
68
|
+
dobTo: "",
|
|
69
|
+
ageFrom: "",
|
|
70
|
+
ageTo: "",
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const mockSites = [
|
|
74
|
+
{ site_id: 1, site_name: "Site A" },
|
|
75
|
+
{ site_id: 2, site_name: "Site B" },
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
const defaultProps = {
|
|
79
|
+
filters: mockFilters,
|
|
80
|
+
onFiltersChange: jest.fn(),
|
|
81
|
+
onApplyFilters: jest.fn(),
|
|
82
|
+
sites: mockSites,
|
|
83
|
+
activeOnly: true,
|
|
84
|
+
isAdvancedFiltersOpen: false,
|
|
85
|
+
onToggleAdvancedFilters: jest.fn(),
|
|
86
|
+
onClearFilters: jest.fn(),
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
beforeEach(() => {
|
|
90
|
+
jest.clearAllMocks();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("renders the advanced filters toggle button", () => {
|
|
94
|
+
render(<ChildSearchFilters {...defaultProps} />);
|
|
95
|
+
|
|
96
|
+
expect(screen.getByText("Advanced Filters")).toBeInTheDocument();
|
|
97
|
+
expect(screen.getByText("Clear Filters")).toBeInTheDocument();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("shows advanced filters when isAdvancedFiltersOpen is true", () => {
|
|
101
|
+
render(<ChildSearchFilters {...defaultProps} isAdvancedFiltersOpen={true} />);
|
|
102
|
+
|
|
103
|
+
expect(screen.getByText("Status")).toBeInTheDocument();
|
|
104
|
+
expect(screen.getByText("Site")).toBeInTheDocument();
|
|
105
|
+
expect(screen.getByText("Date of Birth Range")).toBeInTheDocument();
|
|
106
|
+
expect(screen.getByText("Age From (months)")).toBeInTheDocument();
|
|
107
|
+
expect(screen.getByText("Age To (months)")).toBeInTheDocument();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("hides advanced filters when isAdvancedFiltersOpen is false", () => {
|
|
111
|
+
render(<ChildSearchFilters {...defaultProps} isAdvancedFiltersOpen={false} />);
|
|
112
|
+
|
|
113
|
+
expect(screen.queryByText("Status")).not.toBeInTheDocument();
|
|
114
|
+
expect(screen.queryByText("Site")).not.toBeInTheDocument();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("calls onToggleAdvancedFilters when toggle button is clicked", () => {
|
|
118
|
+
render(<ChildSearchFilters {...defaultProps} />);
|
|
119
|
+
|
|
120
|
+
fireEvent.click(screen.getByText("Advanced Filters"));
|
|
121
|
+
|
|
122
|
+
expect(defaultProps.onToggleAdvancedFilters).toHaveBeenCalledTimes(1);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("calls onClearFilters when clear button is clicked", () => {
|
|
126
|
+
render(<ChildSearchFilters {...defaultProps} />);
|
|
127
|
+
|
|
128
|
+
fireEvent.click(screen.getByText("Clear Filters"));
|
|
129
|
+
|
|
130
|
+
expect(defaultProps.onClearFilters).toHaveBeenCalledTimes(1);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("renders site options when sites are provided", () => {
|
|
134
|
+
render(<ChildSearchFilters {...defaultProps} isAdvancedFiltersOpen={true} />);
|
|
135
|
+
|
|
136
|
+
const siteSelect = screen.getAllByTestId("select")[1]; // Second select is site
|
|
137
|
+
expect(siteSelect).toBeInTheDocument();
|
|
138
|
+
|
|
139
|
+
// Check that site options are rendered
|
|
140
|
+
expect(siteSelect).toHaveValue("");
|
|
141
|
+
expect(siteSelect).toHaveTextContent("All Sites");
|
|
142
|
+
expect(siteSelect).toHaveTextContent("Site A");
|
|
143
|
+
expect(siteSelect).toHaveTextContent("Site B");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("does not render site filter when sites are not provided", () => {
|
|
147
|
+
render(
|
|
148
|
+
<ChildSearchFilters
|
|
149
|
+
{...defaultProps}
|
|
150
|
+
sites={null}
|
|
151
|
+
isAdvancedFiltersOpen={true}
|
|
152
|
+
/>
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
const selects = screen.getAllByTestId("select");
|
|
156
|
+
expect(selects).toHaveLength(1); // Only Status filter (no site filter when sites=null)
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("shows apply button when filters are changed", () => {
|
|
160
|
+
render(<ChildSearchFilters {...defaultProps} isAdvancedFiltersOpen={true} />);
|
|
161
|
+
|
|
162
|
+
const statusSelect = screen.getAllByTestId("select")[0];
|
|
163
|
+
fireEvent.change(statusSelect, { target: { value: "inactive" } });
|
|
164
|
+
|
|
165
|
+
expect(screen.getByText("Apply Filters")).toBeInTheDocument();
|
|
166
|
+
expect(screen.getByText("You have unsaved filter changes")).toBeInTheDocument();
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("calls onApplyFilters when apply button is clicked", () => {
|
|
170
|
+
render(<ChildSearchFilters {...defaultProps} isAdvancedFiltersOpen={true} />);
|
|
171
|
+
|
|
172
|
+
const statusSelect = screen.getAllByTestId("select")[0];
|
|
173
|
+
fireEvent.change(statusSelect, { target: { value: "inactive" } });
|
|
174
|
+
|
|
175
|
+
const applyButton = screen.getByText("Apply Filters");
|
|
176
|
+
fireEvent.click(applyButton);
|
|
177
|
+
|
|
178
|
+
expect(defaultProps.onApplyFilters).toHaveBeenCalledWith(
|
|
179
|
+
expect.objectContaining({
|
|
180
|
+
status: "inactive"
|
|
181
|
+
})
|
|
182
|
+
);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("calls onApplyFilters when apply button is clicked with date range", () => {
|
|
186
|
+
render(<ChildSearchFilters {...defaultProps} isAdvancedFiltersOpen={true} />);
|
|
187
|
+
|
|
188
|
+
const dateRangePicker = screen.getByTestId("date-range-picker");
|
|
189
|
+
const button = dateRangePicker.querySelector("button");
|
|
190
|
+
fireEvent.click(button);
|
|
191
|
+
|
|
192
|
+
const applyButton = screen.getByText("Apply Filters");
|
|
193
|
+
fireEvent.click(applyButton);
|
|
194
|
+
|
|
195
|
+
expect(defaultProps.onApplyFilters).toHaveBeenCalledWith(
|
|
196
|
+
expect.objectContaining({
|
|
197
|
+
dobFrom: "2020-01-01",
|
|
198
|
+
dobTo: "2020-12-31"
|
|
199
|
+
})
|
|
200
|
+
);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("calls onApplyFilters when apply button is clicked with age filter", () => {
|
|
204
|
+
render(<ChildSearchFilters {...defaultProps} isAdvancedFiltersOpen={true} />);
|
|
205
|
+
|
|
206
|
+
const ageFromInput = screen.getAllByTestId("input")[0];
|
|
207
|
+
fireEvent.change(ageFromInput, { target: { value: "12" } });
|
|
208
|
+
|
|
209
|
+
const applyButton = screen.getByText("Apply Filters");
|
|
210
|
+
fireEvent.click(applyButton);
|
|
211
|
+
|
|
212
|
+
expect(defaultProps.onApplyFilters).toHaveBeenCalledWith(
|
|
213
|
+
expect.objectContaining({
|
|
214
|
+
ageFrom: "12"
|
|
215
|
+
})
|
|
216
|
+
);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("displays correct filter values", () => {
|
|
220
|
+
const filtersWithValues = {
|
|
221
|
+
...mockFilters,
|
|
222
|
+
status: "inactive",
|
|
223
|
+
selectedSiteId: "1",
|
|
224
|
+
dobFrom: "2020-01-01",
|
|
225
|
+
dobTo: "2020-12-31",
|
|
226
|
+
ageFrom: "12",
|
|
227
|
+
ageTo: "60",
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
render(
|
|
231
|
+
<ChildSearchFilters
|
|
232
|
+
{...defaultProps}
|
|
233
|
+
filters={filtersWithValues}
|
|
234
|
+
isAdvancedFiltersOpen={true}
|
|
235
|
+
/>
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
const statusSelect = screen.getAllByTestId("select")[0];
|
|
239
|
+
const siteSelect = screen.getAllByTestId("select")[1];
|
|
240
|
+
const dateRangePicker = screen.getByTestId("date-range-picker");
|
|
241
|
+
const ageFromInput = screen.getAllByTestId("input")[0];
|
|
242
|
+
const ageToInput = screen.getAllByTestId("input")[1];
|
|
243
|
+
|
|
244
|
+
expect(statusSelect).toHaveValue("inactive");
|
|
245
|
+
expect(siteSelect).toHaveValue("1");
|
|
246
|
+
expect(dateRangePicker).toBeInTheDocument();
|
|
247
|
+
expect(ageFromInput).toHaveValue(12);
|
|
248
|
+
expect(ageToInput).toHaveValue(60);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("has correct accessibility attributes", () => {
|
|
252
|
+
render(<ChildSearchFilters {...defaultProps} isAdvancedFiltersOpen={true} />);
|
|
253
|
+
|
|
254
|
+
// Check that all inputs have proper labels
|
|
255
|
+
expect(screen.getByText("Status")).toBeInTheDocument();
|
|
256
|
+
expect(screen.getByText("Site")).toBeInTheDocument();
|
|
257
|
+
expect(screen.getByText("Date of Birth Range")).toBeInTheDocument();
|
|
258
|
+
expect(screen.getByText("Age From (months)")).toBeInTheDocument();
|
|
259
|
+
expect(screen.getByText("Age To (months)")).toBeInTheDocument();
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("applies correct CSS classes for dark mode support", () => {
|
|
263
|
+
render(<ChildSearchFilters {...defaultProps} isAdvancedFiltersOpen={true} />);
|
|
264
|
+
|
|
265
|
+
const inputs = screen.getAllByTestId("input");
|
|
266
|
+
const selects = screen.getAllByTestId("select");
|
|
267
|
+
|
|
268
|
+
inputs.forEach(input => {
|
|
269
|
+
expect(input.className).toContain("dark:bg-gray-800");
|
|
270
|
+
expect(input.className).toContain("dark:border-gray-600");
|
|
271
|
+
expect(input.className).toContain("dark:text-white");
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
selects.forEach(select => {
|
|
275
|
+
expect(select.className).toContain("dark:bg-gray-800");
|
|
276
|
+
expect(select.className).toContain("dark:border-gray-600");
|
|
277
|
+
expect(select.className).toContain("dark:text-white");
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
});
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
import {
|
|
3
|
+
ChevronDownIcon,
|
|
4
|
+
ChevronLeftIcon,
|
|
5
|
+
ChevronRightIcon,
|
|
6
|
+
} from "lucide-react"
|
|
7
|
+
import { DayPicker, getDefaultClassNames } from "react-day-picker";
|
|
8
|
+
|
|
9
|
+
import { cn } from "../../lib/utils"
|
|
10
|
+
import { Button, buttonVariants } from "./button"
|
|
11
|
+
|
|
12
|
+
function Calendar({
|
|
13
|
+
className,
|
|
14
|
+
classNames,
|
|
15
|
+
showOutsideDays = true,
|
|
16
|
+
captionLayout = "label",
|
|
17
|
+
buttonVariant = "ghost",
|
|
18
|
+
formatters,
|
|
19
|
+
components,
|
|
20
|
+
...props
|
|
21
|
+
}) {
|
|
22
|
+
const defaultClassNames = getDefaultClassNames()
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<DayPicker
|
|
26
|
+
showOutsideDays={showOutsideDays}
|
|
27
|
+
className={cn(
|
|
28
|
+
"bg-background group/calendar p-3",
|
|
29
|
+
className
|
|
30
|
+
)}
|
|
31
|
+
captionLayout={captionLayout}
|
|
32
|
+
formatters={{
|
|
33
|
+
formatMonthDropdown: (date) =>
|
|
34
|
+
date.toLocaleString("default", { month: "short" }),
|
|
35
|
+
...formatters,
|
|
36
|
+
}}
|
|
37
|
+
classNames={{
|
|
38
|
+
root: cn("w-fit", defaultClassNames.root),
|
|
39
|
+
months: cn("flex gap-4 flex-col md:flex-row relative", defaultClassNames.months),
|
|
40
|
+
month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
|
|
41
|
+
nav: cn(
|
|
42
|
+
"flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
|
|
43
|
+
defaultClassNames.nav
|
|
44
|
+
),
|
|
45
|
+
button_previous: cn(
|
|
46
|
+
buttonVariants({ variant: buttonVariant }),
|
|
47
|
+
"h-8 w-8 aria-disabled:opacity-50 p-0 select-none",
|
|
48
|
+
defaultClassNames.button_previous
|
|
49
|
+
),
|
|
50
|
+
button_next: cn(
|
|
51
|
+
buttonVariants({ variant: buttonVariant }),
|
|
52
|
+
"h-8 w-8 aria-disabled:opacity-50 p-0 select-none",
|
|
53
|
+
defaultClassNames.button_next
|
|
54
|
+
),
|
|
55
|
+
month_caption: cn(
|
|
56
|
+
"flex items-center justify-center h-8 w-full px-8",
|
|
57
|
+
defaultClassNames.month_caption
|
|
58
|
+
),
|
|
59
|
+
dropdowns: cn(
|
|
60
|
+
"w-full flex items-center text-sm font-medium justify-center h-8 gap-1.5",
|
|
61
|
+
defaultClassNames.dropdowns
|
|
62
|
+
),
|
|
63
|
+
dropdown_root: cn(
|
|
64
|
+
"relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
|
|
65
|
+
defaultClassNames.dropdown_root
|
|
66
|
+
),
|
|
67
|
+
dropdown: cn("absolute bg-popover inset-0 opacity-0", defaultClassNames.dropdown),
|
|
68
|
+
caption_label: cn("select-none font-medium", captionLayout === "label"
|
|
69
|
+
? "text-sm"
|
|
70
|
+
: "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5", defaultClassNames.caption_label),
|
|
71
|
+
table: "w-full border-collapse",
|
|
72
|
+
weekdays: cn("flex", defaultClassNames.weekdays),
|
|
73
|
+
weekday: cn(
|
|
74
|
+
"text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none",
|
|
75
|
+
defaultClassNames.weekday
|
|
76
|
+
),
|
|
77
|
+
week: cn("flex w-full mt-2", defaultClassNames.week),
|
|
78
|
+
week_number_header: cn("select-none w-8", defaultClassNames.week_number_header),
|
|
79
|
+
week_number: cn(
|
|
80
|
+
"text-[0.8rem] select-none text-muted-foreground",
|
|
81
|
+
defaultClassNames.week_number
|
|
82
|
+
),
|
|
83
|
+
day: cn(
|
|
84
|
+
"relative w-full h-full p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
|
|
85
|
+
defaultClassNames.day
|
|
86
|
+
),
|
|
87
|
+
range_start: cn("rounded-l-md bg-accent", defaultClassNames.range_start),
|
|
88
|
+
range_middle: cn("rounded-none", defaultClassNames.range_middle),
|
|
89
|
+
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
|
|
90
|
+
today: cn(
|
|
91
|
+
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
|
|
92
|
+
defaultClassNames.today
|
|
93
|
+
),
|
|
94
|
+
outside: cn(
|
|
95
|
+
"text-muted-foreground aria-selected:text-muted-foreground aria-selected:bg-muted aria-selected:opacity-50",
|
|
96
|
+
defaultClassNames.outside
|
|
97
|
+
),
|
|
98
|
+
disabled: cn("text-muted-foreground opacity-50", defaultClassNames.disabled),
|
|
99
|
+
hidden: cn("invisible", defaultClassNames.hidden),
|
|
100
|
+
...classNames,
|
|
101
|
+
}}
|
|
102
|
+
components={{
|
|
103
|
+
Root: ({ className, rootRef, ...props }) => {
|
|
104
|
+
return (<div data-slot="calendar" ref={rootRef} className={cn(className)} {...props} />);
|
|
105
|
+
},
|
|
106
|
+
Chevron: ({ className, orientation, ...props }) => {
|
|
107
|
+
if (orientation === "left") {
|
|
108
|
+
return (<ChevronLeftIcon className={cn("size-4", className)} {...props} />);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (orientation === "right") {
|
|
112
|
+
return (<ChevronRightIcon className={cn("size-4", className)} {...props} />);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return (<ChevronDownIcon className={cn("size-4", className)} {...props} />);
|
|
116
|
+
},
|
|
117
|
+
DayButton: CalendarDayButton,
|
|
118
|
+
WeekNumber: ({ children, ...props }) => {
|
|
119
|
+
return (
|
|
120
|
+
<td {...props}>
|
|
121
|
+
<div
|
|
122
|
+
className="flex h-8 w-8 items-center justify-center text-center">
|
|
123
|
+
{children}
|
|
124
|
+
</div>
|
|
125
|
+
</td>
|
|
126
|
+
);
|
|
127
|
+
},
|
|
128
|
+
...components,
|
|
129
|
+
}}
|
|
130
|
+
{...props} />
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function CalendarDayButton({
|
|
135
|
+
className,
|
|
136
|
+
day,
|
|
137
|
+
modifiers,
|
|
138
|
+
...props
|
|
139
|
+
}) {
|
|
140
|
+
const defaultClassNames = getDefaultClassNames()
|
|
141
|
+
|
|
142
|
+
const ref = React.useRef(null)
|
|
143
|
+
React.useEffect(() => {
|
|
144
|
+
if (modifiers.focused) ref.current?.focus()
|
|
145
|
+
}, [modifiers.focused])
|
|
146
|
+
|
|
147
|
+
return (
|
|
148
|
+
<Button
|
|
149
|
+
ref={ref}
|
|
150
|
+
variant="ghost"
|
|
151
|
+
size="icon"
|
|
152
|
+
data-day={day.date.toLocaleDateString()}
|
|
153
|
+
data-selected-single={
|
|
154
|
+
modifiers.selected &&
|
|
155
|
+
!modifiers.range_start &&
|
|
156
|
+
!modifiers.range_end &&
|
|
157
|
+
!modifiers.range_middle
|
|
158
|
+
}
|
|
159
|
+
data-range-start={modifiers.range_start}
|
|
160
|
+
data-range-end={modifiers.range_end}
|
|
161
|
+
data-range-middle={modifiers.range_middle}
|
|
162
|
+
className={cn(
|
|
163
|
+
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-8 flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
|
|
164
|
+
// Add specific styling for outside selected dates
|
|
165
|
+
modifiers.outside && modifiers.selected && "bg-muted text-muted-foreground opacity-50",
|
|
166
|
+
defaultClassNames.day,
|
|
167
|
+
className
|
|
168
|
+
)}
|
|
169
|
+
{...props} />
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export { Calendar, CalendarDayButton }
|