Skip to content

Commit c9d9b1a

Browse files
Feat: checkbox filter enhancements (#1107)
* nestedcheckbox disable state + debounce * recursive nested checkbox
1 parent 1d92b04 commit c9d9b1a

File tree

3 files changed

+253
-85
lines changed

3 files changed

+253
-85
lines changed
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import {
2+
checkboxCheckValue,
3+
checkboxIndeterminateValue,
4+
type CheckboxStructure,
5+
} from './NestedCheckboxList';
6+
7+
const baseCheckbox = (
8+
overrides: Partial<CheckboxStructure> = {},
9+
): CheckboxStructure => ({
10+
title: 'Test Checkbox',
11+
type: 'checkbox',
12+
checked: false,
13+
disabled: false,
14+
children: [],
15+
...overrides,
16+
});
17+
18+
describe('checkboxCheckValue', () => {
19+
it('returns false if checkbox is disabled', () => {
20+
const checkbox = {
21+
checked: true,
22+
disabled: true,
23+
children: [],
24+
} as unknown as CheckboxStructure;
25+
expect(checkboxCheckValue(checkbox, false)).toBe(false);
26+
});
27+
28+
it('returns false if disableAll is true', () => {
29+
const checkbox = {
30+
checked: true,
31+
disabled: false,
32+
children: [],
33+
} as unknown as CheckboxStructure;
34+
expect(checkboxCheckValue(checkbox, true)).toBe(false);
35+
});
36+
37+
it('returns true if checked is true and not disabled', () => {
38+
const checkbox = {
39+
checked: true,
40+
disabled: false,
41+
children: [],
42+
} as unknown as CheckboxStructure;
43+
expect(checkboxCheckValue(checkbox, false)).toBe(true);
44+
});
45+
46+
it('returns true if all children are checked', () => {
47+
const checkbox = {
48+
checked: false,
49+
disabled: false,
50+
children: [
51+
{ checked: true, disabled: false },
52+
{ checked: true, disabled: false },
53+
],
54+
} as unknown as CheckboxStructure;
55+
expect(checkboxCheckValue(checkbox, false)).toBe(true);
56+
});
57+
58+
it('returns false if any child is not checked', () => {
59+
const checkbox = {
60+
checked: false,
61+
disabled: false,
62+
children: [
63+
{ checked: true, disabled: false },
64+
{ checked: false, disabled: false },
65+
],
66+
} as unknown as CheckboxStructure;
67+
expect(checkboxCheckValue(checkbox, false)).toBe(false);
68+
});
69+
});
70+
71+
describe('checkboxIndeterminateValue', () => {
72+
it('returns false if disableAll is true', () => {
73+
const checkbox = baseCheckbox({
74+
children: [baseCheckbox({ checked: true, disabled: false })],
75+
});
76+
expect(checkboxIndeterminateValue(checkbox, true)).toBe(false);
77+
});
78+
79+
it('returns false if children is undefined', () => {
80+
const checkbox = baseCheckbox({
81+
children: undefined,
82+
});
83+
expect(checkboxIndeterminateValue(checkbox, false)).toBe(false);
84+
});
85+
86+
it('returns false if all children are checked', () => {
87+
const checkbox = baseCheckbox({
88+
children: [
89+
baseCheckbox({ checked: true, disabled: false }),
90+
baseCheckbox({ checked: true, disabled: false }),
91+
],
92+
});
93+
expect(checkboxIndeterminateValue(checkbox, false)).toBe(false);
94+
});
95+
96+
it('returns false if no children are checked', () => {
97+
const checkbox = baseCheckbox({
98+
children: [
99+
baseCheckbox({ checked: false, disabled: false }),
100+
baseCheckbox({ checked: false, disabled: false }),
101+
],
102+
});
103+
expect(checkboxIndeterminateValue(checkbox, false)).toBe(false);
104+
});
105+
106+
it('returns true if some children are checked and some are not', () => {
107+
const checkbox = baseCheckbox({
108+
children: [
109+
baseCheckbox({ checked: true, disabled: false }),
110+
baseCheckbox({ checked: false, disabled: false }),
111+
],
112+
});
113+
expect(checkboxIndeterminateValue(checkbox, false)).toBe(true);
114+
});
115+
116+
it('returns false if all children are disabled', () => {
117+
const checkbox = baseCheckbox({
118+
children: [
119+
baseCheckbox({ checked: true, disabled: true }),
120+
baseCheckbox({ checked: false, disabled: true }),
121+
],
122+
});
123+
expect(checkboxIndeterminateValue(checkbox, false)).toBe(false);
124+
});
125+
126+
it('returns true if some children are checked and some are not, and not all children are disabled', () => {
127+
const checkbox = baseCheckbox({
128+
children: [
129+
baseCheckbox({ checked: true, disabled: false }),
130+
baseCheckbox({ checked: false, disabled: true }),
131+
],
132+
});
133+
expect(checkboxIndeterminateValue(checkbox, false)).toBe(true);
134+
});
135+
});

web-app/src/app/components/NestedCheckboxList.tsx

Lines changed: 92 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {
2+
Box,
23
Checkbox,
34
Collapse,
45
IconButton,
@@ -10,44 +11,100 @@ import {
1011
} from '@mui/material';
1112
import * as React from 'react';
1213
import { ExpandLess, ExpandMore } from '@mui/icons-material';
14+
import { useCallback, useRef } from 'react';
1315

1416
interface NestedCheckboxListProps {
1517
checkboxData: CheckboxStructure[];
1618
onCheckboxChange: (checkboxData: CheckboxStructure[]) => void;
1719
onExpandGroupChange?: (checkboxData: CheckboxStructure[]) => void;
20+
disableAll?: boolean;
21+
debounceTime?: number;
1822
}
1923

20-
// NOTE: Although the data structure allows for multiple levels of nesting, the current implementation only supports two levels.
21-
// TODO: Implement support for multiple levels of nesting
2224
export interface CheckboxStructure {
2325
title: string;
2426
type: 'label' | 'checkbox';
2527
checked: boolean;
2628
seeChildren?: boolean;
2729
children?: CheckboxStructure[];
30+
disabled?: boolean;
2831
}
2932

33+
function useDebouncedCallback(callback: () => void, delay: number): () => void {
34+
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
35+
36+
const debouncedFunction = useCallback(() => {
37+
if (timeoutRef.current != null) {
38+
clearTimeout(timeoutRef.current);
39+
}
40+
41+
timeoutRef.current = setTimeout(() => {
42+
callback();
43+
}, delay);
44+
}, [callback, delay]);
45+
46+
return debouncedFunction;
47+
}
48+
49+
export const checkboxCheckValue = (
50+
checkboxData: CheckboxStructure,
51+
disableAll: boolean,
52+
): boolean => {
53+
if ((checkboxData.disabled != null && checkboxData.disabled) || disableAll) {
54+
return false;
55+
}
56+
return (
57+
checkboxData.checked ||
58+
(checkboxData.children !== undefined &&
59+
checkboxData.children.length > 0 &&
60+
checkboxData.children.every((child) => child.checked))
61+
);
62+
};
63+
64+
export const checkboxIndeterminateValue = (
65+
checkboxData: CheckboxStructure,
66+
disableAll: boolean,
67+
): boolean => {
68+
if (disableAll || checkboxData.children == undefined) {
69+
return false;
70+
}
71+
const checkboxCheck =
72+
checkboxData.children.some((child) => child.checked) &&
73+
!checkboxData.children.every((child) => child.checked);
74+
const areAllChildrenDisabled = checkboxData.children.every(
75+
(child) => child.disabled,
76+
);
77+
return !areAllChildrenDisabled && checkboxCheck;
78+
};
79+
3080
export default function NestedCheckboxList({
3181
checkboxData,
3282
onCheckboxChange,
3383
onExpandGroupChange,
84+
disableAll = false,
85+
debounceTime = 0,
3486
}: NestedCheckboxListProps): JSX.Element {
35-
const [checkboxStructure, setCheckboxStructure] =
36-
React.useState<CheckboxStructure[]>(checkboxData);
87+
const [checkboxStructure, setCheckboxStructure] = React.useState<
88+
CheckboxStructure[]
89+
>([...checkboxData]);
3790
const [hasChange, setHasChange] = React.useState<boolean>(false);
3891
const theme = useTheme();
3992

4093
React.useEffect(() => {
4194
if (hasChange) {
4295
setHasChange(false);
43-
onCheckboxChange(checkboxStructure);
96+
debouncedSubmit();
4497
}
4598
}, [checkboxStructure]);
4699

47100
React.useEffect(() => {
48101
setCheckboxStructure(checkboxData);
49102
}, [checkboxData]);
50103

104+
const debouncedSubmit = useDebouncedCallback(() => {
105+
onCheckboxChange(checkboxStructure);
106+
}, debounceTime);
107+
51108
return (
52109
<List sx={{ width: '100%' }} dense>
53110
{checkboxStructure.map((checkboxData, index) => {
@@ -71,6 +128,7 @@ export default function NestedCheckboxList({
71128
{checkboxData.children !== undefined &&
72129
checkboxData.children?.length > 0 && (
73130
<IconButton
131+
disabled={disableAll || checkboxData.disabled}
74132
edge={'end'}
75133
aria-label='expand'
76134
onClick={() => {
@@ -85,7 +143,8 @@ export default function NestedCheckboxList({
85143
}
86144
}}
87145
>
88-
{checkboxData.seeChildren !== undefined &&
146+
{!disableAll &&
147+
checkboxData.seeChildren != undefined &&
89148
checkboxData.seeChildren ? (
90149
<ExpandLess />
91150
) : (
@@ -99,6 +158,7 @@ export default function NestedCheckboxList({
99158
{checkboxData.type === 'checkbox' && (
100159
<ListItemButton
101160
role={undefined}
161+
disabled={disableAll || checkboxData.disabled}
102162
dense={true}
103163
sx={{ p: 0 }}
104164
onClick={() => {
@@ -118,18 +178,11 @@ export default function NestedCheckboxList({
118178
tabIndex={-1}
119179
disableRipple
120180
inputProps={{ 'aria-labelledby': labelId }}
121-
checked={
122-
checkboxData.checked ||
123-
(checkboxData.children !== undefined &&
124-
checkboxData.children.length > 0 &&
125-
checkboxData.children.every((child) => child.checked))
126-
}
127-
indeterminate={
128-
checkboxData.children !== undefined
129-
? checkboxData.children.some((child) => child.checked) &&
130-
!checkboxData.children.every((child) => child.checked)
131-
: false
132-
}
181+
checked={checkboxCheckValue(checkboxData, disableAll)}
182+
indeterminate={checkboxIndeterminateValue(
183+
checkboxData,
184+
disableAll,
185+
)}
133186
/>
134187
<ListItemText
135188
id={labelId}
@@ -151,62 +204,30 @@ export default function NestedCheckboxList({
151204
)}
152205
{checkboxData.children !== undefined && (
153206
<Collapse
154-
in={checkboxData.seeChildren}
207+
in={
208+
checkboxData.seeChildren != null &&
209+
checkboxData.seeChildren &&
210+
!disableAll
211+
}
155212
timeout='auto'
156213
unmountOnExit
157214
>
158-
<List
159-
sx={{
160-
ml: 1,
161-
display: { xs: 'flex', md: 'block' },
162-
flexWrap: 'wrap',
163-
}}
164-
dense
165-
>
166-
{checkboxData.children.map((value) => {
167-
const labelId = `checkbox-list-label-${value.title}`;
168-
169-
return (
170-
<ListItem
171-
key={value.title}
172-
disablePadding
173-
sx={{ width: { xs: '50%', sm: '33%', md: '100%' } }}
174-
onClick={() => {
175-
setCheckboxStructure((prev) => {
176-
value.checked = !value.checked;
177-
if (!value.checked) {
178-
checkboxData.checked = false;
179-
}
180-
return [...prev];
181-
});
182-
setHasChange(true);
183-
}}
184-
>
185-
<ListItemButton
186-
role={undefined}
187-
dense={true}
188-
sx={{ p: 0, pl: 1 }}
189-
>
190-
<Checkbox
191-
edge='start'
192-
tabIndex={-1}
193-
disableRipple
194-
checked={value.checked || checkboxData.checked}
195-
inputProps={{ 'aria-labelledby': labelId }}
196-
/>
197-
198-
<ListItemText
199-
id={labelId}
200-
primary={`${value.title}`}
201-
primaryTypographyProps={{
202-
variant: 'body1',
203-
}}
204-
/>
205-
</ListItemButton>
206-
</ListItem>
207-
);
208-
})}
209-
</List>
215+
<Box sx={{ pl: 1, width: '100%' }}>
216+
<NestedCheckboxList
217+
checkboxData={checkboxData.children}
218+
onCheckboxChange={(children) => {
219+
setCheckboxStructure((prev) => {
220+
checkboxData.children = children;
221+
prev[index] = checkboxData;
222+
return [...prev];
223+
});
224+
setHasChange(true);
225+
}}
226+
onExpandGroupChange={onExpandGroupChange}
227+
disableAll={disableAll || checkboxData.disabled}
228+
debounceTime={0}
229+
></NestedCheckboxList>
230+
</Box>
210231
</Collapse>
211232
)}
212233
</ListItem>

0 commit comments

Comments
 (0)