Skip to content

Commit ec70945

Browse files
committedAug 24, 2023
Add ImageTableFilter component
Add new filter component to handle filtering of image data by region, architecture and provider Add new component to ImageTable
1 parent d038c00 commit ec70945

File tree

2 files changed

+295
-10
lines changed

2 files changed

+295
-10
lines changed
 

‎src/app/components/ImageTable.tsx

+175-10
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
Toolbar,
66
ToolbarContent,
77
ToolbarItem,
8+
ToolbarFilter,
89
Pagination,
910
Drawer,
1011
DrawerContent,
@@ -14,6 +15,7 @@ import {
1415
import { Table, Thead, Tr, Th, ThProps, Tbody, Td } from '@patternfly/react-table';
1516
import { fetch } from 'cross-fetch'
1617
import { DetailsDrawer } from './DetailsDrawer';
18+
import { ImageTableFilter } from './ImageTableFilter';
1719
interface ImageData {
1820
name: string;
1921
version: string;
@@ -24,7 +26,8 @@ interface ImageData {
2426
date: string;
2527
selflink: URL;
2628
virt: string;
27-
};
29+
}
30+
2831

2932
export const ImageTable: React.FunctionComponent = () => {
3033
const [search, setSearch] = React.useState('');
@@ -35,15 +38,137 @@ export const ImageTable: React.FunctionComponent = () => {
3538
const [perPage, setPerPage] = React.useState(20);
3639
const [isDrawerExpanded, setIsExpanded] = React.useState(false);
3740
const [selectedImage, setSelectedImage] = React.useState({});
41+
42+
const [providerSelections, setProviderSelections] = React.useState<string[]>([]);
43+
const [regionSelections, setRegionSelections] = React.useState<string[]>([]);
44+
const [architectureSelections, setArchitectureSelections] = React.useState<string[]>([]);
45+
const [filterConfig, setFilterConfig] = React.useState<object[]>([]);
46+
3847
const drawerRef = React.useRef<HTMLDivElement>();
3948

49+
const onFilter = (image: ImageData) => {
50+
const matchesProviderValue = providerSelections.includes(image.provider.toLowerCase());
51+
const matchesRegionValue = regionSelections.includes(image.region.toLowerCase());
52+
const matchesArchitectureValue = architectureSelections.includes(image.arch.toLowerCase());
53+
54+
return (
55+
(providerSelections.length === 0 || matchesProviderValue)
56+
&& (regionSelections.length === 0 || matchesRegionValue)
57+
&& (architectureSelections.length === 0 || matchesArchitectureValue)
58+
);
59+
};
60+
61+
const onFilterSelect = (_: React.MouseEvent | undefined, itemId: string | number | undefined) => {
62+
if (!itemId || typeof itemId !== 'string') {
63+
return;
64+
}
65+
66+
const itemIdElements = itemId.split('/');
67+
const category = itemIdElements[0];
68+
const item = itemIdElements[1];
69+
let selections: string[] = [];
70+
71+
switch (category) {
72+
case 'provider':
73+
selections = providerSelections.includes(item)
74+
? providerSelections.filter((fil: string) => fil !== item)
75+
: [item, ...providerSelections]
76+
setProviderSelections(selections);
77+
break;
78+
case 'region':
79+
selections = regionSelections.includes(item)
80+
? regionSelections.filter((fil: string) => fil !== item)
81+
: [item, ...regionSelections]
82+
setRegionSelections(selections);
83+
break;
84+
case 'architecture':
85+
selections = architectureSelections.includes(item)
86+
? architectureSelections.filter((fil: string) => fil !== item)
87+
: [item, ...architectureSelections]
88+
setArchitectureSelections(selections);
89+
break;
90+
default:
91+
break;
92+
}
93+
setPage(1);
94+
};
95+
96+
const onFilterDelete = (type: string, id: string) => {
97+
switch (type) {
98+
case 'provider':
99+
if (id === 'all') {
100+
setProviderSelections([]);
101+
} else {
102+
setProviderSelections(providerSelections.filter((fil: string) => fil !== id));
103+
}
104+
break;
105+
case 'region':
106+
if (id === 'all') {
107+
setRegionSelections([]);
108+
} else {
109+
setRegionSelections(regionSelections.filter((fil: string) => fil !== id));
110+
}
111+
break;
112+
case 'architecture':
113+
if (id === 'all') {
114+
setArchitectureSelections([]);
115+
} else {
116+
setArchitectureSelections(architectureSelections.filter((fil: string) => fil !== id));
117+
}
118+
break;
119+
default:
120+
setProviderSelections([]);
121+
setRegionSelections([]);
122+
setArchitectureSelections([]);
123+
break;
124+
}
125+
setPage(1);
126+
};
127+
128+
const isFilterSelected = (category: string, itemId: string): boolean => {
129+
let filterSelected = false;
130+
switch (category) {
131+
case 'provider':
132+
filterSelected = providerSelections.includes(itemId);
133+
break;
134+
case 'region':
135+
filterSelected = regionSelections.includes(itemId);
136+
break;
137+
case 'architecture':
138+
filterSelected = architectureSelections.includes(itemId);
139+
break;
140+
default:
141+
break;
142+
}
143+
return filterSelected;
144+
};
145+
146+
const getSelectedFiltersByCategory = (category: string): string[] => {
147+
let filters = [] as string[];
148+
switch (category) {
149+
case 'provider':
150+
filters = providerSelections;
151+
break;
152+
case 'region':
153+
filters = regionSelections;
154+
break;
155+
case 'architecture':
156+
filters = architectureSelections;
157+
break;
158+
default:
159+
break;
160+
}
161+
return filters;
162+
};
163+
164+
40165
const onDrawerExpand = () => {
41166
drawerRef.current && drawerRef.current.focus();
42167
};
43168

44169
const onDrawerOpenClick = (details: object) => {
45170
setIsExpanded(true);
46-
setSelectedImage(details)
171+
setSelectedImage(details);
47172
};
48173

49174
const onDrawerCloseClick = () => {
@@ -56,21 +181,35 @@ export const ImageTable: React.FunctionComponent = () => {
56181
region: 'Region',
57182
arch: 'Architecture',
58183
date: 'Release Date'
59-
}
184+
};
60185

61186
useEffect(() => {
62-
loadImageData()
187+
loadImageData();
63188
}, [])
64189

65190
const loadImageData = () => {
66191
fetch('https://imagedirectory.cloud/images/v2/all', {
67192
method: 'get',
68193
})
69194
.then(res => res.json())
70-
.then(data => {
71-
setImageData(data)
195+
.then((data) => {
196+
setImageData(data);
197+
setFilterConfig([
198+
{
199+
category: 'provider',
200+
data: [...new Set(data.map(item => item['provider']))]
201+
},
202+
{
203+
category: 'region',
204+
data: [...new Set(data.map(item => item['region']))]
205+
},
206+
{
207+
category: 'architecture',
208+
data: [...new Set(data.map(item => item['arch'].toLowerCase()))]
209+
}
210+
]);
72211
})
73-
}
212+
};
74213

75214
const handleSearch = (event) => {
76215
setIsExpanded(false);
@@ -109,11 +248,12 @@ export const ImageTable: React.FunctionComponent = () => {
109248
setPage(newPage);
110249
};
111250

112-
const searchedImageData = imageData.filter((item: ImageData) =>
251+
const filteredImageData = imageData.filter(onFilter);
252+
const searchedImageData = filteredImageData.filter((item: ImageData) =>
113253
item.name.toLowerCase().includes(search.toLowerCase())
114254
);
115255

116-
const paginatedImageData = searchedImageData.slice((page - 1) * perPage, page * perPage)
256+
const paginatedImageData = searchedImageData.slice((page - 1) * perPage, page * perPage);
117257

118258
let sortedImageData = paginatedImageData;
119259
if (activeSortIndex !== undefined) {
@@ -147,7 +287,9 @@ export const ImageTable: React.FunctionComponent = () => {
147287
return (
148288
<React.Fragment>
149289
<Title headingLevel='h1'>Browse Images</Title>
150-
<Toolbar id="toolbar-top">
290+
<Toolbar
291+
id="toolbar-top"
292+
clearAllFilters={() => onFilterDelete('', '')}>
151293
<ToolbarContent>
152294
<ToolbarItem variant="search-filter">
153295
<SearchInput
@@ -156,6 +298,29 @@ export const ImageTable: React.FunctionComponent = () => {
156298
placeholder='Search by name'
157299
/>
158300
</ToolbarItem>
301+
{filterConfig.map((filter: object) => {
302+
const category = filter['category'];
303+
const data = filter['data'];
304+
return (
305+
<ToolbarItem key={category}>
306+
<ToolbarFilter
307+
chips={getSelectedFiltersByCategory(category)}
308+
deleteChip={(category, chip) => onFilterDelete(category as string, chip as string)}
309+
deleteChipGroup={() => onFilterDelete(category, 'all')}
310+
categoryName={category}
311+
>
312+
<ImageTableFilter
313+
onFilterSelect={onFilterSelect}
314+
category={category}
315+
filters={data}
316+
isFilterSelected={isFilterSelected}
317+
totalFilterCount={getSelectedFiltersByCategory(category).length} />
318+
</ToolbarFilter>
319+
</ToolbarItem>
320+
)
321+
}
322+
)}
323+
159324
<ToolbarItem alignment={{
160325
default: 'alignRight'
161326
}}>
+120
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import React, { ReactElement } from 'react';
2+
import { FilterIcon } from '@patternfly/react-icons';
3+
import {
4+
Badge,
5+
Menu,
6+
MenuContent,
7+
MenuList,
8+
MenuItem,
9+
MenuToggle,
10+
Popper,
11+
MenuGroup
12+
} from '@patternfly/react-core';
13+
14+
export const ImageTableFilter: React.FunctionComponent<{
15+
onFilterSelect: (
16+
event: React.MouseEvent | undefined,
17+
itemId: string | number | undefined) => void,
18+
isFilterSelected: (
19+
category: string,
20+
itemId: string) => boolean,
21+
category: string,
22+
filters: string[],
23+
totalFilterCount: number,
24+
}> = ({
25+
onFilterSelect,
26+
category,
27+
filters,
28+
isFilterSelected,
29+
totalFilterCount
30+
}) => {
31+
const [isOpen, setIsOpen] = React.useState<boolean>(false);
32+
const toggleRef = React.useRef<HTMLButtonElement>(null);
33+
const menuRef = React.useRef<HTMLDivElement>(null);
34+
const containerRef = React.useRef<HTMLDivElement>(null);
35+
36+
React.useEffect(() => {
37+
const handleKeys = (event: KeyboardEvent) => {
38+
if (isOpen && menuRef.current?.contains(event.target as Node)) {
39+
if (event.key === 'Escape' || event.key === 'Tab') {
40+
setIsOpen(!isOpen);
41+
toggleRef.current?.focus();
42+
}
43+
}
44+
};
45+
46+
const handleClickOutside = (event: MouseEvent) => {
47+
if (isOpen && !menuRef.current?.contains(event.target as Node)) {
48+
setIsOpen(false);
49+
}
50+
};
51+
window.addEventListener('keydown', handleKeys);
52+
window.addEventListener('click', handleClickOutside);
53+
return () => {
54+
window.removeEventListener('keydown', handleKeys);
55+
window.removeEventListener('click', handleClickOutside);
56+
};
57+
}, [isOpen, menuRef]);
58+
59+
const onToggleClick = (ev: React.MouseEvent) => {
60+
ev.stopPropagation();
61+
setTimeout(() => {
62+
if (menuRef.current) {
63+
const firstElement = menuRef.current.querySelector('li > button:not(:disabled)');
64+
firstElement && (firstElement as HTMLElement).focus();
65+
}
66+
}, 0);
67+
setIsOpen(!isOpen);
68+
};
69+
70+
const createMenuGroup = (category: string, items: string[]): ReactElement => {
71+
return (
72+
<MenuGroup label={category}>
73+
{
74+
items.map((item, index) => {
75+
return (
76+
<MenuItem hasCheck key={`${category}-${index}`} isSelected={isFilterSelected(category, item)} itemId={`${category}/${item}`}>
77+
{item}
78+
</MenuItem>
79+
)
80+
})
81+
}
82+
</MenuGroup>
83+
)
84+
}
85+
86+
const menuItemGroups = createMenuGroup(category, filters)
87+
88+
const menu = (
89+
<Menu
90+
isScrollable
91+
ref={menuRef}
92+
id="image-filter-menu"
93+
onSelect={onFilterSelect}
94+
>
95+
<MenuContent maxMenuHeight="300px">
96+
<MenuList>
97+
{menuItemGroups}
98+
</MenuList>
99+
</MenuContent>
100+
</Menu>
101+
);
102+
103+
const toggle = (
104+
<MenuToggle
105+
ref={toggleRef}
106+
onClick={onToggleClick}
107+
isExpanded={isOpen}
108+
icon={<FilterIcon />}
109+
>
110+
Filter {category}
111+
{totalFilterCount > 0 && <Badge isRead>{totalFilterCount}</Badge>}
112+
</MenuToggle>
113+
)
114+
115+
return (
116+
<div ref={containerRef}>
117+
<Popper direction="down" trigger={toggle} popper={menu} appendTo={containerRef.current || undefined} isVisible={isOpen} />
118+
</div>
119+
);
120+
}

0 commit comments

Comments
 (0)
Please sign in to comment.