@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.
- package/LICENSE +2 -0
- package/README.md +6 -0
- package/keycloak-theme/shared/keycloak-ui-shared/alerts/AlertPanel.tsx +43 -0
- package/keycloak-theme/shared/keycloak-ui-shared/alerts/Alerts.tsx +82 -0
- package/keycloak-theme/shared/keycloak-ui-shared/buttons/FormSubmitButton.tsx +47 -0
- package/keycloak-theme/shared/keycloak-ui-shared/context/ErrorPage.tsx +60 -0
- package/keycloak-theme/shared/keycloak-ui-shared/context/HelpContext.tsx +30 -0
- package/keycloak-theme/shared/keycloak-ui-shared/context/KeycloakContext.tsx +97 -0
- package/keycloak-theme/shared/keycloak-ui-shared/context/environment.ts +50 -0
- package/keycloak-theme/shared/keycloak-ui-shared/continue-cancel/ContinueCancelModal.tsx +75 -0
- package/keycloak-theme/shared/keycloak-ui-shared/controls/FormErrorText.tsx +23 -0
- package/keycloak-theme/shared/keycloak-ui-shared/controls/FormLabel.tsx +40 -0
- package/keycloak-theme/shared/keycloak-ui-shared/controls/HelpItem.tsx +43 -0
- package/keycloak-theme/shared/keycloak-ui-shared/controls/KeycloakSpinner.tsx +12 -0
- package/keycloak-theme/shared/keycloak-ui-shared/controls/NumberControl.tsx +93 -0
- package/keycloak-theme/shared/keycloak-ui-shared/controls/OrganizationTable.tsx +122 -0
- package/keycloak-theme/shared/keycloak-ui-shared/controls/PasswordControl.tsx +71 -0
- package/keycloak-theme/shared/keycloak-ui-shared/controls/PasswordInput.tsx +50 -0
- package/keycloak-theme/shared/keycloak-ui-shared/controls/SwitchControl.tsx +67 -0
- package/keycloak-theme/shared/keycloak-ui-shared/controls/TextAreaControl.tsx +60 -0
- package/keycloak-theme/shared/keycloak-ui-shared/controls/TextControl.tsx +75 -0
- package/keycloak-theme/shared/keycloak-ui-shared/controls/keycloak-text-area/KeycloakTextArea.tsx +23 -0
- package/keycloak-theme/shared/keycloak-ui-shared/controls/select-control/SelectControl.tsx +75 -0
- package/keycloak-theme/shared/keycloak-ui-shared/controls/select-control/SingleSelectControl.tsx +109 -0
- package/keycloak-theme/shared/keycloak-ui-shared/controls/select-control/TypeaheadSelectControl.tsx +285 -0
- package/keycloak-theme/shared/keycloak-ui-shared/controls/table/KeycloakDataTable.tsx +597 -0
- package/keycloak-theme/shared/keycloak-ui-shared/controls/table/ListEmptyState.tsx +86 -0
- package/keycloak-theme/shared/keycloak-ui-shared/controls/table/PaginatingTableToolbar.tsx +106 -0
- package/keycloak-theme/shared/keycloak-ui-shared/controls/table/TableToolbar.tsx +92 -0
- package/keycloak-theme/shared/keycloak-ui-shared/icons/IconMapper.tsx +63 -0
- package/keycloak-theme/shared/keycloak-ui-shared/index.ts +1 -0
- package/keycloak-theme/shared/keycloak-ui-shared/main.ts +96 -0
- package/keycloak-theme/shared/keycloak-ui-shared/masthead/DefaultAvatar.tsx +109 -0
- package/keycloak-theme/shared/keycloak-ui-shared/masthead/KeycloakDropdown.tsx +48 -0
- package/keycloak-theme/shared/keycloak-ui-shared/masthead/Masthead.tsx +161 -0
- package/keycloak-theme/shared/keycloak-ui-shared/scroll-form/FormPanel.tsx +29 -0
- package/keycloak-theme/shared/keycloak-ui-shared/scroll-form/FormTitle.tsx +28 -0
- package/keycloak-theme/shared/keycloak-ui-shared/scroll-form/ScrollForm.tsx +98 -0
- package/keycloak-theme/shared/keycloak-ui-shared/scroll-form/ScrollPanel.tsx +21 -0
- package/keycloak-theme/shared/keycloak-ui-shared/scroll-form/form-title.module.css +4 -0
- package/keycloak-theme/shared/keycloak-ui-shared/scroll-form/scroll-form.module.css +8 -0
- package/keycloak-theme/shared/keycloak-ui-shared/select/KeycloakSelect.tsx +49 -0
- package/keycloak-theme/shared/keycloak-ui-shared/select/SingleSelect.tsx +89 -0
- package/keycloak-theme/shared/keycloak-ui-shared/select/TypeaheadSelect.tsx +198 -0
- package/keycloak-theme/shared/keycloak-ui-shared/user-profile/LocaleSelector.tsx +51 -0
- package/keycloak-theme/shared/keycloak-ui-shared/user-profile/MultiInputComponent.tsx +146 -0
- package/keycloak-theme/shared/keycloak-ui-shared/user-profile/OptionsComponent.tsx +63 -0
- package/keycloak-theme/shared/keycloak-ui-shared/user-profile/SelectComponent.tsx +109 -0
- package/keycloak-theme/shared/keycloak-ui-shared/user-profile/TextAreaComponent.tsx +23 -0
- package/keycloak-theme/shared/keycloak-ui-shared/user-profile/TextComponent.tsx +32 -0
- package/keycloak-theme/shared/keycloak-ui-shared/user-profile/UserProfileFields.tsx +243 -0
- package/keycloak-theme/shared/keycloak-ui-shared/user-profile/UserProfileGroup.tsx +71 -0
- package/keycloak-theme/shared/keycloak-ui-shared/user-profile/utils.ts +170 -0
- package/keycloak-theme/shared/keycloak-ui-shared/utils/ErrorBoundary.tsx +77 -0
- package/keycloak-theme/shared/keycloak-ui-shared/utils/createNamedContext.ts +11 -0
- package/keycloak-theme/shared/keycloak-ui-shared/utils/darkMode.ts +19 -0
- package/keycloak-theme/shared/keycloak-ui-shared/utils/errors.ts +55 -0
- package/keycloak-theme/shared/keycloak-ui-shared/utils/generateId.ts +1 -0
- package/keycloak-theme/shared/keycloak-ui-shared/utils/getRuleValue.ts +17 -0
- package/keycloak-theme/shared/keycloak-ui-shared/utils/isDefined.ts +3 -0
- package/keycloak-theme/shared/keycloak-ui-shared/utils/useFetch.ts +44 -0
- package/keycloak-theme/shared/keycloak-ui-shared/utils/useRequiredContext.ts +24 -0
- package/keycloak-theme/shared/keycloak-ui-shared/utils/useSetTimeout.ts +40 -0
- package/keycloak-theme/shared/keycloak-ui-shared/utils/useStorageItem.ts +51 -0
- package/keycloak-theme/shared/keycloak-ui-shared/utils/useStoredState.ts +38 -0
- 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
|
+
};
|