@keycloakify/keycloak-ui-shared 26.0.6001

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 (66) hide show
  1. package/LICENSE +2 -0
  2. package/README.md +6 -0
  3. package/keycloak-theme/shared/keycloak-ui-shared/alerts/AlertPanel.tsx +43 -0
  4. package/keycloak-theme/shared/keycloak-ui-shared/alerts/Alerts.tsx +82 -0
  5. package/keycloak-theme/shared/keycloak-ui-shared/buttons/FormSubmitButton.tsx +47 -0
  6. package/keycloak-theme/shared/keycloak-ui-shared/context/ErrorPage.tsx +60 -0
  7. package/keycloak-theme/shared/keycloak-ui-shared/context/HelpContext.tsx +30 -0
  8. package/keycloak-theme/shared/keycloak-ui-shared/context/KeycloakContext.tsx +97 -0
  9. package/keycloak-theme/shared/keycloak-ui-shared/context/environment.ts +50 -0
  10. package/keycloak-theme/shared/keycloak-ui-shared/continue-cancel/ContinueCancelModal.tsx +75 -0
  11. package/keycloak-theme/shared/keycloak-ui-shared/controls/FormErrorText.tsx +23 -0
  12. package/keycloak-theme/shared/keycloak-ui-shared/controls/FormLabel.tsx +40 -0
  13. package/keycloak-theme/shared/keycloak-ui-shared/controls/HelpItem.tsx +43 -0
  14. package/keycloak-theme/shared/keycloak-ui-shared/controls/KeycloakSpinner.tsx +12 -0
  15. package/keycloak-theme/shared/keycloak-ui-shared/controls/NumberControl.tsx +93 -0
  16. package/keycloak-theme/shared/keycloak-ui-shared/controls/OrganizationTable.tsx +122 -0
  17. package/keycloak-theme/shared/keycloak-ui-shared/controls/PasswordControl.tsx +71 -0
  18. package/keycloak-theme/shared/keycloak-ui-shared/controls/PasswordInput.tsx +50 -0
  19. package/keycloak-theme/shared/keycloak-ui-shared/controls/SwitchControl.tsx +67 -0
  20. package/keycloak-theme/shared/keycloak-ui-shared/controls/TextAreaControl.tsx +60 -0
  21. package/keycloak-theme/shared/keycloak-ui-shared/controls/TextControl.tsx +75 -0
  22. package/keycloak-theme/shared/keycloak-ui-shared/controls/keycloak-text-area/KeycloakTextArea.tsx +23 -0
  23. package/keycloak-theme/shared/keycloak-ui-shared/controls/select-control/SelectControl.tsx +75 -0
  24. package/keycloak-theme/shared/keycloak-ui-shared/controls/select-control/SingleSelectControl.tsx +109 -0
  25. package/keycloak-theme/shared/keycloak-ui-shared/controls/select-control/TypeaheadSelectControl.tsx +285 -0
  26. package/keycloak-theme/shared/keycloak-ui-shared/controls/table/KeycloakDataTable.tsx +597 -0
  27. package/keycloak-theme/shared/keycloak-ui-shared/controls/table/ListEmptyState.tsx +86 -0
  28. package/keycloak-theme/shared/keycloak-ui-shared/controls/table/PaginatingTableToolbar.tsx +106 -0
  29. package/keycloak-theme/shared/keycloak-ui-shared/controls/table/TableToolbar.tsx +92 -0
  30. package/keycloak-theme/shared/keycloak-ui-shared/icons/IconMapper.tsx +63 -0
  31. package/keycloak-theme/shared/keycloak-ui-shared/index.ts +1 -0
  32. package/keycloak-theme/shared/keycloak-ui-shared/main.ts +96 -0
  33. package/keycloak-theme/shared/keycloak-ui-shared/masthead/DefaultAvatar.tsx +109 -0
  34. package/keycloak-theme/shared/keycloak-ui-shared/masthead/KeycloakDropdown.tsx +48 -0
  35. package/keycloak-theme/shared/keycloak-ui-shared/masthead/Masthead.tsx +161 -0
  36. package/keycloak-theme/shared/keycloak-ui-shared/scroll-form/FormPanel.tsx +29 -0
  37. package/keycloak-theme/shared/keycloak-ui-shared/scroll-form/FormTitle.tsx +28 -0
  38. package/keycloak-theme/shared/keycloak-ui-shared/scroll-form/ScrollForm.tsx +98 -0
  39. package/keycloak-theme/shared/keycloak-ui-shared/scroll-form/ScrollPanel.tsx +21 -0
  40. package/keycloak-theme/shared/keycloak-ui-shared/scroll-form/form-title.module.css +4 -0
  41. package/keycloak-theme/shared/keycloak-ui-shared/scroll-form/scroll-form.module.css +8 -0
  42. package/keycloak-theme/shared/keycloak-ui-shared/select/KeycloakSelect.tsx +49 -0
  43. package/keycloak-theme/shared/keycloak-ui-shared/select/SingleSelect.tsx +89 -0
  44. package/keycloak-theme/shared/keycloak-ui-shared/select/TypeaheadSelect.tsx +198 -0
  45. package/keycloak-theme/shared/keycloak-ui-shared/user-profile/LocaleSelector.tsx +51 -0
  46. package/keycloak-theme/shared/keycloak-ui-shared/user-profile/MultiInputComponent.tsx +146 -0
  47. package/keycloak-theme/shared/keycloak-ui-shared/user-profile/OptionsComponent.tsx +63 -0
  48. package/keycloak-theme/shared/keycloak-ui-shared/user-profile/SelectComponent.tsx +109 -0
  49. package/keycloak-theme/shared/keycloak-ui-shared/user-profile/TextAreaComponent.tsx +23 -0
  50. package/keycloak-theme/shared/keycloak-ui-shared/user-profile/TextComponent.tsx +32 -0
  51. package/keycloak-theme/shared/keycloak-ui-shared/user-profile/UserProfileFields.tsx +243 -0
  52. package/keycloak-theme/shared/keycloak-ui-shared/user-profile/UserProfileGroup.tsx +71 -0
  53. package/keycloak-theme/shared/keycloak-ui-shared/user-profile/utils.ts +170 -0
  54. package/keycloak-theme/shared/keycloak-ui-shared/utils/ErrorBoundary.tsx +77 -0
  55. package/keycloak-theme/shared/keycloak-ui-shared/utils/createNamedContext.ts +11 -0
  56. package/keycloak-theme/shared/keycloak-ui-shared/utils/darkMode.ts +19 -0
  57. package/keycloak-theme/shared/keycloak-ui-shared/utils/errors.ts +55 -0
  58. package/keycloak-theme/shared/keycloak-ui-shared/utils/generateId.ts +1 -0
  59. package/keycloak-theme/shared/keycloak-ui-shared/utils/getRuleValue.ts +17 -0
  60. package/keycloak-theme/shared/keycloak-ui-shared/utils/isDefined.ts +3 -0
  61. package/keycloak-theme/shared/keycloak-ui-shared/utils/useFetch.ts +44 -0
  62. package/keycloak-theme/shared/keycloak-ui-shared/utils/useRequiredContext.ts +24 -0
  63. package/keycloak-theme/shared/keycloak-ui-shared/utils/useSetTimeout.ts +40 -0
  64. package/keycloak-theme/shared/keycloak-ui-shared/utils/useStorageItem.ts +51 -0
  65. package/keycloak-theme/shared/keycloak-ui-shared/utils/useStoredState.ts +38 -0
  66. package/package.json +31 -0
@@ -0,0 +1,597 @@
1
+ import { Button, ButtonVariant, ToolbarItem } from "@patternfly/react-core";
2
+ import type { SVGIconProps } from "@patternfly/react-icons/dist/js/createIcon";
3
+ import {
4
+ ActionsColumn,
5
+ ExpandableRowContent,
6
+ IAction,
7
+ IActions,
8
+ IActionsResolver,
9
+ IFormatter,
10
+ IRow,
11
+ IRowCell,
12
+ ITransform,
13
+ Table,
14
+ TableProps,
15
+ TableVariant,
16
+ Tbody,
17
+ Td,
18
+ Th,
19
+ Thead,
20
+ Tr,
21
+ } from "@patternfly/react-table";
22
+ import { cloneDeep, differenceBy, get } from "lodash-es";
23
+ import {
24
+ ComponentClass,
25
+ ReactNode,
26
+ isValidElement,
27
+ useEffect,
28
+ useId,
29
+ useMemo,
30
+ useRef,
31
+ useState,
32
+ type JSX,
33
+ } from "react";
34
+ import { useTranslation } from "react-i18next";
35
+
36
+ import { useStoredState } from "../../utils/useStoredState";
37
+ import { useFetch } from "../../utils/useFetch";
38
+ import { ListEmptyState } from "./ListEmptyState";
39
+ import { PaginatingTableToolbar } from "./PaginatingTableToolbar";
40
+ import { SyncAltIcon } from "@patternfly/react-icons";
41
+ import { KeycloakSpinner } from "../KeycloakSpinner";
42
+
43
+ type TitleCell = { title: JSX.Element };
44
+ type Cell<T> = keyof T | JSX.Element | TitleCell;
45
+
46
+ type BaseRow<T> = {
47
+ data: T;
48
+ cells: Cell<T>[];
49
+ };
50
+
51
+ type Row<T> = BaseRow<T> & {
52
+ selected: boolean;
53
+ isOpen?: boolean;
54
+ disableSelection: boolean;
55
+ disableActions: boolean;
56
+ };
57
+
58
+ type SubRow<T> = BaseRow<T> & {
59
+ parent: number;
60
+ };
61
+
62
+ type DataTableProps<T> = {
63
+ ariaLabelKey: string;
64
+ columns: Field<T>[];
65
+ rows: (Row<T> | SubRow<T>)[];
66
+ actions?: IActions;
67
+ actionResolver?: IActionsResolver;
68
+ onSelect?: (isSelected: boolean, rowIndex: number) => void;
69
+ onCollapse?: (isOpen: boolean, rowIndex: number) => void;
70
+ canSelectAll: boolean;
71
+ isNotCompact?: boolean;
72
+ isRadio?: boolean;
73
+ };
74
+
75
+ type CellRendererProps = {
76
+ row: IRow;
77
+ };
78
+
79
+ const CellRenderer = ({ row }: CellRendererProps) => {
80
+ const isRow = (c: ReactNode | IRowCell): c is IRowCell =>
81
+ !!c && (c as IRowCell).title !== undefined;
82
+ return row.cells!.map((c, i) => (
83
+ <Td key={`cell-${i}`}>{(isRow(c) ? c.title : c) as ReactNode}</Td>
84
+ ));
85
+ };
86
+
87
+ function DataTable<T>({
88
+ columns,
89
+ rows,
90
+ actions,
91
+ actionResolver,
92
+ ariaLabelKey,
93
+ onSelect,
94
+ onCollapse,
95
+ canSelectAll,
96
+ isNotCompact,
97
+ isRadio,
98
+ ...props
99
+ }: DataTableProps<T>) {
100
+ const { t } = useTranslation();
101
+
102
+ const [selectedRows, setSelectedRows] = useState<boolean[]>([]);
103
+ const [expandedRows, setExpandedRows] = useState<boolean[]>([]);
104
+
105
+ const updateState = (rowIndex: number, isSelected: boolean) => {
106
+ const items = [
107
+ ...(rowIndex === -1 ? Array(rows.length).fill(isSelected) : selectedRows),
108
+ ];
109
+ items[rowIndex] = isSelected;
110
+ setSelectedRows(items);
111
+ };
112
+
113
+ useEffect(() => {
114
+ if (canSelectAll) {
115
+ const selectAllCheckbox = document.getElementsByName("check-all").item(0);
116
+ if (selectAllCheckbox) {
117
+ const checkbox = selectAllCheckbox as HTMLInputElement;
118
+ const selected = selectedRows.filter((r) => r === true);
119
+ checkbox.indeterminate =
120
+ selected.length < rows.length && selected.length > 0;
121
+ }
122
+ }
123
+ }, [selectedRows]);
124
+
125
+ return (
126
+ <Table
127
+ {...props}
128
+ variant={isNotCompact ? undefined : TableVariant.compact}
129
+ aria-label={t(ariaLabelKey)}
130
+ >
131
+ <Thead>
132
+ <Tr>
133
+ {onCollapse && <Th />}
134
+ {canSelectAll && (
135
+ <Th
136
+ select={
137
+ !isRadio
138
+ ? {
139
+ onSelect: (_, isSelected, rowIndex) => {
140
+ onSelect!(isSelected, rowIndex);
141
+ updateState(-1, isSelected);
142
+ },
143
+ isSelected:
144
+ selectedRows.filter((r) => r === true).length ===
145
+ rows.length,
146
+ }
147
+ : undefined
148
+ }
149
+ />
150
+ )}
151
+ {columns.map((column) => (
152
+ <Th
153
+ key={column.displayKey}
154
+ className={column.transforms?.[0]().className}
155
+ >
156
+ {t(column.displayKey || column.name)}
157
+ </Th>
158
+ ))}
159
+ </Tr>
160
+ </Thead>
161
+ {!onCollapse ? (
162
+ <Tbody>
163
+ {(rows as IRow[]).map((row, index) => (
164
+ <Tr key={index} isExpanded={expandedRows[index]}>
165
+ {onSelect && (
166
+ <Td
167
+ select={{
168
+ rowIndex: index,
169
+ onSelect: (_, isSelected, rowIndex) => {
170
+ onSelect!(isSelected, rowIndex);
171
+ updateState(rowIndex, isSelected);
172
+ },
173
+ isSelected: selectedRows[index],
174
+ variant: isRadio ? "radio" : "checkbox",
175
+ }}
176
+ />
177
+ )}
178
+ <CellRenderer row={row} />
179
+ {(actions || actionResolver) && (
180
+ <Td isActionCell>
181
+ <ActionsColumn
182
+ items={actions || actionResolver?.(row, {})!}
183
+ extraData={{ rowIndex: index }}
184
+ />
185
+ </Td>
186
+ )}
187
+ </Tr>
188
+ ))}
189
+ </Tbody>
190
+ ) : (
191
+ (rows as IRow[]).map((row, index) => (
192
+ <Tbody key={index}>
193
+ {index % 2 === 0 ? (
194
+ <Tr>
195
+ <Td
196
+ expand={{
197
+ isExpanded: !!expandedRows[index],
198
+ rowIndex: index,
199
+ expandId: `${index}`,
200
+ onToggle: (_, rowIndex, isOpen) => {
201
+ onCollapse(isOpen, rowIndex);
202
+ const expand = [...expandedRows];
203
+ expand[index] = isOpen;
204
+ setExpandedRows(expand);
205
+ },
206
+ }}
207
+ />
208
+ <CellRenderer row={row} />
209
+ </Tr>
210
+ ) : (
211
+ <Tr isExpanded={!!expandedRows[index - 1]}>
212
+ <Td />
213
+ <Td colSpan={columns.length}>
214
+ <ExpandableRowContent>
215
+ <CellRenderer row={row} />
216
+ </ExpandableRowContent>
217
+ </Td>
218
+ </Tr>
219
+ )}
220
+ </Tbody>
221
+ ))
222
+ )}
223
+ </Table>
224
+ );
225
+ }
226
+
227
+ export type Field<T> = {
228
+ name: string;
229
+ displayKey?: string;
230
+ cellFormatters?: IFormatter[];
231
+ transforms?: ITransform[];
232
+ cellRenderer?: (row: T) => JSX.Element | string;
233
+ };
234
+
235
+ export type DetailField<T> = {
236
+ name: string;
237
+ enabled?: (row: T) => boolean;
238
+ cellRenderer?: (row: T) => JSX.Element | string;
239
+ };
240
+
241
+ export type Action<T> = IAction & {
242
+ onRowClick?: (row: T) => Promise<boolean | void> | void;
243
+ };
244
+
245
+ export type LoaderFunction<T> = (
246
+ first?: number,
247
+ max?: number,
248
+ search?: string,
249
+ ) => Promise<T[]>;
250
+
251
+ export type DataListProps<T> = Omit<
252
+ TableProps,
253
+ "rows" | "cells" | "onSelect"
254
+ > & {
255
+ loader: T[] | LoaderFunction<T>;
256
+ onSelect?: (value: T[]) => void;
257
+ canSelectAll?: boolean;
258
+ detailColumns?: DetailField<T>[];
259
+ isRowDisabled?: (value: T) => boolean;
260
+ isPaginated?: boolean;
261
+ ariaLabelKey: string;
262
+ searchPlaceholderKey?: string;
263
+ columns: Field<T>[];
264
+ actions?: Action<T>[];
265
+ actionResolver?: IActionsResolver;
266
+ searchTypeComponent?: ReactNode;
267
+ toolbarItem?: ReactNode;
268
+ subToolbar?: ReactNode;
269
+ emptyState?: ReactNode;
270
+ icon?: ComponentClass<SVGIconProps>;
271
+ isNotCompact?: boolean;
272
+ isRadio?: boolean;
273
+ isSearching?: boolean;
274
+ };
275
+
276
+ /**
277
+ * A generic component that can be used to show the initial list most sections have. Takes care of the loading of the date and filtering.
278
+ * All you have to define is how the columns are displayed.
279
+ * @example
280
+ * <KeycloakDataTable columns={[
281
+ * {
282
+ * name: "clientId", //name of the field from the array of object the loader returns to display in this column
283
+ * displayKey: "clientId", //i18n key to use to lookup the name of the column header
284
+ * cellRenderer: ClientDetailLink, //optionally you can use a component to render the column when you don't want just the content of the field, the whole row / entire object is passed in.
285
+ * }
286
+ * ]}
287
+ * @param {DataListProps} props - The properties.
288
+ * @param {string} props.ariaLabelKey - The aria label key i18n key to lookup the label
289
+ * @param {string} props.searchPlaceholderKey - The i18n key to lookup the placeholder for the search box
290
+ * @param {boolean} props.isPaginated - if true the the loader will be called with first, max and search and a pager will be added in the header
291
+ * @param {(first?: number, max?: number, search?: string) => Promise<T[]>} props.loader - loader function that will fetch the data to display first, max and search are only applicable when isPaginated = true
292
+ * @param {Field<T>} props.columns - definition of the columns
293
+ * @param {Field<T>} props.detailColumns - definition of the columns expandable columns
294
+ * @param {Action[]} props.actions - the actions that appear on the row
295
+ * @param {IActionsResolver} props.actionResolver Resolver for the given action
296
+ * @param {ReactNode} props.toolbarItem - Toolbar items that appear on the top of the table {@link toolbarItem}
297
+ * @param {ReactNode} props.emptyState - ReactNode show when the list is empty could be any component but best to use {@link ListEmptyState}
298
+ */
299
+ export function KeycloakDataTable<T>({
300
+ ariaLabelKey,
301
+ searchPlaceholderKey,
302
+ isPaginated = false,
303
+ onSelect,
304
+ canSelectAll = false,
305
+ isNotCompact,
306
+ isRadio,
307
+ detailColumns,
308
+ isRowDisabled,
309
+ loader,
310
+ columns,
311
+ actions,
312
+ actionResolver,
313
+ searchTypeComponent,
314
+ toolbarItem,
315
+ subToolbar,
316
+ emptyState,
317
+ icon,
318
+ isSearching = false,
319
+ ...props
320
+ }: DataListProps<T>) {
321
+ const { t } = useTranslation();
322
+ const [selected, setSelected] = useState<T[]>([]);
323
+ const [rows, setRows] = useState<(Row<T> | SubRow<T>)[]>();
324
+ const [unPaginatedData, setUnPaginatedData] = useState<T[]>();
325
+ const [loading, setLoading] = useState(false);
326
+
327
+ const [defaultPageSize, setDefaultPageSize] = useStoredState(
328
+ localStorage,
329
+ "pageSize",
330
+ 10,
331
+ );
332
+
333
+ const [max, setMax] = useState(defaultPageSize);
334
+ const [first, setFirst] = useState(0);
335
+ const [search, setSearch] = useState<string>("");
336
+ const prevSearch = useRef<string>();
337
+
338
+ const [key, setKey] = useState(0);
339
+ const prevKey = useRef<number>();
340
+ const refresh = () => setKey(key + 1);
341
+ const id = useId();
342
+
343
+ const renderCell = (columns: (Field<T> | DetailField<T>)[], value: T) => {
344
+ return columns.map((col) => {
345
+ if ("cellFormatters" in col) {
346
+ const v = get(value, col.name);
347
+ return col.cellFormatters?.reduce((s, f) => f(s), v);
348
+ }
349
+ if (col.cellRenderer) {
350
+ const Component = col.cellRenderer;
351
+ //@ts-ignore
352
+ return { title: <Component {...value} /> };
353
+ }
354
+ return get(value, col.name);
355
+ });
356
+ };
357
+
358
+ const convertToColumns = (data: T[]): (Row<T> | SubRow<T>)[] => {
359
+ const isDetailColumnsEnabled = (value: T) =>
360
+ detailColumns?.[0]?.enabled?.(value);
361
+ return data
362
+ .map((value, index) => {
363
+ const disabledRow = isRowDisabled ? isRowDisabled(value) : false;
364
+ const row: (Row<T> | SubRow<T>)[] = [
365
+ {
366
+ data: value,
367
+ disableSelection: disabledRow,
368
+ disableActions: disabledRow,
369
+ selected: !!selected.find((v) => get(v, "id") === get(value, "id")),
370
+ isOpen: isDetailColumnsEnabled(value) ? false : undefined,
371
+ cells: renderCell(columns, value),
372
+ },
373
+ ];
374
+ if (detailColumns) {
375
+ row.push({
376
+ parent: index * 2,
377
+ cells: isDetailColumnsEnabled(value)
378
+ ? renderCell(detailColumns!, value)
379
+ : [],
380
+ } as SubRow<T>);
381
+ }
382
+ return row;
383
+ })
384
+ .flat();
385
+ };
386
+
387
+ const getNodeText = (node: Cell<T>): string => {
388
+ if (["string", "number"].includes(typeof node)) {
389
+ return node!.toString();
390
+ }
391
+ if (node instanceof Array) {
392
+ return node.map(getNodeText).join("");
393
+ }
394
+ if (typeof node === "object") {
395
+ return getNodeText(
396
+ isValidElement((node as TitleCell).title)
397
+ ? (node as TitleCell).title.props
398
+ : Object.values(node),
399
+ );
400
+ }
401
+ return "";
402
+ };
403
+
404
+ const filteredData = useMemo<(Row<T> | SubRow<T>)[] | undefined>(
405
+ () =>
406
+ search === "" || isPaginated
407
+ ? undefined
408
+ : convertToColumns(unPaginatedData || [])
409
+ .filter((row) =>
410
+ row.cells.some(
411
+ (cell) =>
412
+ cell &&
413
+ getNodeText(cell)
414
+ .toLowerCase()
415
+ .includes(search.toLowerCase()),
416
+ ),
417
+ )
418
+ .slice(first, first + max + 1),
419
+ [search, first, max],
420
+ );
421
+
422
+ useFetch(
423
+ async () => {
424
+ setLoading(true);
425
+ const newSearch = prevSearch.current === "" && search !== "";
426
+
427
+ if (newSearch) {
428
+ setFirst(0);
429
+ }
430
+ prevSearch.current = search;
431
+ return typeof loader === "function"
432
+ ? key === prevKey.current && unPaginatedData
433
+ ? unPaginatedData
434
+ : await loader(newSearch ? 0 : first, max + 1, search)
435
+ : loader;
436
+ },
437
+ (data) => {
438
+ prevKey.current = key;
439
+ if (!isPaginated) {
440
+ setUnPaginatedData(data);
441
+ if (data.length > first) {
442
+ data = data.slice(first, first + max + 1);
443
+ } else {
444
+ setFirst(0);
445
+ }
446
+ }
447
+
448
+ const result = convertToColumns(data);
449
+ setRows(result);
450
+ setLoading(false);
451
+ },
452
+ [
453
+ key,
454
+ first,
455
+ max,
456
+ search,
457
+ typeof loader !== "function" ? loader : undefined,
458
+ ],
459
+ );
460
+
461
+ const convertAction = () =>
462
+ actions &&
463
+ cloneDeep(actions).map((action: Action<T>, index: number) => {
464
+ delete action.onRowClick;
465
+ action.onClick = async (_, rowIndex) => {
466
+ const result = await actions[index].onRowClick!(
467
+ (filteredData || rows)![rowIndex].data,
468
+ );
469
+ if (result) {
470
+ if (!isPaginated) {
471
+ setSearch("");
472
+ }
473
+ refresh();
474
+ }
475
+ };
476
+ return action;
477
+ });
478
+
479
+ const _onSelect = (isSelected: boolean, rowIndex: number) => {
480
+ const data = filteredData || rows;
481
+ if (rowIndex === -1) {
482
+ setRows(
483
+ data!.map((row) => {
484
+ (row as Row<T>).selected = isSelected;
485
+ return row;
486
+ }),
487
+ );
488
+ } else {
489
+ (data![rowIndex] as Row<T>).selected = isSelected;
490
+
491
+ setRows([...rows!]);
492
+ }
493
+
494
+ // Keeps selected items when paginating
495
+ const difference = differenceBy(
496
+ selected,
497
+ data!.map((row) => row.data),
498
+ "id",
499
+ );
500
+
501
+ // Selected rows are any rows previously selected from a different page, plus current page selections
502
+ const selectedRows = [
503
+ ...difference,
504
+ ...data!.filter((row) => (row as Row<T>).selected).map((row) => row.data),
505
+ ];
506
+
507
+ setSelected(selectedRows);
508
+ onSelect!(selectedRows);
509
+ };
510
+
511
+ const onCollapse = (isOpen: boolean, rowIndex: number) => {
512
+ (data![rowIndex] as Row<T>).isOpen = isOpen;
513
+ setRows([...data!]);
514
+ };
515
+
516
+ const data = filteredData || rows;
517
+ const noData = !data || data.length === 0;
518
+ const searching = search !== "" || isSearching;
519
+ // if we use detail columns there are twice the number of rows
520
+ const maxRows = detailColumns ? max * 2 : max;
521
+ const rowLength = detailColumns ? (data?.length || 0) / 2 : data?.length || 0;
522
+
523
+ return (
524
+ <>
525
+ {(loading || !noData || searching) && (
526
+ <PaginatingTableToolbar
527
+ id={id}
528
+ count={rowLength}
529
+ first={first}
530
+ max={max}
531
+ onNextClick={setFirst}
532
+ onPreviousClick={setFirst}
533
+ onPerPageSelect={(first, max) => {
534
+ setFirst(first);
535
+ setMax(max);
536
+ setDefaultPageSize(max);
537
+ }}
538
+ inputGroupName={
539
+ searchPlaceholderKey ? `${ariaLabelKey}input` : undefined
540
+ }
541
+ inputGroupOnEnter={setSearch}
542
+ inputGroupPlaceholder={t(searchPlaceholderKey || "")}
543
+ searchTypeComponent={searchTypeComponent}
544
+ toolbarItem={
545
+ <>
546
+ {toolbarItem} <ToolbarItem variant="separator" />{" "}
547
+ <ToolbarItem>
548
+ <Button variant="link" onClick={refresh}>
549
+ <SyncAltIcon /> {t("refresh")}
550
+ </Button>
551
+ </ToolbarItem>
552
+ </>
553
+ }
554
+ subToolbar={subToolbar}
555
+ >
556
+ {!loading && !noData && (
557
+ <DataTable
558
+ {...props}
559
+ canSelectAll={canSelectAll}
560
+ onSelect={onSelect ? _onSelect : undefined}
561
+ onCollapse={detailColumns ? onCollapse : undefined}
562
+ actions={convertAction()}
563
+ actionResolver={actionResolver}
564
+ rows={data.slice(0, maxRows)}
565
+ columns={columns}
566
+ isNotCompact={isNotCompact}
567
+ isRadio={isRadio}
568
+ ariaLabelKey={ariaLabelKey}
569
+ />
570
+ )}
571
+ {!loading && noData && searching && (
572
+ <ListEmptyState
573
+ hasIcon={true}
574
+ icon={icon}
575
+ isSearchVariant={true}
576
+ message={t("noSearchResults")}
577
+ instructions={t("noSearchResultsInstructions")}
578
+ secondaryActions={
579
+ !isSearching
580
+ ? [
581
+ {
582
+ text: t("clearAllFilters"),
583
+ onClick: () => setSearch(""),
584
+ type: ButtonVariant.link,
585
+ },
586
+ ]
587
+ : []
588
+ }
589
+ />
590
+ )}
591
+ {loading && <KeycloakSpinner />}
592
+ </PaginatingTableToolbar>
593
+ )}
594
+ {!loading && noData && !searching && emptyState}
595
+ </>
596
+ );
597
+ }
@@ -0,0 +1,86 @@
1
+ import { ComponentClass, MouseEventHandler, ReactNode } from "react";
2
+ import {
3
+ EmptyState,
4
+ EmptyStateIcon,
5
+ EmptyStateBody,
6
+ Button,
7
+ ButtonVariant,
8
+ EmptyStateActions,
9
+ EmptyStateHeader,
10
+ EmptyStateFooter,
11
+ } from "@patternfly/react-core";
12
+ import type { SVGIconProps } from "@patternfly/react-icons/dist/js/createIcon";
13
+ import { PlusCircleIcon, SearchIcon } from "@patternfly/react-icons";
14
+
15
+ export type Action = {
16
+ text: string;
17
+ type?: ButtonVariant;
18
+ onClick: MouseEventHandler<HTMLButtonElement>;
19
+ };
20
+
21
+ export type ListEmptyStateProps = {
22
+ message: string;
23
+ instructions: ReactNode;
24
+ primaryActionText?: string;
25
+ onPrimaryAction?: MouseEventHandler<HTMLButtonElement>;
26
+ hasIcon?: boolean;
27
+ icon?: ComponentClass<SVGIconProps>;
28
+ isSearchVariant?: boolean;
29
+ secondaryActions?: Action[];
30
+ isDisabled?: boolean;
31
+ };
32
+
33
+ export const ListEmptyState = ({
34
+ message,
35
+ instructions,
36
+ onPrimaryAction,
37
+ hasIcon = true,
38
+ isSearchVariant,
39
+ primaryActionText,
40
+ secondaryActions,
41
+ icon,
42
+ isDisabled = false,
43
+ }: ListEmptyStateProps) => {
44
+ return (
45
+ <EmptyState data-testid="empty-state" variant="lg">
46
+ {hasIcon && isSearchVariant ? (
47
+ <EmptyStateIcon icon={SearchIcon} />
48
+ ) : (
49
+ hasIcon && <EmptyStateIcon icon={icon ? icon : PlusCircleIcon} />
50
+ )}
51
+ <EmptyStateHeader titleText={message} headingLevel="h1" />
52
+ <EmptyStateBody>{instructions}</EmptyStateBody>
53
+ <EmptyStateFooter>
54
+ {primaryActionText && (
55
+ <Button
56
+ data-testid={`${message
57
+ .replace(/\W+/g, "-")
58
+ .toLowerCase()}-empty-action`}
59
+ variant="primary"
60
+ onClick={onPrimaryAction}
61
+ isDisabled={isDisabled}
62
+ >
63
+ {primaryActionText}
64
+ </Button>
65
+ )}
66
+ {secondaryActions && (
67
+ <EmptyStateActions>
68
+ {secondaryActions.map((action) => (
69
+ <Button
70
+ key={action.text}
71
+ data-testid={`${action.text
72
+ .replace(/\W+/g, "-")
73
+ .toLowerCase()}-empty-action`}
74
+ variant={action.type || ButtonVariant.secondary}
75
+ onClick={action.onClick}
76
+ isDisabled={isDisabled}
77
+ >
78
+ {action.text}
79
+ </Button>
80
+ ))}
81
+ </EmptyStateActions>
82
+ )}
83
+ </EmptyStateFooter>
84
+ </EmptyState>
85
+ );
86
+ };