Skip to content

Commit 34ae301

Browse files
committed
feat(filters): Implement DataViewFilters and text filter
1 parent 215918a commit 34ae301

File tree

8 files changed

+323
-28
lines changed

8 files changed

+323
-28
lines changed
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import React, { useMemo, useState, useRef, useEffect, ReactElement } from 'react';
2+
import {
3+
Menu, MenuContent, MenuItem, MenuList, MenuToggle, Popper, ToolbarGroup, ToolbarToggleGroup, ToolbarToggleGroupProps,
4+
} from '@patternfly/react-core';
5+
import { FilterIcon } from '@patternfly/react-icons';
6+
7+
// helper interface to generate attribute menu
8+
interface DataViewFilterIdentifiers {
9+
filterId: string;
10+
title: string;
11+
}
12+
13+
/** extends ToolbarToggleGroupProps */
14+
export interface DataViewFiltersProps<T extends object> extends Omit<ToolbarToggleGroupProps, 'toggleIcon' | 'breakpoint' | 'onChange'> {
15+
/** Content rendered inside the data view */
16+
children: React.ReactNode;
17+
/** Optional onChange callback shared across filters */
18+
onChange?: (key: string, newValues: Partial<T>) => void;
19+
/** Optional values shared across filters */
20+
values?: T;
21+
/** Icon for the toolbar toggle group */
22+
toggleIcon?: ToolbarToggleGroupProps['toggleIcon'];
23+
/** Breakpoint for the toolbar toggle group */
24+
breakpoint?: ToolbarToggleGroupProps['breakpoint'];
25+
/** Custom OUIA ID */
26+
ouiaId?: string;
27+
};
28+
29+
30+
export const DataViewFilters = <T extends object>({
31+
children,
32+
ouiaId = 'DataViewFilters',
33+
toggleIcon = <FilterIcon />,
34+
breakpoint = 'xl',
35+
onChange,
36+
values,
37+
...props
38+
}: DataViewFiltersProps<T>) => {
39+
const [ activeAttributeMenu, setActiveAttributeMenu ] = useState<string>('');
40+
const [ isAttributeMenuOpen, setIsAttributeMenuOpen ] = useState(false);
41+
const attributeToggleRef = useRef<HTMLButtonElement>(null);
42+
const attributeMenuRef = useRef<HTMLDivElement>(null);
43+
const attributeContainerRef = useRef<HTMLDivElement>(null);
44+
45+
const filterItems: DataViewFilterIdentifiers[] = useMemo(() => React.Children.toArray(children)
46+
.map(child =>
47+
React.isValidElement(child) ? { filterId: String(child.props.filterId), title: String(child.props.title) } : undefined
48+
).filter((item): item is DataViewFilterIdentifiers => !!item), []); // eslint-disable-line react-hooks/exhaustive-deps
49+
50+
useEffect(() => {
51+
filterItems.length > 0 && setActiveAttributeMenu(filterItems[0].title);
52+
}, [ filterItems ]);
53+
54+
const attributeToggle = (
55+
<MenuToggle
56+
ref={attributeToggleRef}
57+
onClick={() => setIsAttributeMenuOpen(!isAttributeMenuOpen)}
58+
isExpanded={isAttributeMenuOpen}
59+
icon={toggleIcon}
60+
>
61+
{activeAttributeMenu}
62+
</MenuToggle>
63+
);
64+
65+
const attributeMenu = (
66+
<Menu
67+
ref={attributeMenuRef}
68+
onSelect={(_ev, itemId) => {
69+
const selectedItem = filterItems.find(item => item.filterId === itemId);
70+
selectedItem && setActiveAttributeMenu(selectedItem.title);
71+
setIsAttributeMenuOpen(false);
72+
}}
73+
>
74+
<MenuContent>
75+
<MenuList>
76+
{filterItems.map(item => (
77+
<MenuItem key={item.filterId} itemId={item.filterId}>
78+
{item.title}
79+
</MenuItem>
80+
))}
81+
</MenuList>
82+
</MenuContent>
83+
</Menu>
84+
);
85+
86+
return (
87+
<ToolbarToggleGroup data-ouia-component-id={ouiaId} toggleIcon={toggleIcon} breakpoint={breakpoint} {...props}>
88+
<ToolbarGroup variant="filter-group">
89+
<div ref={attributeContainerRef}>
90+
<Popper
91+
trigger={attributeToggle}
92+
triggerRef={attributeToggleRef}
93+
popper={attributeMenu}
94+
popperRef={attributeMenuRef}
95+
appendTo={attributeContainerRef.current || undefined}
96+
isVisible={isAttributeMenuOpen}
97+
/>
98+
</div>
99+
{React.Children.map(children, (child) => (
100+
React.isValidElement(child) ? (
101+
React.cloneElement(child as ReactElement<{
102+
showToolbarItem: boolean;
103+
onChange: (_e: unknown, values: unknown) => void;
104+
value: unknown;
105+
}>, {
106+
showToolbarItem: activeAttributeMenu === child.props.title,
107+
onChange: (event, value) => onChange?.(event, { [child.props.filterId]: value } as Partial<T>),
108+
value: values?.[child.props.filterId],
109+
...child.props
110+
})
111+
) : child
112+
))}
113+
114+
</ToolbarGroup>
115+
</ToolbarToggleGroup>
116+
);
117+
};
118+
119+
export default DataViewFilters;
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { default } from './DataViewFilters';
2+
export * from './DataViewFilters';
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import React from 'react';
2+
import { SearchInput, SearchInputProps, ToolbarFilter, ToolbarFilterProps } from '@patternfly/react-core';
3+
4+
/** extends SearchInputProps */
5+
export interface DataViewTextFilterProps extends SearchInputProps {
6+
/** Unique key for the filter attribute */
7+
filterId: string;
8+
/** Current filter value */
9+
value?: string;
10+
/** Filter title displayed in the toolbar */
11+
title: string;
12+
/** Callback for when the input value changes */
13+
onChange?: (event: React.FormEvent<HTMLInputElement> | undefined, value: string) => void;
14+
/** Controls visibility of the filter in the toolbar */
15+
showToolbarItem?: ToolbarFilterProps['showToolbarItem'];
16+
/** Trims input value on change */
17+
trimValue?: boolean;
18+
/** Custom OUIA ID */
19+
ouiaId?: string;
20+
}
21+
22+
export const DataViewTextFilter: React.FC<DataViewTextFilterProps> = ({
23+
filterId,
24+
title,
25+
value = '',
26+
onChange,
27+
onClear = () => onChange?.(undefined, ''),
28+
showToolbarItem,
29+
trimValue = true,
30+
ouiaId = 'DataViewTextFilter',
31+
...props
32+
}: DataViewTextFilterProps) => (
33+
<ToolbarFilter
34+
data-ouia-component-id={ouiaId}
35+
chips={value.length > 0 ? [ { key: title, node: value } ] : []}
36+
deleteChip={() => onChange?.(undefined, '')}
37+
categoryName={title}
38+
showToolbarItem={showToolbarItem}
39+
>
40+
<SearchInput
41+
searchInputId={filterId}
42+
value={value}
43+
onChange={(e, inputValue) => onChange?.(e, trimValue ? inputValue.trim() : inputValue)}
44+
onClear={onClear}
45+
placeholder={`Filter by ${title}`}
46+
aria-label={`${title ?? filterId} filter`}
47+
data-ouia-component-id={`${ouiaId}-input`}
48+
{...props}
49+
/>
50+
</ToolbarFilter>
51+
);
52+
53+
export default DataViewTextFilter;
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { default } from './DataViewTextFilter';
2+
export * from './DataViewTextFilter';
Lines changed: 47 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,60 @@
1-
import React, { PropsWithChildren } from 'react';
2-
import { Toolbar, ToolbarContent, ToolbarItem, ToolbarItemVariant } from '@patternfly/react-core';
1+
import React, { PropsWithChildren, useRef } from 'react';
2+
import { Button, Toolbar, ToolbarContent, ToolbarItem, ToolbarItemVariant, ToolbarProps } from '@patternfly/react-core';
33

4-
export interface DataViewToolbarProps extends PropsWithChildren {
4+
/** extends ToolbarProps */
5+
export interface DataViewToolbarProps extends Omit<PropsWithChildren<ToolbarProps>, 'ref'> {
56
/** Toolbar className */
67
className?: string;
78
/** Custom OUIA ID */
89
ouiaId?: string;
9-
/** React component to display bulk select */
10+
/** React node to display bulk select */
1011
bulkSelect?: React.ReactNode;
11-
/** React component to display pagination */
12+
/** React node to display pagination */
1213
pagination?: React.ReactNode;
13-
/** React component to display actions */
14+
/** React node to display actions */
1415
actions?: React.ReactNode;
16+
/** React node to display filters */
17+
filters?: React.ReactNode;
18+
/** React node to display custom filter chips */
19+
customChipGroupContent?: React.ReactNode;
1520
}
1621

17-
export const DataViewToolbar: React.FC<DataViewToolbarProps> = ({ className, ouiaId = 'DataViewToolbar', bulkSelect, actions, pagination, children, ...props }: DataViewToolbarProps) => (
18-
<Toolbar ouiaId={ouiaId} className={className} {...props}>
19-
<ToolbarContent>
20-
{bulkSelect && (
21-
<ToolbarItem data-ouia-component-id={`${ouiaId}-bulk-select`}>
22-
{bulkSelect}
23-
</ToolbarItem>
24-
)}
25-
{actions && (
26-
<ToolbarItem variant={ToolbarItemVariant['overflow-menu']}>
27-
{actions}
28-
</ToolbarItem>
29-
)}
30-
{pagination && (
31-
<ToolbarItem variant={ToolbarItemVariant.pagination} data-ouia-component-id={`${ouiaId}-pagination`}>
32-
{pagination}
33-
</ToolbarItem>
34-
)}
35-
{children}
36-
</ToolbarContent>
37-
</Toolbar>
38-
)
22+
export const DataViewToolbar: React.FC<DataViewToolbarProps> = ({ className, ouiaId = 'DataViewToolbar', bulkSelect, actions, pagination, filters, customChipGroupContent, clearAllFilters, children, ...props }: DataViewToolbarProps) => {
23+
const defaultClearFilters = useRef(
24+
<ToolbarItem>
25+
<Button ouiaId={`${ouiaId}-clear-all-filters`} variant="link" onClick={clearAllFilters} isInline>
26+
Clear filters
27+
</Button>
28+
</ToolbarItem>
29+
);
30+
return (
31+
<Toolbar ouiaId={ouiaId} className={className} customChipGroupContent={customChipGroupContent ?? defaultClearFilters.current} {...props}>
32+
<ToolbarContent>
33+
{bulkSelect && (
34+
<ToolbarItem data-ouia-component-id={`${ouiaId}-bulk-select`}>
35+
{bulkSelect}
36+
</ToolbarItem>
37+
)}
38+
{actions && (
39+
<ToolbarItem variant={ToolbarItemVariant['overflow-menu']}>
40+
{actions}
41+
</ToolbarItem>
42+
)}
43+
{filters && (
44+
<ToolbarItem variant={ToolbarItemVariant['search-filter']}>
45+
{filters}
46+
</ToolbarItem>
47+
)}
48+
{pagination && (
49+
<ToolbarItem variant={ToolbarItemVariant.pagination} data-ouia-component-id={`${ouiaId}-pagination`}>
50+
{pagination}
51+
</ToolbarItem>
52+
)}
53+
{children}
54+
</ToolbarContent>
55+
</Toolbar>
56+
)
57+
};
3958

4059
export default DataViewToolbar;
4160

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { useState, useCallback, useEffect, useMemo } from "react";
2+
3+
export interface UseDataViewFiltersProps<T extends object> {
4+
/** Initial filters object */
5+
initialFilters?: T;
6+
/** Current search parameters as a string */
7+
searchParams?: URLSearchParams;
8+
/** Function to set search parameters */
9+
setSearchParams?: (params: URLSearchParams) => void;
10+
};
11+
12+
export const useDataViewFilters = <T extends object>({
13+
initialFilters = {} as T,
14+
searchParams,
15+
setSearchParams,
16+
}: UseDataViewFiltersProps<T>) => {
17+
const isUrlSyncEnabled = useMemo(() => searchParams && !!setSearchParams, [ searchParams, setSearchParams ]);
18+
19+
const getInitialFilters = useCallback((): T => isUrlSyncEnabled ? Object.keys(initialFilters).reduce((loadedFilters, key) => {
20+
const urlValue = searchParams?.get(key);
21+
loadedFilters[key as keyof T] = urlValue
22+
? (urlValue as T[keyof T] | T[keyof T])
23+
: initialFilters[key as keyof T];
24+
return loadedFilters;
25+
// eslint-disable-next-line react-hooks/exhaustive-deps
26+
}, { ...initialFilters }) : initialFilters, [ isUrlSyncEnabled, JSON.stringify(initialFilters), searchParams?.toString() ]);
27+
28+
const [ filters, setFilters ] = useState<T>(getInitialFilters());
29+
30+
const updateSearchParams = useCallback(
31+
(newFilters: T) => {
32+
if (isUrlSyncEnabled) {
33+
const params = new URLSearchParams(searchParams);
34+
Object.entries(newFilters).forEach(([ key, value ]) => {
35+
if (value) {
36+
params.set(key, Array.isArray(value) ? value.join(',') : value);
37+
} else {
38+
params.delete(key);
39+
}
40+
});
41+
setSearchParams?.(params);
42+
}
43+
},
44+
[ isUrlSyncEnabled, searchParams, setSearchParams ]
45+
);
46+
47+
useEffect(() => {
48+
isUrlSyncEnabled && setFilters(getInitialFilters())
49+
}, []); // eslint-disable-line react-hooks/exhaustive-deps
50+
51+
const onSetFilters = useCallback(
52+
(newFilters: Partial<T>) => {
53+
setFilters(prevFilters => {
54+
const updatedFilters = { ...prevFilters, ...newFilters };
55+
isUrlSyncEnabled && updateSearchParams(updatedFilters);
56+
return updatedFilters;
57+
});
58+
},
59+
[ isUrlSyncEnabled, updateSearchParams ]
60+
);
61+
62+
// helper function to reset filters
63+
const resetFilterValues = useCallback((filters: Partial<T>): Partial<T> => Object.entries(filters).reduce((acc, [ key, value ]) => {
64+
if (Array.isArray(value)) {
65+
acc[key as keyof T] = [] as T[keyof T];
66+
} else {
67+
acc[key as keyof T] = '' as T[keyof T];
68+
}
69+
return acc;
70+
}, {} as Partial<T>), []);
71+
72+
const onDeleteFilters = useCallback(
73+
(filtersToDelete: Partial<T>) => {
74+
setFilters(prevFilters => {
75+
const updatedFilters = { ...prevFilters,...resetFilterValues(filtersToDelete) };
76+
isUrlSyncEnabled && updateSearchParams(updatedFilters);
77+
return updatedFilters;
78+
});
79+
},
80+
[ isUrlSyncEnabled, updateSearchParams, resetFilterValues ]
81+
);
82+
83+
const clearAllFilters = useCallback(() => {
84+
const clearedFilters = resetFilterValues(filters) as T;
85+
86+
setFilters(clearedFilters);
87+
isUrlSyncEnabled && updateSearchParams(clearedFilters);
88+
}, [ filters, isUrlSyncEnabled, updateSearchParams, resetFilterValues ]);
89+
90+
return {
91+
filters,
92+
onSetFilters,
93+
onDeleteFilters,
94+
clearAllFilters,
95+
};
96+
};

packages/module/src/Hooks/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export * from './pagination';
22
export * from './selection';
3+
export * from './filters';

packages/module/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ export * from './Hooks';
77
export { default as DataViewToolbar } from './DataViewToolbar';
88
export * from './DataViewToolbar';
99

10+
export { default as DataViewTextFilter } from './DataViewTextFilter';
11+
export * from './DataViewTextFilter';
12+
1013
export { default as DataViewTableTree } from './DataViewTableTree';
1114
export * from './DataViewTableTree';
1215

0 commit comments

Comments
 (0)