@patternfly/react-core 4.237.7 → 4.237.8
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.
- package/CHANGELOG.md +8 -0
- package/dist/umd/react-core.min.css +2167 -2167
- package/dist/umd/react-core.min.js +1 -1
- package/package.json +6 -6
- package/src/demos/Filters/FilterDemos.md +48 -0
- package/src/demos/Filters/examples/FilterCheckboxSelect.tsx +405 -0
- package/src/demos/Filters/examples/FilterSearchInput.tsx +331 -0
- package/src/demos/Filters/examples/FilterSingleSelect.tsx +392 -0
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import {
|
|
3
|
+
SearchInput,
|
|
4
|
+
Toolbar,
|
|
5
|
+
ToolbarContent,
|
|
6
|
+
ToolbarItem,
|
|
7
|
+
Menu,
|
|
8
|
+
MenuContent,
|
|
9
|
+
MenuList,
|
|
10
|
+
MenuItem,
|
|
11
|
+
MenuToggle,
|
|
12
|
+
MenuToggleCheckbox,
|
|
13
|
+
Popper,
|
|
14
|
+
Pagination,
|
|
15
|
+
EmptyState,
|
|
16
|
+
EmptyStateIcon,
|
|
17
|
+
Title,
|
|
18
|
+
EmptyStateBody,
|
|
19
|
+
EmptyStatePrimary,
|
|
20
|
+
Button
|
|
21
|
+
} from '@patternfly/react-core';
|
|
22
|
+
import { TableComposable, Thead, Tr, Th, Tbody, Td } from '@patternfly/react-table';
|
|
23
|
+
import SearchIcon from '@patternfly/react-icons/dist/esm/icons/search-icon';
|
|
24
|
+
|
|
25
|
+
interface Repository {
|
|
26
|
+
name: string;
|
|
27
|
+
threads: string;
|
|
28
|
+
apps: string;
|
|
29
|
+
workspaces: string;
|
|
30
|
+
status: string;
|
|
31
|
+
location: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// In real usage, this data would come from some external source like an API via props.
|
|
35
|
+
const repositories: Repository[] = [
|
|
36
|
+
{ name: 'US-Node 1', threads: '5', apps: '25', workspaces: '5', status: 'Stopped', location: 'Raleigh' },
|
|
37
|
+
{ name: 'US-Node 2', threads: '5', apps: '30', workspaces: '2', status: 'Down', location: 'Westford' },
|
|
38
|
+
{ name: 'US-Node 3', threads: '13', apps: '35', workspaces: '12', status: 'Degraded', location: 'Boston' },
|
|
39
|
+
{ name: 'US-Node 4', threads: '2', apps: '5', workspaces: '18', status: 'Needs Maintainence', location: 'Raleigh' },
|
|
40
|
+
{ name: 'US-Node 5', threads: '7', apps: '30', workspaces: '5', status: 'Running', location: 'Boston' },
|
|
41
|
+
{ name: 'US-Node 6', threads: '5', apps: '20', workspaces: '15', status: 'Stopped', location: 'Raleigh' },
|
|
42
|
+
{ name: 'CZ-Node 1', threads: '12', apps: '48', workspaces: '13', status: 'Down', location: 'Brno' },
|
|
43
|
+
{ name: 'CZ-Node 2', threads: '3', apps: '8', workspaces: '20', status: 'Running', location: 'Brno' },
|
|
44
|
+
{ name: 'CZ-Remote-Node 1', threads: '1', apps: '15', workspaces: '20', status: 'Down', location: 'Brno' },
|
|
45
|
+
{ name: 'Bangalore-Node 1', threads: '1', apps: '20', workspaces: '20', status: 'Running', location: 'Bangalore' }
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
const columnNames = {
|
|
49
|
+
name: 'Servers',
|
|
50
|
+
threads: 'Threads',
|
|
51
|
+
apps: 'Applications',
|
|
52
|
+
workspaces: 'Workspaces',
|
|
53
|
+
status: 'Status',
|
|
54
|
+
location: 'Location'
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/* eslint-disable patternfly-react/no-anonymous-functions */
|
|
58
|
+
export const FilterSearchInput: React.FunctionComponent = () => {
|
|
59
|
+
const isRepoSelectable = (repo: Repository) => repo.name !== 'a'; // Arbitrary logic for this example
|
|
60
|
+
const selectableRepos = repositories.filter(isRepoSelectable);
|
|
61
|
+
|
|
62
|
+
// In this example, selected rows are tracked by the repo names from each row. This could be any unique identifier.
|
|
63
|
+
// This is to prevent state from being based on row order index in case we later add sorting.
|
|
64
|
+
const [selectedRepoNames, setSelectedRepoNames] = React.useState<string[]>([]);
|
|
65
|
+
const setRepoSelected = (repo: Repository, isSelecting = true) =>
|
|
66
|
+
setSelectedRepoNames(prevSelected => {
|
|
67
|
+
const otherSelectedRepoNames = prevSelected.filter(r => r !== repo.name);
|
|
68
|
+
return isSelecting && isRepoSelectable(repo) ? [...otherSelectedRepoNames, repo.name] : otherSelectedRepoNames;
|
|
69
|
+
});
|
|
70
|
+
const selectAllRepos = (isSelecting = true) =>
|
|
71
|
+
setSelectedRepoNames(isSelecting ? selectableRepos.map(r => r.name) : []);
|
|
72
|
+
const areAllReposSelected = selectedRepoNames.length === selectableRepos.length;
|
|
73
|
+
const areSomeReposSelected = selectedRepoNames.length > 0;
|
|
74
|
+
const isRepoSelected = (repo: Repository) => selectedRepoNames.includes(repo.name);
|
|
75
|
+
|
|
76
|
+
// To allow shift+click to select/deselect multiple rows
|
|
77
|
+
const [recentSelectedRowIndex, setRecentSelectedRowIndex] = React.useState<number | null>(null);
|
|
78
|
+
const [shifting, setShifting] = React.useState(false);
|
|
79
|
+
|
|
80
|
+
const onSelectRepo = (repo: Repository, rowIndex: number, isSelecting: boolean) => {
|
|
81
|
+
// If the user is shift + selecting the checkboxes, then all intermediate checkboxes should be selected
|
|
82
|
+
if (shifting && recentSelectedRowIndex !== null) {
|
|
83
|
+
const numberSelected = rowIndex - recentSelectedRowIndex;
|
|
84
|
+
const intermediateIndexes =
|
|
85
|
+
numberSelected > 0
|
|
86
|
+
? Array.from(new Array(numberSelected + 1), (_x, i) => i + recentSelectedRowIndex)
|
|
87
|
+
: Array.from(new Array(Math.abs(numberSelected) + 1), (_x, i) => i + rowIndex);
|
|
88
|
+
intermediateIndexes.forEach(index => setRepoSelected(repositories[index], isSelecting));
|
|
89
|
+
} else {
|
|
90
|
+
setRepoSelected(repo, isSelecting);
|
|
91
|
+
}
|
|
92
|
+
setRecentSelectedRowIndex(rowIndex);
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
React.useEffect(() => {
|
|
96
|
+
const onKeyDown = (e: KeyboardEvent) => {
|
|
97
|
+
if (e.key === 'Shift') {
|
|
98
|
+
setShifting(true);
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
const onKeyUp = (e: KeyboardEvent) => {
|
|
102
|
+
if (e.key === 'Shift') {
|
|
103
|
+
setShifting(false);
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
document.addEventListener('keydown', onKeyDown);
|
|
108
|
+
document.addEventListener('keyup', onKeyUp);
|
|
109
|
+
|
|
110
|
+
return () => {
|
|
111
|
+
document.removeEventListener('keydown', onKeyDown);
|
|
112
|
+
document.removeEventListener('keyup', onKeyUp);
|
|
113
|
+
};
|
|
114
|
+
}, []);
|
|
115
|
+
|
|
116
|
+
const bulkSelectMenuRef = React.createRef<HTMLDivElement>();
|
|
117
|
+
const bulkSelectToggleRef = React.createRef<any>();
|
|
118
|
+
const containerRef = React.createRef<HTMLDivElement>();
|
|
119
|
+
|
|
120
|
+
const [isBulkSelectOpen, setIsBulkSelectOpen] = React.useState<boolean>(false);
|
|
121
|
+
|
|
122
|
+
React.useEffect(() => {
|
|
123
|
+
window.addEventListener('keydown', handleBulkSelectMenuKeys);
|
|
124
|
+
window.addEventListener('click', handleBulkSelectClickOutside);
|
|
125
|
+
return () => {
|
|
126
|
+
window.removeEventListener('keydown', handleBulkSelectMenuKeys);
|
|
127
|
+
window.removeEventListener('click', handleBulkSelectClickOutside);
|
|
128
|
+
};
|
|
129
|
+
}, [isBulkSelectOpen, bulkSelectMenuRef]);
|
|
130
|
+
|
|
131
|
+
const handleBulkSelectClickOutside = (event: MouseEvent) => {
|
|
132
|
+
if (isBulkSelectOpen && !bulkSelectMenuRef.current?.contains(event.target as Node)) {
|
|
133
|
+
setIsBulkSelectOpen(false);
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const handleBulkSelectMenuKeys = (event: KeyboardEvent) => {
|
|
138
|
+
if (!isBulkSelectOpen) {
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (
|
|
142
|
+
bulkSelectMenuRef.current?.contains(event.target as Node) ||
|
|
143
|
+
bulkSelectToggleRef.current?.contains(event.target as Node)
|
|
144
|
+
) {
|
|
145
|
+
if (event.key === 'Escape' || event.key === 'Tab') {
|
|
146
|
+
setIsBulkSelectOpen(!isBulkSelectOpen);
|
|
147
|
+
bulkSelectToggleRef.current?.focus();
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const onBulkSelectToggleClick = (ev: React.MouseEvent) => {
|
|
153
|
+
ev.stopPropagation(); // Stop handleClickOutside from handling
|
|
154
|
+
setTimeout(() => {
|
|
155
|
+
if (bulkSelectMenuRef.current) {
|
|
156
|
+
const firstElement = bulkSelectMenuRef.current.querySelector('li > button:not(:disabled)');
|
|
157
|
+
firstElement && (firstElement as HTMLElement).focus();
|
|
158
|
+
}
|
|
159
|
+
}, 0);
|
|
160
|
+
setIsBulkSelectOpen(!isBulkSelectOpen);
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
let menuToggleCheckmark: boolean | null = false;
|
|
164
|
+
if (areAllReposSelected) {
|
|
165
|
+
menuToggleCheckmark = true;
|
|
166
|
+
} else if (areSomeReposSelected) {
|
|
167
|
+
menuToggleCheckmark = null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const bulkSelectToggle = (
|
|
171
|
+
<MenuToggle
|
|
172
|
+
ref={bulkSelectToggleRef}
|
|
173
|
+
onClick={onBulkSelectToggleClick}
|
|
174
|
+
isExpanded={isBulkSelectOpen}
|
|
175
|
+
splitButtonOptions={{
|
|
176
|
+
items: [
|
|
177
|
+
<MenuToggleCheckbox
|
|
178
|
+
id="select-checkbox"
|
|
179
|
+
key="select-checkbox"
|
|
180
|
+
aria-label="Select all"
|
|
181
|
+
isChecked={menuToggleCheckmark}
|
|
182
|
+
onChange={(checked, _event) => selectAllRepos(checked)}
|
|
183
|
+
/>
|
|
184
|
+
]
|
|
185
|
+
}}
|
|
186
|
+
aria-label="Full table selection checkbox"
|
|
187
|
+
/>
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
const bulkSelectMenu = (
|
|
191
|
+
// eslint-disable-next-line no-console
|
|
192
|
+
<Menu
|
|
193
|
+
ref={bulkSelectMenuRef}
|
|
194
|
+
onSelect={(_ev, itemId) => {
|
|
195
|
+
selectAllRepos(itemId === 1 || itemId === 2);
|
|
196
|
+
setIsBulkSelectOpen(!isBulkSelectOpen);
|
|
197
|
+
}}
|
|
198
|
+
>
|
|
199
|
+
<MenuContent>
|
|
200
|
+
<MenuList>
|
|
201
|
+
<MenuItem itemId={0}>Select none (0 items)</MenuItem>
|
|
202
|
+
<MenuItem itemId={1}>Select page ({repositories.length} items)</MenuItem>
|
|
203
|
+
<MenuItem itemId={2}>Select all ({repositories.length} items)</MenuItem>
|
|
204
|
+
</MenuList>
|
|
205
|
+
</MenuContent>
|
|
206
|
+
</Menu>
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
const toolbarBulkSelect = (
|
|
210
|
+
<div ref={containerRef}>
|
|
211
|
+
<Popper
|
|
212
|
+
trigger={bulkSelectToggle}
|
|
213
|
+
popper={bulkSelectMenu}
|
|
214
|
+
appendTo={containerRef.current || undefined}
|
|
215
|
+
isVisible={isBulkSelectOpen}
|
|
216
|
+
popperMatchesTriggerWidth={false}
|
|
217
|
+
/>
|
|
218
|
+
</div>
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
const [searchValue, setSearchValue] = React.useState('');
|
|
222
|
+
|
|
223
|
+
const onSearchChange = (value: string) => {
|
|
224
|
+
setSearchValue(value);
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
const onFilter = (repo: Repository) => {
|
|
228
|
+
if (searchValue === '') {
|
|
229
|
+
return true;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
let input: RegExp;
|
|
233
|
+
try {
|
|
234
|
+
input = new RegExp(searchValue, 'i');
|
|
235
|
+
} catch (err) {
|
|
236
|
+
input = new RegExp(searchValue.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i');
|
|
237
|
+
}
|
|
238
|
+
return repo.name.search(input) >= 0;
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
const searchInput = (
|
|
242
|
+
<SearchInput
|
|
243
|
+
placeholder="Filter by server name"
|
|
244
|
+
value={searchValue}
|
|
245
|
+
onChange={onSearchChange}
|
|
246
|
+
onClear={() => onSearchChange('')}
|
|
247
|
+
/>
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
const toolbarPagination = (
|
|
251
|
+
<Pagination
|
|
252
|
+
titles={{ paginationTitle: 'Search filter pagination' }}
|
|
253
|
+
perPageComponent="button"
|
|
254
|
+
itemCount={repositories.length}
|
|
255
|
+
perPage={10}
|
|
256
|
+
page={1}
|
|
257
|
+
widgetId="search-input-mock-pagination"
|
|
258
|
+
isCompact
|
|
259
|
+
/>
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
const toolbar = (
|
|
263
|
+
<Toolbar id="search-input-filter-toolbar">
|
|
264
|
+
<ToolbarContent>
|
|
265
|
+
<ToolbarItem>{toolbarBulkSelect}</ToolbarItem>
|
|
266
|
+
<ToolbarItem variant="search-filter">{searchInput}</ToolbarItem>
|
|
267
|
+
<ToolbarItem variant="pagination">{toolbarPagination}</ToolbarItem>
|
|
268
|
+
</ToolbarContent>
|
|
269
|
+
</Toolbar>
|
|
270
|
+
);
|
|
271
|
+
|
|
272
|
+
const filteredRepos = repositories.filter(onFilter).map((repo, rowIndex) => (
|
|
273
|
+
<Tr key={repo.name}>
|
|
274
|
+
<Td
|
|
275
|
+
select={{
|
|
276
|
+
rowIndex,
|
|
277
|
+
onSelect: (_event, isSelecting) => onSelectRepo(repo, rowIndex, isSelecting),
|
|
278
|
+
isSelected: isRepoSelected(repo),
|
|
279
|
+
disable: !isRepoSelectable(repo)
|
|
280
|
+
}}
|
|
281
|
+
/>
|
|
282
|
+
<Td dataLabel={columnNames.name}>{repo.name}</Td>
|
|
283
|
+
<Td dataLabel={columnNames.threads}>{repo.threads}</Td>
|
|
284
|
+
<Td dataLabel={columnNames.apps}>{repo.apps}</Td>
|
|
285
|
+
<Td dataLabel={columnNames.workspaces}>{repo.workspaces}</Td>
|
|
286
|
+
<Td dataLabel={columnNames.status}>{repo.status}</Td>
|
|
287
|
+
<Td dataLabel={columnNames.location}>{repo.location}</Td>
|
|
288
|
+
</Tr>
|
|
289
|
+
));
|
|
290
|
+
|
|
291
|
+
const emptyState = (
|
|
292
|
+
<EmptyState>
|
|
293
|
+
<EmptyStateIcon icon={SearchIcon} />
|
|
294
|
+
<Title size="lg" headingLevel="h4">
|
|
295
|
+
No results found
|
|
296
|
+
</Title>
|
|
297
|
+
<EmptyStateBody>No results match the filter criteria. Clear all filters and try again.</EmptyStateBody>
|
|
298
|
+
<EmptyStatePrimary>
|
|
299
|
+
<Button
|
|
300
|
+
variant="link"
|
|
301
|
+
onClick={() => {
|
|
302
|
+
setSearchValue('');
|
|
303
|
+
}}
|
|
304
|
+
>
|
|
305
|
+
Clear all filters
|
|
306
|
+
</Button>
|
|
307
|
+
</EmptyStatePrimary>
|
|
308
|
+
</EmptyState>
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
return (
|
|
312
|
+
<React.Fragment>
|
|
313
|
+
{toolbar}
|
|
314
|
+
<TableComposable aria-label="Selectable table">
|
|
315
|
+
<Thead>
|
|
316
|
+
<Tr>
|
|
317
|
+
<Th />
|
|
318
|
+
<Th width={20}>{columnNames.name}</Th>
|
|
319
|
+
<Th width={20}>{columnNames.threads}</Th>
|
|
320
|
+
<Th width={20}>{columnNames.apps}</Th>
|
|
321
|
+
<Th width={20}>{columnNames.workspaces}</Th>
|
|
322
|
+
<Th width={20}>{columnNames.status}</Th>
|
|
323
|
+
<Th width={20}>{columnNames.location}</Th>
|
|
324
|
+
</Tr>
|
|
325
|
+
</Thead>
|
|
326
|
+
<Tbody>{filteredRepos.length > 0 && filteredRepos}</Tbody>
|
|
327
|
+
</TableComposable>
|
|
328
|
+
{filteredRepos.length === 0 && emptyState}
|
|
329
|
+
</React.Fragment>
|
|
330
|
+
);
|
|
331
|
+
};
|