Skip to content

Commit 561a827

Browse files
committed
[FEATURE] Table: Add fitler to the table
Signed-off-by: Mahmoud Shahrokni <seyedmahmoud.shahrokni@amadeus.com> Signed-off-by: Mahmoud Shahrokni <seyedmahmoud.shahrokni@amadeus.com> Signed-off-by: Mahmoud Shahrokni <seyedmahmoud.shahrokni@amadeus.com> Signed-off-by: Mahmoud Shahrokni <seyedmahmoud.shahrokni@amadeus.com> Signed-off-by: Mahmoud Shahrokni <seyedmahmoud.shahrokni@amadeus.com>
1 parent 9ebdd4f commit 561a827

5 files changed

Lines changed: 448 additions & 18 deletions

File tree

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import { Box, ButtonBase, Typography, useTheme } from '@mui/material';
2+
import { ReactElement, useMemo, useRef, useState } from 'react';
3+
import { ColumnFilterDropdown } from './ColumnFilterDropDown';
4+
import { TableColumnConfig } from './model/table-model';
5+
import { FilterColumns } from './TableFilters';
6+
7+
interface Props<TableData> extends FilterColumns {
8+
id: string;
9+
width?: number | 'auto';
10+
filters: Array<string | number>;
11+
borderRight: string;
12+
column: TableColumnConfig<TableData>;
13+
columnUniqueValues: Record<string, Array<string | number>>;
14+
openFilterColumn?: string;
15+
setOpenFilterColumn: (columnId?: string) => void;
16+
}
17+
18+
export function ColumnFilter<TableData>({
19+
id,
20+
width,
21+
filters,
22+
column,
23+
setColumnFilters,
24+
columnFilters,
25+
borderRight,
26+
columnUniqueValues,
27+
openFilterColumn,
28+
setOpenFilterColumn,
29+
}: Props<TableData>): ReactElement {
30+
const theme = useTheme();
31+
const dropdownId = id.concat('-dropdown');
32+
33+
const [filterAnchorEl, setFilterAnchorEl] = useState<{ [key: string]: HTMLElement | undefined }>({});
34+
const [calculatedWidth, setCalculatedWidth] = useState<string>('0px');
35+
36+
const handleFilterClick = (event: React.MouseEvent<HTMLButtonElement>, columnId: string): void => {
37+
event.preventDefault();
38+
event.stopPropagation();
39+
setFilterAnchorEl({ ...filterAnchorEl, [columnId]: event.currentTarget });
40+
setOpenFilterColumn(columnId);
41+
};
42+
43+
const handleFilterClose = (): void => {
44+
setFilterAnchorEl({});
45+
setOpenFilterColumn(undefined);
46+
};
47+
48+
const updateColumnFilter = (columnId: string, values: Array<string | number>): void => {
49+
const newFilters = columnFilters.filter((f) => f.id !== columnId);
50+
if (values.length) {
51+
newFilters.push({ id: columnId, value: values });
52+
}
53+
setColumnFilters(newFilters);
54+
};
55+
56+
const mainContainerRef = useRef<HTMLDivElement>(null);
57+
const [mainContainerDimension, setMainContainerDimension] = useState<{ width: number; height: number }>({
58+
width: 0,
59+
height: 0,
60+
});
61+
62+
const observeDimensionChanges = (htmlElements: ResizeObserverEntry[]): void => {
63+
if (htmlElements?.length) {
64+
const targetElement = htmlElements[0]?.target as HTMLElement;
65+
const width = targetElement.offsetWidth;
66+
const height = targetElement.offsetHeight;
67+
setMainContainerDimension({ width, height });
68+
}
69+
};
70+
71+
/**
72+
* Width is taken from the optional column.width. Therefore, it could be possibly undefined
73+
* To handle this, we need the actual width of the container to adjust the width of the dropdown. They need to be perfectly aligned
74+
* Also, using an observer is necessary due to the effects of the toggle view mode which changes the table dimension
75+
*/
76+
const observer = useRef(new ResizeObserver(observeDimensionChanges));
77+
if (mainContainerRef.current) {
78+
observer.current.observe(mainContainerRef.current);
79+
}
80+
81+
useMemo(() => {
82+
if (width !== undefined) {
83+
setCalculatedWidth(typeof width === 'number' ? `${width}px` : width);
84+
} else if (mainContainerDimension) {
85+
setCalculatedWidth(`${mainContainerDimension.width}px`);
86+
}
87+
}, [width, mainContainerDimension]);
88+
89+
return (
90+
<Box
91+
key={id}
92+
data-testid={id}
93+
ref={mainContainerRef}
94+
sx={{
95+
padding: '8px',
96+
borderRight: borderRight,
97+
width: width,
98+
minWidth: width,
99+
maxWidth: width,
100+
display: 'flex',
101+
alignItems: 'center',
102+
position: 'relative',
103+
boxSizing: 'border-box',
104+
flex: typeof width === 'number' ? 'none' : '1 1 auto',
105+
}}
106+
>
107+
<Typography
108+
variant="body2"
109+
color="text.secondary"
110+
noWrap
111+
component="span"
112+
sx={{
113+
mr: 1,
114+
flex: 1,
115+
fontSize: '12px',
116+
minWidth: '100px',
117+
}}
118+
>
119+
{filters.length ? `${filters.length} items` : 'All'}
120+
</Typography>
121+
122+
<ButtonBase
123+
onClick={(e) => handleFilterClick(e, column.accessorKey as string)}
124+
sx={{
125+
border: '1px solid',
126+
borderColor: 'divider',
127+
backgroundColor: 'background.paper',
128+
fontSize: '12px',
129+
color: filters.length ? 'primary.main' : 'text.secondary',
130+
px: 1,
131+
py: 0.5,
132+
borderRadius: 1,
133+
minWidth: '20px',
134+
height: '24px',
135+
flexShrink: 0,
136+
transition: (theme) => theme.transitions.create('all', { duration: 200 }),
137+
'&:hover': {
138+
backgroundColor: 'action.hover',
139+
},
140+
}}
141+
>
142+
143+
</ButtonBase>
144+
145+
{openFilterColumn === column.accessorKey && (
146+
<ColumnFilterDropdown
147+
id={dropdownId}
148+
width={calculatedWidth}
149+
allValues={columnUniqueValues[column.accessorKey as string] || []}
150+
selectedValues={filters}
151+
onFilterChange={(values) => updateColumnFilter(column.accessorKey as string, values)}
152+
theme={theme}
153+
handleFilterClose={handleFilterClose}
154+
/>
155+
)}
156+
</Box>
157+
);
158+
}
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
// Copyright The Perses Authors
2+
// Licensed under the Apache License, Version 2.0 (the "License");
3+
// you may not use this file except in compliance with the License.
4+
// You may obtain a copy of the License at
5+
//
6+
// http://www.apache.org/licenses/LICENSE-2.0
7+
//
8+
// Unless required by applicable law or agreed to in writing, software
9+
// distributed under the License is distributed on an "AS IS" BASIS,
10+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
// See the License for the specific language governing permissions and
12+
// limitations under the License.
13+
14+
import { ReactElement } from 'react';
15+
import { Box, Checkbox, Divider, FormControlLabel, Theme, Typography, ClickAwayListener } from '@mui/material';
16+
17+
interface Props {
18+
id: string;
19+
allValues: Array<string | number>;
20+
selectedValues: Array<string | number>;
21+
onFilterChange: (values: Array<string | number>) => void;
22+
handleFilterClose: () => void;
23+
theme: Theme;
24+
width: string;
25+
}
26+
27+
export const ColumnFilterDropdown = ({
28+
id,
29+
allValues,
30+
selectedValues,
31+
onFilterChange,
32+
handleFilterClose,
33+
theme,
34+
width,
35+
}: Props): ReactElement => {
36+
const values = [...new Set(allValues)].filter((v) => v !== null).sort();
37+
38+
if (!values.length) {
39+
return (
40+
<ClickAwayListener onClickAway={handleFilterClose}>
41+
<Box
42+
sx={{
43+
position: 'absolute',
44+
top: '100%',
45+
left: 0,
46+
zIndex: 9999,
47+
marginTop: '4px',
48+
}}
49+
>
50+
<Box
51+
data-filter-dropdown
52+
data-testid={id}
53+
sx={{
54+
width: width,
55+
padding: 10,
56+
backgroundColor: theme.palette.background.paper,
57+
border: `1px solid ${theme.palette.divider}`,
58+
boxShadow: theme.shadows[4],
59+
}}
60+
>
61+
<Typography sx={{ color: theme.palette.text.secondary, fontSize: 14 }}>No values found</Typography>
62+
</Box>
63+
</Box>
64+
</ClickAwayListener>
65+
);
66+
}
67+
68+
return (
69+
<ClickAwayListener onClickAway={handleFilterClose}>
70+
<Box
71+
sx={{
72+
position: 'absolute',
73+
top: '100%',
74+
left: 0,
75+
zIndex: 9999,
76+
marginTop: '4px',
77+
}}
78+
>
79+
<Box
80+
data-filter-dropdown
81+
data-testid={id}
82+
sx={{
83+
width: width,
84+
padding: '10px',
85+
backgroundColor: theme.palette.background.paper,
86+
border: `1px solid ${theme.palette.divider}`,
87+
boxShadow: theme.shadows[4],
88+
maxHeight: 250,
89+
overflowY: 'auto',
90+
}}
91+
>
92+
<Box style={{ marginBottom: 8, fontSize: 14, fontWeight: 'bold' }}>
93+
<FormControlLabel
94+
control={
95+
<Checkbox
96+
checked={selectedValues.length === values.length && values.length > 0}
97+
onChange={(e) => onFilterChange(e.target.checked ? values : [])}
98+
indeterminate={selectedValues.length > 0 && selectedValues.length < values.length}
99+
/>
100+
}
101+
label={<Typography sx={{ color: 'text.primary' }}>Select All ({values.length})</Typography>}
102+
/>
103+
</Box>
104+
<Divider sx={{ my: 1 }} />
105+
{values.map((value, index) => (
106+
<Box key={`value-${index}`} style={{ marginBottom: 4 }}>
107+
<FormControlLabel
108+
sx={{
109+
display: 'flex',
110+
alignItems: 'center',
111+
padding: '2px 0',
112+
borderRadius: '4px',
113+
cursor: 'pointer',
114+
}}
115+
control={
116+
<Checkbox
117+
size="small"
118+
checked={selectedValues.includes(value)}
119+
onChange={(e) => {
120+
if (e.target.checked) {
121+
onFilterChange([...selectedValues, value]);
122+
} else {
123+
onFilterChange(selectedValues.filter((v) => v !== value));
124+
}
125+
}}
126+
/>
127+
}
128+
label={
129+
<Typography variant="body2" sx={{ color: 'text.primary', fontSize: 14 }}>
130+
{!value && value !== 0 ? '(empty)' : String(value)}
131+
</Typography>
132+
}
133+
/>
134+
</Box>
135+
))}
136+
</Box>
137+
</Box>
138+
</ClickAwayListener>
139+
);
140+
};

0 commit comments

Comments
 (0)