@snapdragonsnursery/react-components 1.1.37 → 1.2.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.
@@ -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,308 @@
1
+ // ChildSearchFilters Component Tests
2
+ // Tests the filter functionality including status, site, date of birth, age, and sorting 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
+ sortBy: "last_name",
72
+ sortOrder: "asc",
73
+ };
74
+
75
+ const mockSites = [
76
+ { site_id: 1, site_name: "Site A" },
77
+ { site_id: 2, site_name: "Site B" },
78
+ ];
79
+
80
+ const defaultProps = {
81
+ filters: mockFilters,
82
+ onFiltersChange: jest.fn(),
83
+ onApplyFilters: jest.fn(),
84
+ sites: mockSites,
85
+ activeOnly: true,
86
+ isAdvancedFiltersOpen: false,
87
+ onToggleAdvancedFilters: jest.fn(),
88
+ onClearFilters: jest.fn(),
89
+ };
90
+
91
+ beforeEach(() => {
92
+ jest.clearAllMocks();
93
+ });
94
+
95
+ it("renders the advanced filters toggle button", () => {
96
+ render(<ChildSearchFilters {...defaultProps} />);
97
+
98
+ expect(screen.getByText("Advanced Filters")).toBeInTheDocument();
99
+ expect(screen.getByText("Clear Filters")).toBeInTheDocument();
100
+ });
101
+
102
+ it("shows advanced filters when isAdvancedFiltersOpen is true", () => {
103
+ render(<ChildSearchFilters {...defaultProps} isAdvancedFiltersOpen={true} />);
104
+
105
+ expect(screen.getByText("Status")).toBeInTheDocument();
106
+ expect(screen.getByText("Site")).toBeInTheDocument();
107
+ expect(screen.getByText("Date of Birth Range")).toBeInTheDocument();
108
+ expect(screen.getByText("Age From (months)")).toBeInTheDocument();
109
+ expect(screen.getByText("Age To (months)")).toBeInTheDocument();
110
+ expect(screen.getByText("Sort By")).toBeInTheDocument();
111
+ expect(screen.getByText("Sort Order")).toBeInTheDocument();
112
+ });
113
+
114
+ it("hides advanced filters when isAdvancedFiltersOpen is false", () => {
115
+ render(<ChildSearchFilters {...defaultProps} isAdvancedFiltersOpen={false} />);
116
+
117
+ expect(screen.queryByText("Status")).not.toBeInTheDocument();
118
+ expect(screen.queryByText("Site")).not.toBeInTheDocument();
119
+ });
120
+
121
+ it("calls onToggleAdvancedFilters when toggle button is clicked", () => {
122
+ render(<ChildSearchFilters {...defaultProps} />);
123
+
124
+ fireEvent.click(screen.getByText("Advanced Filters"));
125
+
126
+ expect(defaultProps.onToggleAdvancedFilters).toHaveBeenCalledTimes(1);
127
+ });
128
+
129
+ it("calls onClearFilters when clear button is clicked", () => {
130
+ render(<ChildSearchFilters {...defaultProps} />);
131
+
132
+ fireEvent.click(screen.getByText("Clear Filters"));
133
+
134
+ expect(defaultProps.onClearFilters).toHaveBeenCalledTimes(1);
135
+ });
136
+
137
+ it("renders site options when sites are provided", () => {
138
+ render(<ChildSearchFilters {...defaultProps} isAdvancedFiltersOpen={true} />);
139
+
140
+ const siteSelect = screen.getAllByTestId("select")[1]; // Second select is site
141
+ expect(siteSelect).toBeInTheDocument();
142
+
143
+ // Check that site options are rendered
144
+ expect(siteSelect).toHaveValue("");
145
+ expect(siteSelect).toHaveTextContent("All Sites");
146
+ expect(siteSelect).toHaveTextContent("Site A");
147
+ expect(siteSelect).toHaveTextContent("Site B");
148
+ });
149
+
150
+ it("does not render site filter when sites are not provided", () => {
151
+ render(
152
+ <ChildSearchFilters
153
+ {...defaultProps}
154
+ sites={null}
155
+ isAdvancedFiltersOpen={true}
156
+ />
157
+ );
158
+
159
+ const selects = screen.getAllByTestId("select");
160
+ expect(selects).toHaveLength(3); // Status, Sort By, Sort Order (no site filter when sites=null)
161
+ });
162
+
163
+ it("shows apply button when filters are changed", () => {
164
+ render(<ChildSearchFilters {...defaultProps} isAdvancedFiltersOpen={true} />);
165
+
166
+ const statusSelect = screen.getAllByTestId("select")[0];
167
+ fireEvent.change(statusSelect, { target: { value: "inactive" } });
168
+
169
+ expect(screen.getByText("Apply Filters")).toBeInTheDocument();
170
+ expect(screen.getByText("You have unsaved filter changes")).toBeInTheDocument();
171
+ });
172
+
173
+ it("calls onApplyFilters when apply button is clicked", () => {
174
+ render(<ChildSearchFilters {...defaultProps} isAdvancedFiltersOpen={true} />);
175
+
176
+ const statusSelect = screen.getAllByTestId("select")[0];
177
+ fireEvent.change(statusSelect, { target: { value: "inactive" } });
178
+
179
+ const applyButton = screen.getByText("Apply Filters");
180
+ fireEvent.click(applyButton);
181
+
182
+ expect(defaultProps.onApplyFilters).toHaveBeenCalledWith(
183
+ expect.objectContaining({
184
+ status: "inactive"
185
+ })
186
+ );
187
+ });
188
+
189
+ it("calls onApplyFilters when apply button is clicked with date range", () => {
190
+ render(<ChildSearchFilters {...defaultProps} isAdvancedFiltersOpen={true} />);
191
+
192
+ const dateRangePicker = screen.getByTestId("date-range-picker");
193
+ const button = dateRangePicker.querySelector("button");
194
+ fireEvent.click(button);
195
+
196
+ const applyButton = screen.getByText("Apply Filters");
197
+ fireEvent.click(applyButton);
198
+
199
+ expect(defaultProps.onApplyFilters).toHaveBeenCalledWith(
200
+ expect.objectContaining({
201
+ dobFrom: "2020-01-01",
202
+ dobTo: "2020-12-31"
203
+ })
204
+ );
205
+ });
206
+
207
+ it("calls onApplyFilters when apply button is clicked with age filter", () => {
208
+ render(<ChildSearchFilters {...defaultProps} isAdvancedFiltersOpen={true} />);
209
+
210
+ const ageFromInput = screen.getAllByTestId("input")[0];
211
+ fireEvent.change(ageFromInput, { target: { value: "12" } });
212
+
213
+ const applyButton = screen.getByText("Apply Filters");
214
+ fireEvent.click(applyButton);
215
+
216
+ expect(defaultProps.onApplyFilters).toHaveBeenCalledWith(
217
+ expect.objectContaining({
218
+ ageFrom: "12"
219
+ })
220
+ );
221
+ });
222
+
223
+ it("calls onApplyFilters when apply button is clicked with sort filter", () => {
224
+ render(<ChildSearchFilters {...defaultProps} isAdvancedFiltersOpen={true} />);
225
+
226
+ const sortBySelect = screen.getAllByTestId("select")[2];
227
+ fireEvent.change(sortBySelect, { target: { value: "first_name" } });
228
+
229
+ const applyButton = screen.getByText("Apply Filters");
230
+ fireEvent.click(applyButton);
231
+
232
+ expect(defaultProps.onApplyFilters).toHaveBeenCalledWith(
233
+ expect.objectContaining({
234
+ sortBy: "first_name"
235
+ })
236
+ );
237
+ });
238
+
239
+ it("displays correct filter values", () => {
240
+ const filtersWithValues = {
241
+ ...mockFilters,
242
+ status: "inactive",
243
+ selectedSiteId: "1",
244
+ dobFrom: "2020-01-01",
245
+ dobTo: "2020-12-31",
246
+ ageFrom: "12",
247
+ ageTo: "60",
248
+ sortBy: "first_name",
249
+ sortOrder: "desc",
250
+ };
251
+
252
+ render(
253
+ <ChildSearchFilters
254
+ {...defaultProps}
255
+ filters={filtersWithValues}
256
+ isAdvancedFiltersOpen={true}
257
+ />
258
+ );
259
+
260
+ const statusSelect = screen.getAllByTestId("select")[0];
261
+ const siteSelect = screen.getAllByTestId("select")[1];
262
+ const dateRangePicker = screen.getByTestId("date-range-picker");
263
+ const ageFromInput = screen.getAllByTestId("input")[0];
264
+ const ageToInput = screen.getAllByTestId("input")[1];
265
+ const sortBySelect = screen.getAllByTestId("select")[2];
266
+ const sortOrderSelect = screen.getAllByTestId("select")[3];
267
+
268
+ expect(statusSelect).toHaveValue("inactive");
269
+ expect(siteSelect).toHaveValue("1");
270
+ expect(dateRangePicker).toBeInTheDocument();
271
+ expect(ageFromInput).toHaveValue(12);
272
+ expect(ageToInput).toHaveValue(60);
273
+ expect(sortBySelect).toHaveValue("first_name");
274
+ expect(sortOrderSelect).toHaveValue("desc");
275
+ });
276
+
277
+ it("has correct accessibility attributes", () => {
278
+ render(<ChildSearchFilters {...defaultProps} isAdvancedFiltersOpen={true} />);
279
+
280
+ // Check that all inputs have proper labels
281
+ expect(screen.getByText("Status")).toBeInTheDocument();
282
+ expect(screen.getByText("Site")).toBeInTheDocument();
283
+ expect(screen.getByText("Date of Birth Range")).toBeInTheDocument();
284
+ expect(screen.getByText("Age From (months)")).toBeInTheDocument();
285
+ expect(screen.getByText("Age To (months)")).toBeInTheDocument();
286
+ expect(screen.getByText("Sort By")).toBeInTheDocument();
287
+ expect(screen.getByText("Sort Order")).toBeInTheDocument();
288
+ });
289
+
290
+ it("applies correct CSS classes for dark mode support", () => {
291
+ render(<ChildSearchFilters {...defaultProps} isAdvancedFiltersOpen={true} />);
292
+
293
+ const inputs = screen.getAllByTestId("input");
294
+ const selects = screen.getAllByTestId("select");
295
+
296
+ inputs.forEach(input => {
297
+ expect(input.className).toContain("dark:bg-gray-800");
298
+ expect(input.className).toContain("dark:border-gray-600");
299
+ expect(input.className).toContain("dark:text-white");
300
+ });
301
+
302
+ selects.forEach(select => {
303
+ expect(select.className).toContain("dark:bg-gray-800");
304
+ expect(select.className).toContain("dark:border-gray-600");
305
+ expect(select.className).toContain("dark:text-white");
306
+ });
307
+ });
308
+ });