@patternfly/react-data-view 5.5.1 → 5.7.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.
Files changed (52) hide show
  1. package/dist/cjs/DataViewCheckboxFilter/DataViewCheckboxFilter.d.ts +29 -0
  2. package/dist/cjs/DataViewCheckboxFilter/DataViewCheckboxFilter.js +70 -0
  3. package/dist/cjs/DataViewCheckboxFilter/DataViewCheckboxFilter.test.d.ts +1 -0
  4. package/dist/cjs/DataViewCheckboxFilter/DataViewCheckboxFilter.test.js +25 -0
  5. package/dist/cjs/DataViewCheckboxFilter/index.d.ts +2 -0
  6. package/dist/cjs/DataViewCheckboxFilter/index.js +23 -0
  7. package/dist/cjs/DataViewFilters/DataViewFilters.d.ts +7 -1
  8. package/dist/cjs/DataViewFilters/DataViewFilters.js +16 -1
  9. package/dist/cjs/DataViewTextFilter/DataViewTextFilter.js +1 -1
  10. package/dist/cjs/Hooks/filters.js +13 -14
  11. package/dist/cjs/Hooks/index.d.ts +1 -0
  12. package/dist/cjs/Hooks/index.js +1 -0
  13. package/dist/cjs/Hooks/sort.d.ts +32 -0
  14. package/dist/cjs/Hooks/sort.js +47 -0
  15. package/dist/cjs/Hooks/sort.test.d.ts +1 -0
  16. package/dist/cjs/Hooks/sort.test.js +68 -0
  17. package/dist/cjs/index.d.ts +2 -0
  18. package/dist/cjs/index.js +4 -1
  19. package/dist/dynamic/DataViewCheckboxFilter/package.json +1 -0
  20. package/dist/esm/DataViewCheckboxFilter/DataViewCheckboxFilter.d.ts +29 -0
  21. package/dist/esm/DataViewCheckboxFilter/DataViewCheckboxFilter.js +62 -0
  22. package/dist/esm/DataViewCheckboxFilter/DataViewCheckboxFilter.test.d.ts +1 -0
  23. package/dist/esm/DataViewCheckboxFilter/DataViewCheckboxFilter.test.js +20 -0
  24. package/dist/esm/DataViewCheckboxFilter/index.d.ts +2 -0
  25. package/dist/esm/DataViewCheckboxFilter/index.js +2 -0
  26. package/dist/esm/DataViewFilters/DataViewFilters.d.ts +7 -1
  27. package/dist/esm/DataViewFilters/DataViewFilters.js +16 -1
  28. package/dist/esm/DataViewTextFilter/DataViewTextFilter.js +1 -1
  29. package/dist/esm/Hooks/filters.js +13 -14
  30. package/dist/esm/Hooks/index.d.ts +1 -0
  31. package/dist/esm/Hooks/index.js +1 -0
  32. package/dist/esm/Hooks/sort.d.ts +32 -0
  33. package/dist/esm/Hooks/sort.js +43 -0
  34. package/dist/esm/Hooks/sort.test.d.ts +1 -0
  35. package/dist/esm/Hooks/sort.test.js +66 -0
  36. package/dist/esm/index.d.ts +2 -0
  37. package/dist/esm/index.js +2 -0
  38. package/package.json +1 -1
  39. package/patternfly-docs/content/extensions/data-view/examples/Functionality/FiltersExample.tsx +31 -16
  40. package/patternfly-docs/content/extensions/data-view/examples/Functionality/Functionality.md +36 -4
  41. package/patternfly-docs/content/extensions/data-view/examples/Functionality/SortingExample.tsx +87 -0
  42. package/src/DataViewCheckboxFilter/DataViewCheckboxFilter.test.tsx +24 -0
  43. package/src/DataViewCheckboxFilter/DataViewCheckboxFilter.tsx +175 -0
  44. package/src/DataViewCheckboxFilter/__snapshots__/DataViewCheckboxFilter.test.tsx.snap +194 -0
  45. package/src/DataViewCheckboxFilter/index.ts +2 -0
  46. package/src/DataViewFilters/DataViewFilters.tsx +26 -7
  47. package/src/DataViewTextFilter/DataViewTextFilter.tsx +1 -0
  48. package/src/Hooks/filters.ts +14 -13
  49. package/src/Hooks/index.ts +1 -0
  50. package/src/Hooks/sort.test.tsx +84 -0
  51. package/src/Hooks/sort.ts +87 -0
  52. package/src/index.ts +3 -0
@@ -0,0 +1,175 @@
1
+ import React from 'react';
2
+ import {
3
+ Badge,
4
+ Menu,
5
+ MenuContent,
6
+ MenuItem,
7
+ MenuList,
8
+ MenuProps,
9
+ MenuToggle,
10
+ Popper,
11
+ ToolbarChip,
12
+ ToolbarFilter,
13
+ } from '@patternfly/react-core';
14
+ import { FilterIcon } from '@patternfly/react-icons';
15
+ import { DataViewFilterOption } from '../DataViewFilters';
16
+
17
+ const isToolbarChip = (chip: string | ToolbarChip): chip is ToolbarChip =>
18
+ typeof chip === 'object' && 'key' in chip;
19
+
20
+ export const isDataViewFilterOption = (obj: unknown): obj is DataViewFilterOption =>
21
+ !!obj &&
22
+ typeof obj === 'object' &&
23
+ 'label' in obj &&
24
+ 'value' in obj &&
25
+ typeof (obj as DataViewFilterOption).value === 'string';
26
+
27
+ /** extends MenuProps */
28
+ export interface DataViewCheckboxFilterProps extends Omit<MenuProps, 'onSelect' | 'onChange'> {
29
+ /** Unique key for the filter attribute */
30
+ filterId: string;
31
+ /** Array of current filter values */
32
+ value?: string[];
33
+ /** Filter title displayed in the toolbar */
34
+ title: string;
35
+ /** Placeholder text of the menu */
36
+ placeholder?: string;
37
+ /** Filter options displayed */
38
+ options: (DataViewFilterOption | string)[];
39
+ /** Callback for updating when item selection changes. */
40
+ onChange?: (event?: React.MouseEvent, values?: string[]) => void;
41
+ /** Controls visibility of the filter in the toolbar */
42
+ showToolbarItem?: boolean;
43
+ /** Controls visibility of the filter icon */
44
+ showIcon?: boolean;
45
+ /** Controls visibility of the selected items badge */
46
+ showBadge?: boolean;
47
+ /** Custom OUIA ID */
48
+ ouiaId?: string;
49
+ }
50
+
51
+ export const DataViewCheckboxFilter: React.FC<DataViewCheckboxFilterProps> = ({
52
+ filterId,
53
+ title,
54
+ value = [],
55
+ onChange,
56
+ placeholder,
57
+ options = [],
58
+ showToolbarItem,
59
+ showIcon = !placeholder,
60
+ showBadge = !placeholder,
61
+ ouiaId = 'DataViewCheckboxFilter',
62
+ ...props
63
+ }: DataViewCheckboxFilterProps) => {
64
+ const [ isOpen, setIsOpen ] = React.useState(false);
65
+ const toggleRef = React.useRef<HTMLButtonElement>(null);
66
+ const menuRef = React.useRef<HTMLDivElement>(null);
67
+ const containerRef = React.useRef<HTMLDivElement>(null);
68
+
69
+ const normalizeOptions = React.useMemo(
70
+ () =>
71
+ options.map(option =>
72
+ typeof option === 'string'
73
+ ? { label: option, value: option }
74
+ : option
75
+ ),
76
+ [ options ]
77
+ );
78
+
79
+ const handleToggleClick = (event: React.MouseEvent) => {
80
+ event.stopPropagation();
81
+ setTimeout(() => {
82
+ const firstElement = menuRef.current?.querySelector('li > button:not(:disabled)') as HTMLElement;
83
+ firstElement?.focus();
84
+ }, 0);
85
+ setIsOpen(prev => !prev);
86
+ };
87
+
88
+ const handleSelect = (event?: React.MouseEvent, itemId?: string | number) => {
89
+ const activeItem = String(itemId);
90
+ const isSelected = value.includes(activeItem);
91
+
92
+ onChange?.(
93
+ event,
94
+ isSelected ? value.filter(item => item !== activeItem) : [ activeItem, ...value ]
95
+ );
96
+ };
97
+
98
+ const handleClickOutside = (event: MouseEvent) =>
99
+ isOpen &&
100
+ menuRef.current && toggleRef.current &&
101
+ !menuRef.current.contains(event.target as Node) && !toggleRef.current.contains(event.target as Node)
102
+ && setIsOpen(false);
103
+
104
+
105
+ React.useEffect(() => {
106
+ window.addEventListener('click', handleClickOutside);
107
+ return () => {
108
+ window.removeEventListener('click', handleClickOutside);
109
+ };
110
+ }, [ isOpen ]); // eslint-disable-line react-hooks/exhaustive-deps
111
+
112
+ return (
113
+ <ToolbarFilter
114
+ key={ouiaId}
115
+ data-ouia-component-id={ouiaId}
116
+ chips={value.map(item => {
117
+ const activeOption = normalizeOptions.find(option => option.value === item);
118
+ return ({ key: activeOption?.value as string, node: activeOption?.label })
119
+ })}
120
+ deleteChip={(_, chip) =>
121
+ onChange?.(undefined, value.filter(item => item !== (isToolbarChip(chip) ? chip.key : chip)))
122
+ }
123
+ categoryName={title}
124
+ showToolbarItem={showToolbarItem}
125
+ >
126
+ <Popper
127
+ trigger={
128
+ <MenuToggle
129
+ ouiaId={`${ouiaId}-toggle`}
130
+ ref={toggleRef}
131
+ onClick={handleToggleClick}
132
+ isExpanded={isOpen}
133
+ icon={showIcon ? <FilterIcon /> : undefined}
134
+ badge={value.length > 0 && showBadge ? <Badge data-ouia-component-id={`${ouiaId}-badge`} isRead>{value.length}</Badge> : undefined}
135
+ style={{ width: '200px' }}
136
+ >
137
+ {placeholder ?? title}
138
+ </MenuToggle>
139
+ }
140
+ triggerRef={toggleRef}
141
+ popper={
142
+ <Menu
143
+ ref={menuRef}
144
+ ouiaId={`${ouiaId}-menu`}
145
+ onSelect={handleSelect}
146
+ selected={value}
147
+ {...props}
148
+ >
149
+ <MenuContent>
150
+ <MenuList>
151
+ {normalizeOptions.map(option => (
152
+ <MenuItem
153
+ data-ouia-component-id={`${ouiaId}-filter-item-${option.value}`}
154
+ key={option.value}
155
+ itemId={option.value}
156
+ isSelected={value.includes(option.value)}
157
+ hasCheckbox
158
+ >
159
+ {option.label}
160
+ </MenuItem>
161
+ ))}
162
+ </MenuList>
163
+ </MenuContent>
164
+ </Menu>
165
+ }
166
+ popperRef={menuRef}
167
+ appendTo={containerRef.current || undefined}
168
+ aria-label={`${title ?? filterId} filter`}
169
+ isVisible={isOpen}
170
+ />
171
+ </ToolbarFilter>
172
+ );
173
+ };
174
+
175
+ export default DataViewCheckboxFilter;
@@ -0,0 +1,194 @@
1
+ // Jest Snapshot v1, https://goo.gl/fbAQLP
2
+
3
+ exports[`DataViewCheckboxFilter component should render correctly 1`] = `
4
+ <div>
5
+ <div
6
+ class="pf-v5-c-toolbar"
7
+ data-ouia-component-id="DataViewToolbar"
8
+ data-ouia-component-type="PF5/Toolbar"
9
+ data-ouia-safe="true"
10
+ id="pf-random-id-0"
11
+ >
12
+ <div
13
+ class="pf-v5-c-toolbar__content"
14
+ >
15
+ <div
16
+ class="pf-v5-c-toolbar__content-section"
17
+ >
18
+ <div
19
+ class="pf-v5-c-toolbar__item pf-m-search-filter"
20
+ >
21
+ <div
22
+ class="pf-v5-c-toolbar__item"
23
+ data-ouia-component-id="DataViewCheckboxFilter"
24
+ >
25
+ <button
26
+ aria-expanded="false"
27
+ class="pf-v5-c-menu-toggle"
28
+ data-ouia-component-id="DataViewCheckboxFilter-toggle"
29
+ data-ouia-component-type="PF5/MenuToggle"
30
+ data-ouia-safe="true"
31
+ style="width: 200px;"
32
+ type="button"
33
+ >
34
+ <span
35
+ class="pf-v5-c-menu-toggle__icon"
36
+ >
37
+ <svg
38
+ aria-hidden="true"
39
+ class="pf-v5-svg"
40
+ fill="currentColor"
41
+ height="1em"
42
+ role="img"
43
+ viewBox="0 0 512 512"
44
+ width="1em"
45
+ >
46
+ <path
47
+ d="M487.976 0H24.028C2.71 0-8.047 25.866 7.058 40.971L192 225.941V432c0 7.831 3.821 15.17 10.237 19.662l80 55.98C298.02 518.69 320 507.493 320 487.98V225.941l184.947-184.97C520.021 25.896 509.338 0 487.976 0z"
48
+ />
49
+ </svg>
50
+ </span>
51
+ <span
52
+ class="pf-v5-c-menu-toggle__text"
53
+ >
54
+ Test Checkbox Filter
55
+ </span>
56
+ <span
57
+ class="pf-v5-c-menu-toggle__count"
58
+ >
59
+ <span
60
+ class="pf-v5-c-badge pf-m-read"
61
+ data-ouia-component-id="DataViewCheckboxFilter-badge"
62
+ >
63
+ 1
64
+ </span>
65
+ </span>
66
+ <span
67
+ class="pf-v5-c-menu-toggle__controls"
68
+ >
69
+ <span
70
+ class="pf-v5-c-menu-toggle__toggle-icon"
71
+ >
72
+ <svg
73
+ aria-hidden="true"
74
+ class="pf-v5-svg"
75
+ fill="currentColor"
76
+ height="1em"
77
+ role="img"
78
+ viewBox="0 0 320 512"
79
+ width="1em"
80
+ >
81
+ <path
82
+ d="M31.3 192h257.3c17.8 0 26.7 21.5 14.1 34.1L174.1 354.8c-7.8 7.8-20.5 7.8-28.3 0L17.2 226.1C4.6 213.5 13.5 192 31.3 192z"
83
+ />
84
+ </svg>
85
+ </span>
86
+ </span>
87
+ </button>
88
+ </div>
89
+ </div>
90
+ </div>
91
+ </div>
92
+ <div
93
+ class="pf-v5-c-toolbar__content pf-m-chip-container"
94
+ >
95
+ <div
96
+ class="pf-v5-c-toolbar__group"
97
+ >
98
+ <div
99
+ class="pf-v5-c-toolbar__item pf-m-chip-group"
100
+ >
101
+ <div
102
+ aria-labelledby="pf-random-id-1"
103
+ class="pf-v5-c-chip-group pf-m-category"
104
+ data-ouia-component-type="PF5/ChipGroup"
105
+ data-ouia-safe="true"
106
+ role="group"
107
+ >
108
+ <div
109
+ class="pf-v5-c-chip-group__main"
110
+ >
111
+ <span
112
+ class="pf-v5-c-chip-group__label"
113
+ id="pf-random-id-1"
114
+ >
115
+ Test Checkbox Filter
116
+ </span>
117
+ <ul
118
+ aria-labelledby="pf-random-id-1"
119
+ class="pf-v5-c-chip-group__list"
120
+ role="list"
121
+ >
122
+ <li
123
+ class="pf-v5-c-chip-group__list-item"
124
+ >
125
+ <div
126
+ class="pf-v5-c-chip"
127
+ data-ouia-component-id="OUIA-Generated-Chip-1"
128
+ data-ouia-component-type="PF5/Chip"
129
+ data-ouia-safe="true"
130
+ >
131
+ <span
132
+ class="pf-v5-c-chip__content"
133
+ >
134
+ <span
135
+ class="pf-v5-c-chip__text"
136
+ id="pf-random-id-2"
137
+ >
138
+ Workspace one
139
+ </span>
140
+ </span>
141
+ <span
142
+ class="pf-v5-c-chip__actions"
143
+ >
144
+ <button
145
+ aria-disabled="false"
146
+ aria-label="close"
147
+ aria-labelledby="remove_pf-random-id-2 pf-random-id-2"
148
+ class="pf-v5-c-button pf-m-plain"
149
+ data-ouia-component-id="close"
150
+ data-ouia-component-type="PF5/Button"
151
+ data-ouia-safe="true"
152
+ id="remove_pf-random-id-2"
153
+ type="button"
154
+ >
155
+ <svg
156
+ aria-hidden="true"
157
+ class="pf-v5-svg"
158
+ fill="currentColor"
159
+ height="1em"
160
+ role="img"
161
+ viewBox="0 0 352 512"
162
+ width="1em"
163
+ >
164
+ <path
165
+ d="M242.72 256l100.07-100.07c12.28-12.28 12.28-32.19 0-44.48l-22.24-22.24c-12.28-12.28-32.19-12.28-44.48 0L176 189.28 75.93 89.21c-12.28-12.28-32.19-12.28-44.48 0L9.21 111.45c-12.28 12.28-12.28 32.19 0 44.48L109.28 256 9.21 356.07c-12.28 12.28-12.28 32.19 0 44.48l22.24 22.24c12.28 12.28 32.2 12.28 44.48 0L176 322.72l100.07 100.07c12.28 12.28 32.2 12.28 44.48 0l22.24-22.24c12.28-12.28 12.28-32.19 0-44.48L242.72 256z"
166
+ />
167
+ </svg>
168
+ </button>
169
+ </span>
170
+ </div>
171
+ </li>
172
+ </ul>
173
+ </div>
174
+ </div>
175
+ </div>
176
+ </div>
177
+ <div
178
+ class="pf-v5-c-toolbar__item"
179
+ >
180
+ <button
181
+ aria-disabled="false"
182
+ class="pf-v5-c-button pf-m-link pf-m-inline"
183
+ data-ouia-component-id="DataViewToolbar-clear-all-filters"
184
+ data-ouia-component-type="PF5/Button"
185
+ data-ouia-safe="true"
186
+ type="button"
187
+ >
188
+ Clear filters
189
+ </button>
190
+ </div>
191
+ </div>
192
+ </div>
193
+ </div>
194
+ `;
@@ -0,0 +1,2 @@
1
+ export { default } from './DataViewCheckboxFilter';
2
+ export * from './DataViewCheckboxFilter';
@@ -1,9 +1,16 @@
1
- import React, { useMemo, useState, useRef, useEffect, ReactElement } from 'react';
1
+ import React, { useMemo, useState, useRef, useEffect, ReactElement, ReactNode } from 'react';
2
2
  import {
3
3
  Menu, MenuContent, MenuItem, MenuList, MenuToggle, Popper, ToolbarGroup, ToolbarToggleGroup, ToolbarToggleGroupProps,
4
4
  } from '@patternfly/react-core';
5
5
  import { FilterIcon } from '@patternfly/react-icons';
6
6
 
7
+ export interface DataViewFilterOption {
8
+ /** Filter option label */
9
+ label: ReactNode;
10
+ /** Filter option value */
11
+ value: string;
12
+ }
13
+
7
14
  // helper interface to generate attribute menu
8
15
  interface DataViewFilterIdentifiers {
9
16
  filterId: string;
@@ -57,6 +64,19 @@ export const DataViewFilters = <T extends object>({
57
64
  filterItems.length > 0 && setActiveAttributeMenu(filterItems[0].title);
58
65
  }, [ filterItems ]);
59
66
 
67
+ const handleClickOutside = (event: MouseEvent) =>
68
+ isAttributeMenuOpen &&
69
+ !attributeMenuRef.current?.contains(event.target as Node) &&
70
+ !attributeToggleRef.current?.contains(event.target as Node)
71
+ && setIsAttributeMenuOpen(false);
72
+
73
+ useEffect(() => {
74
+ window.addEventListener('click', handleClickOutside);
75
+ return () => {
76
+ window.removeEventListener('click', handleClickOutside);
77
+ };
78
+ }, [ isAttributeMenuOpen ]); // eslint-disable-line react-hooks/exhaustive-deps
79
+
60
80
  const attributeToggle = (
61
81
  <MenuToggle
62
82
  ref={attributeToggleRef}
@@ -102,9 +122,9 @@ export const DataViewFilters = <T extends object>({
102
122
  isVisible={isAttributeMenuOpen}
103
123
  />
104
124
  </div>
105
- {React.Children.map(children, (child) => (
106
- React.isValidElement(child) ? (
107
- React.cloneElement(child as ReactElement<{
125
+ {React.Children.map(children, (child) =>
126
+ React.isValidElement(child)
127
+ ? React.cloneElement(child as ReactElement<{
108
128
  showToolbarItem: boolean;
109
129
  onChange: (_e: unknown, values: unknown) => void;
110
130
  value: unknown;
@@ -114,9 +134,8 @@ export const DataViewFilters = <T extends object>({
114
134
  value: values?.[child.props.filterId],
115
135
  ...child.props
116
136
  })
117
- ) : child
118
- ))}
119
-
137
+ : child
138
+ )}
120
139
  </ToolbarGroup>
121
140
  </ToolbarToggleGroup>
122
141
  );
@@ -31,6 +31,7 @@ export const DataViewTextFilter: React.FC<DataViewTextFilterProps> = ({
31
31
  ...props
32
32
  }: DataViewTextFilterProps) => (
33
33
  <ToolbarFilter
34
+ key={ouiaId}
34
35
  data-ouia-component-id={ouiaId}
35
36
  chips={value.length > 0 ? [ { key: title, node: value } ] : []}
36
37
  deleteChip={() => onChange?.(undefined, '')}
@@ -16,15 +16,19 @@ export const useDataViewFilters = <T extends object>({
16
16
  }: UseDataViewFiltersProps<T>) => {
17
17
  const isUrlSyncEnabled = useMemo(() => searchParams && !!setSearchParams, [ searchParams, setSearchParams ]);
18
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() ]);
19
+ const getInitialFilters = useCallback((): T => isUrlSyncEnabled
20
+ ? Object.keys(initialFilters).reduce((loadedFilters, key) => {
21
+ const urlValue = searchParams?.get(key);
22
+ const isArrayFilter = Array.isArray(initialFilters[key]);
27
23
 
24
+ // eslint-disable-next-line no-nested-ternary
25
+ loadedFilters[key] = urlValue
26
+ ? (isArrayFilter && !Array.isArray(urlValue) ? [ urlValue ] : urlValue)
27
+ : initialFilters[key];
28
+
29
+ return loadedFilters;
30
+ }, { ...initialFilters })
31
+ : initialFilters, [ isUrlSyncEnabled, initialFilters, searchParams ]);
28
32
  const [ filters, setFilters ] = useState<T>(getInitialFilters());
29
33
 
30
34
  const updateSearchParams = useCallback(
@@ -32,11 +36,8 @@ export const useDataViewFilters = <T extends object>({
32
36
  if (isUrlSyncEnabled) {
33
37
  const params = new URLSearchParams(searchParams);
34
38
  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
- }
39
+ params.delete(key);
40
+ (Array.isArray(value) ? value : [ value ]).forEach((val) => value && params.append(key, val));
40
41
  });
41
42
  setSearchParams?.(params);
42
43
  }
@@ -1,3 +1,4 @@
1
1
  export * from './pagination';
2
2
  export * from './selection';
3
3
  export * from './filters';
4
+ export * from './sort';
@@ -0,0 +1,84 @@
1
+ import '@testing-library/jest-dom';
2
+ import { renderHook, act } from '@testing-library/react';
3
+ import { useDataViewSort, UseDataViewSortProps, DataViewSortConfig, DataViewSortParams } from './sort';
4
+
5
+ describe('useDataViewSort', () => {
6
+ const initialSort: DataViewSortConfig = { sortBy: 'name', direction: 'asc' };
7
+
8
+ it('should initialize with provided initial sort config', () => {
9
+ const { result } = renderHook(() => useDataViewSort({ initialSort }));
10
+ expect(result.current).toEqual(expect.objectContaining(initialSort));
11
+ });
12
+
13
+ it('should initialize with empty sort config if no initialSort is provided', () => {
14
+ const { result } = renderHook(() => useDataViewSort());
15
+ expect(result.current).toEqual(expect.objectContaining({ sortBy: undefined, direction: 'asc' }));
16
+ });
17
+
18
+ it('should update sort state when onSort is called', () => {
19
+ const { result } = renderHook(() => useDataViewSort({ initialSort }));
20
+ act(() => {
21
+ result.current.onSort(undefined, 'age', 'desc');
22
+ });
23
+ expect(result.current).toEqual(expect.objectContaining({ sortBy: 'age', direction: 'desc' }));
24
+ });
25
+
26
+ it('should sync with URL search params if isUrlSyncEnabled', () => {
27
+ const searchParams = new URLSearchParams();
28
+ const setSearchParams = jest.fn();
29
+ const props: UseDataViewSortProps = {
30
+ initialSort,
31
+ searchParams,
32
+ setSearchParams,
33
+ };
34
+
35
+ const { result } = renderHook(() => useDataViewSort(props));
36
+
37
+ expect(setSearchParams).toHaveBeenCalledTimes(1);
38
+ expect(result.current).toEqual(expect.objectContaining(initialSort));
39
+ });
40
+
41
+ it('should validate direction and fallback to default direction if invalid direction is provided', () => {
42
+ const searchParams = new URLSearchParams();
43
+ searchParams.set(DataViewSortParams.SORT_BY, 'name');
44
+ searchParams.set(DataViewSortParams.DIRECTION, 'invalid-direction');
45
+ const { result } = renderHook(() => useDataViewSort({ searchParams, defaultDirection: 'desc' }));
46
+
47
+ expect(result.current).toEqual(expect.objectContaining({ sortBy: 'name', direction: 'desc' }));
48
+ });
49
+
50
+ it('should update search params when URL sync is enabled and sort changes', () => {
51
+ const searchParams = new URLSearchParams();
52
+ const setSearchParams = jest.fn();
53
+ const props: UseDataViewSortProps = {
54
+ initialSort,
55
+ searchParams,
56
+ setSearchParams,
57
+ };
58
+
59
+ const { result } = renderHook(() => useDataViewSort(props));
60
+ act(() => {
61
+ expect(setSearchParams).toHaveBeenCalledTimes(1);
62
+ result.current.onSort(undefined, 'priority', 'desc');
63
+ });
64
+
65
+ expect(setSearchParams).toHaveBeenCalledTimes(2);
66
+ expect(result.current).toEqual(expect.objectContaining({ sortBy: 'priority', direction: 'desc' }));
67
+ });
68
+
69
+ it('should prioritize searchParams values', () => {
70
+ const searchParams = new URLSearchParams();
71
+ searchParams.set(DataViewSortParams.SORT_BY, 'category');
72
+ searchParams.set(DataViewSortParams.DIRECTION, 'desc');
73
+
74
+ const { result } = renderHook(
75
+ (props: UseDataViewSortProps) => useDataViewSort(props),
76
+ { initialProps: { initialSort, searchParams } }
77
+ );
78
+
79
+ expect(result.current).toEqual(expect.objectContaining({
80
+ sortBy: 'category',
81
+ direction: 'desc',
82
+ }));
83
+ });
84
+ });
@@ -0,0 +1,87 @@
1
+ import { ISortBy } from "@patternfly/react-table";
2
+ import { useState, useEffect, useMemo } from "react";
3
+
4
+ export enum DataViewSortParams {
5
+ SORT_BY = 'sortBy',
6
+ DIRECTION = 'direction'
7
+ };
8
+
9
+ const validateDirection = (direction: string | null | undefined, defaultDirection: ISortBy['direction']): ISortBy['direction'] => (
10
+ direction === 'asc' || direction === 'desc' ? direction : defaultDirection
11
+ );
12
+
13
+ export interface DataViewSortConfig {
14
+ /** Attribute to sort the entries by */
15
+ sortBy: string | undefined;
16
+ /** Sort direction */
17
+ direction: ISortBy['direction'];
18
+ };
19
+
20
+ export interface UseDataViewSortProps {
21
+ /** Initial sort config */
22
+ initialSort?: DataViewSortConfig;
23
+ /** Current search parameters as a string */
24
+ searchParams?: URLSearchParams;
25
+ /** Function to set search parameters */
26
+ setSearchParams?: (params: URLSearchParams) => void;
27
+ /** Default direction */
28
+ defaultDirection?: ISortBy['direction'];
29
+ /** Sort by URL param name */
30
+ sortByParam?: string;
31
+ /** Direction URL param name */
32
+ directionParam?: string;
33
+ };
34
+
35
+ export const useDataViewSort = (props?: UseDataViewSortProps) => {
36
+ const {
37
+ initialSort,
38
+ searchParams,
39
+ setSearchParams,
40
+ defaultDirection = 'asc',
41
+ sortByParam = DataViewSortParams.SORT_BY,
42
+ directionParam = DataViewSortParams.DIRECTION
43
+ } = props ?? {};
44
+
45
+ const isUrlSyncEnabled = useMemo(() => searchParams && !!setSearchParams, [ searchParams, setSearchParams ]);
46
+
47
+ const [ state, setState ] = useState<DataViewSortConfig>({
48
+ sortBy: searchParams?.get(sortByParam) ?? initialSort?.sortBy,
49
+ direction: validateDirection(searchParams?.get(directionParam) as ISortBy['direction'], initialSort?.direction),
50
+ });
51
+
52
+ const updateSearchParams = (sortBy: string, direction: ISortBy['direction']) => {
53
+ if (isUrlSyncEnabled && sortBy) {
54
+ const params = new URLSearchParams(searchParams);
55
+ params.set(sortByParam, `${sortBy}`);
56
+ params.set(directionParam, `${direction}`);
57
+ setSearchParams?.(params);
58
+ }
59
+ };
60
+
61
+ useEffect(() => {
62
+ state.sortBy && state.direction && updateSearchParams(state.sortBy, state.direction);
63
+ // eslint-disable-next-line react-hooks/exhaustive-deps
64
+ }, []);
65
+
66
+ useEffect(() => {
67
+ const currentSortBy = searchParams?.get(sortByParam) || state.sortBy;
68
+ const currentDirection = searchParams?.get(directionParam) as ISortBy['direction'] || state.direction;
69
+ const validDirection = validateDirection(currentDirection, defaultDirection);
70
+ currentSortBy !== state.sortBy || validDirection !== state.direction && setState({ sortBy: currentSortBy, direction: validDirection });
71
+ // eslint-disable-next-line react-hooks/exhaustive-deps
72
+ }, [ searchParams?.toString() ]);
73
+
74
+ const onSort = (
75
+ _event: React.MouseEvent | React.KeyboardEvent | MouseEvent | undefined,
76
+ newSortBy: string,
77
+ newSortDirection: ISortBy['direction']
78
+ ) => {
79
+ setState({ sortBy: newSortBy, direction: newSortDirection });
80
+ updateSearchParams(newSortBy, newSortDirection);
81
+ };
82
+
83
+ return {
84
+ ...state,
85
+ onSort
86
+ };
87
+ };
package/src/index.ts CHANGED
@@ -25,5 +25,8 @@ export * from './DataViewTable';
25
25
  export { default as DataViewEventsContext } from './DataViewEventsContext';
26
26
  export * from './DataViewEventsContext';
27
27
 
28
+ export { default as DataViewCheckboxFilter } from './DataViewCheckboxFilter';
29
+ export * from './DataViewCheckboxFilter';
30
+
28
31
  export { default as DataView } from './DataView';
29
32
  export * from './DataView';