@keycloakify/keycloak-ui-shared 260007.0.5 → 260200.0.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 (23) hide show
  1. package/keycloak-theme/shared/keycloak-ui-shared/context/ErrorPage.tsx +10 -6
  2. package/keycloak-theme/shared/keycloak-ui-shared/context/KeycloakContext.tsx +9 -2
  3. package/keycloak-theme/shared/keycloak-ui-shared/context/environment.ts +2 -0
  4. package/keycloak-theme/shared/keycloak-ui-shared/controls/OrganizationTable.tsx +3 -0
  5. package/keycloak-theme/shared/keycloak-ui-shared/controls/PasswordControl.tsx +2 -1
  6. package/keycloak-theme/shared/keycloak-ui-shared/controls/SwitchControl.tsx +2 -1
  7. package/keycloak-theme/shared/keycloak-ui-shared/controls/TextAreaControl.tsx +1 -0
  8. package/keycloak-theme/shared/keycloak-ui-shared/controls/TextControl.tsx +3 -2
  9. package/keycloak-theme/shared/keycloak-ui-shared/controls/select-control/SelectControl.tsx +4 -0
  10. package/keycloak-theme/shared/keycloak-ui-shared/controls/select-control/SingleSelectControl.tsx +13 -4
  11. package/keycloak-theme/shared/keycloak-ui-shared/controls/select-control/TypeaheadSelectControl.tsx +9 -8
  12. package/keycloak-theme/shared/keycloak-ui-shared/controls/table/KeycloakDataTable.tsx +134 -92
  13. package/keycloak-theme/shared/keycloak-ui-shared/controls/table/TableToolbar.tsx +7 -15
  14. package/keycloak-theme/shared/keycloak-ui-shared/icons/IconMapper.tsx +0 -1
  15. package/keycloak-theme/shared/keycloak-ui-shared/main.ts +0 -1
  16. package/keycloak-theme/shared/keycloak-ui-shared/masthead/Masthead.tsx +3 -0
  17. package/keycloak-theme/shared/keycloak-ui-shared/select/KeycloakSelect.tsx +1 -1
  18. package/keycloak-theme/shared/keycloak-ui-shared/select/TypeaheadSelect.tsx +5 -1
  19. package/keycloak-theme/shared/keycloak-ui-shared/user-profile/LocaleSelector.tsx +1 -1
  20. package/keycloak-theme/shared/keycloak-ui-shared/user-profile/SelectComponent.tsx +1 -1
  21. package/keycloak-theme/shared/keycloak-ui-shared/user-profile/TextComponent.tsx +12 -6
  22. package/keycloak-theme/shared/keycloak-ui-shared/user-profile/utils.ts +4 -28
  23. package/package.json +11 -11
@@ -9,9 +9,9 @@ import {
9
9
  Page,
10
10
  Text,
11
11
  TextContent,
12
- TextVariants,
13
12
  } from "../../@patternfly/react-core";
14
13
  import { useTranslation } from "react-i18next";
14
+ import { getNetworkErrorDescription } from "../utils/errors";
15
15
 
16
16
  type ErrorPageProps = {
17
17
  error?: unknown;
@@ -20,7 +20,10 @@ type ErrorPageProps = {
20
20
  export const ErrorPage = (props: ErrorPageProps) => {
21
21
  const { t } = useTranslation();
22
22
  const error = props.error;
23
- const errorMessage = getErrorMessage(error);
23
+ const errorMessage =
24
+ getErrorMessage(error) ||
25
+ getNetworkErrorDescription(error)?.replace(/\+/g, " ");
26
+ console.error(error);
24
27
 
25
28
  function onRetry() {
26
29
  location.href = location.origin + location.pathname;
@@ -30,7 +33,7 @@ export const ErrorPage = (props: ErrorPageProps) => {
30
33
  <Page>
31
34
  <Modal
32
35
  variant={ModalVariant.small}
33
- title={t("somethingWentWrong")}
36
+ title={errorMessage ? "" : t("somethingWentWrong")}
34
37
  titleIconVariant="danger"
35
38
  showClose={false}
36
39
  isOpen
@@ -41,9 +44,10 @@ export const ErrorPage = (props: ErrorPageProps) => {
41
44
  ]}
42
45
  >
43
46
  <TextContent>
44
- <Text>{t("somethingWentWrongDescription")}</Text>
45
- {errorMessage && (
46
- <Text component={TextVariants.small}>{errorMessage}</Text>
47
+ {errorMessage ? (
48
+ <Text>{t(errorMessage)}</Text>
49
+ ) : (
50
+ <Text>{t("somethingWentWrongDescription")}</Text>
47
51
  )}
48
52
  </TextContent>
49
53
  </Modal>
@@ -74,6 +74,7 @@ export const KeycloakProvider = <T extends BaseEnvironment>({
74
74
  onLoad: "check-sso",
75
75
  pkceMethod: "S256",
76
76
  responseMode: "query",
77
+ scope: environment.scope,
77
78
  });
78
79
 
79
80
  init()
@@ -83,8 +84,14 @@ export const KeycloakProvider = <T extends BaseEnvironment>({
83
84
  calledOnce.current = true;
84
85
  }, [keycloak]);
85
86
 
86
- if (error) {
87
- return <ErrorPage error={error} />;
87
+ const searchParams = new URLSearchParams(window.location.search);
88
+
89
+ if (error || searchParams.get("error_description")) {
90
+ return (
91
+ <ErrorPage
92
+ error={error ? error : searchParams.get("error_description")}
93
+ />
94
+ );
88
95
  }
89
96
 
90
97
  if (!init) {
@@ -23,6 +23,8 @@ export type BaseEnvironment = {
23
23
  logo: string;
24
24
  /** The URL to be followed when the logo is clicked. */
25
25
  logoUrl: string;
26
+ /** The scopes to be requested when sending authorization requests*/
27
+ scope?: string;
26
28
  };
27
29
 
28
30
  /**
@@ -67,6 +67,7 @@ export type OrganizationTableProps = PropsWithChildren & {
67
67
  toolbarItem?: ReactNode;
68
68
  isPaginated?: boolean;
69
69
  isSearching?: boolean;
70
+ searchPlaceholderKey?: string;
70
71
  onSelect?: (orgs: OrganizationRepresentation[]) => void;
71
72
  onDelete?: (org: OrganizationRepresentation) => void;
72
73
  deleteLabel?: string;
@@ -77,6 +78,7 @@ export const OrganizationTable = ({
77
78
  toolbarItem,
78
79
  isPaginated = false,
79
80
  isSearching = false,
81
+ searchPlaceholderKey,
80
82
  onSelect,
81
83
  onDelete,
82
84
  deleteLabel = "delete",
@@ -91,6 +93,7 @@ export const OrganizationTable = ({
91
93
  isPaginated={isPaginated}
92
94
  isSearching={isSearching}
93
95
  ariaLabelKey="organizationList"
96
+ searchPlaceholderKey={searchPlaceholderKey}
94
97
  toolbarItem={toolbarItem}
95
98
  onSelect={onSelect}
96
99
  canSelectAll={onSelect !== undefined}
@@ -15,6 +15,7 @@ import {
15
15
  UseControllerProps,
16
16
  useController,
17
17
  } from "react-hook-form";
18
+ import { getRuleValue } from "../utils/getRuleValue";
18
19
  import { FormLabel } from "./FormLabel";
19
20
  import { PasswordInput, PasswordInputProps } from "./PasswordInput";
20
21
 
@@ -36,7 +37,7 @@ export const PasswordControl = <
36
37
  props: PasswordControlProps<T, P>,
37
38
  ) => {
38
39
  const { labelIcon, ...rest } = props;
39
- const required = !!props.rules?.required;
40
+ const required = !!getRuleValue(props.rules?.required);
40
41
  const defaultValue = props.defaultValue ?? ("" as PathValue<T, P>);
41
42
 
42
43
  const { field, fieldState } = useController({
@@ -12,6 +12,7 @@ import {
12
12
  } from "react-hook-form";
13
13
  import { SwitchProps, Switch } from "../../@patternfly/react-core";
14
14
  import { FormLabel } from "./FormLabel";
15
+ import { debeerify } from "../user-profile/utils";
15
16
 
16
17
  export type SwitchControlProps<
17
18
  T extends FieldValues,
@@ -55,7 +56,7 @@ export const SwitchControl = <
55
56
  <Switch
56
57
  {...props}
57
58
  id={props.name}
58
- data-testid={props.name}
59
+ data-testid={debeerify(props.name)}
59
60
  label={labelOn}
60
61
  isChecked={stringify ? value === "true" : value}
61
62
  onChange={(e, checked) => {
@@ -57,6 +57,7 @@ export const TextAreaControl = <
57
57
  fieldState.error ? ValidatedOptions.error : ValidatedOptions.default
58
58
  }
59
59
  isDisabled={props.isDisabled}
60
+ {...props}
60
61
  {...field}
61
62
  />
62
63
  </FormLabel>
@@ -18,7 +18,7 @@ import {
18
18
  UseControllerProps,
19
19
  useController,
20
20
  } from "react-hook-form";
21
-
21
+ import { getRuleValue } from "../utils/getRuleValue";
22
22
  import { FormLabel } from "./FormLabel";
23
23
 
24
24
  export type TextControlProps<
@@ -31,6 +31,7 @@ export type TextControlProps<
31
31
  isDisabled?: boolean;
32
32
  helperText?: string;
33
33
  "data-testid"?: string;
34
+ type?: string;
34
35
  };
35
36
 
36
37
  export const TextControl = <
@@ -40,7 +41,7 @@ export const TextControl = <
40
41
  props: TextControlProps<T, P>,
41
42
  ) => {
42
43
  const { labelIcon, helperText, ...rest } = props;
43
- const required = !!props.rules?.required;
44
+ const required = !!getRuleValue(props.rules?.required);
44
45
  const defaultValue = props.defaultValue ?? ("" as PathValue<T, P>);
45
46
 
46
47
  const { field, fieldState } = useController({
@@ -53,6 +53,10 @@ export type SelectControlProps<
53
53
  menuAppendTo?: string;
54
54
  placeholderText?: string;
55
55
  chipGroupProps?: ChipGroupProps;
56
+ onSelect?: (
57
+ value: string | string[],
58
+ onChangeHandler: (value: string | string[]) => void,
59
+ ) => void;
56
60
  };
57
61
 
58
62
  export const isSelectBasedOptions = (
@@ -36,6 +36,8 @@ export const SingleSelectControl = <
36
36
  options,
37
37
  controller,
38
38
  labelIcon,
39
+ isDisabled,
40
+ onSelect,
39
41
  ...rest
40
42
  }: SelectControlProps<T, P>) => {
41
43
  const {
@@ -60,6 +62,7 @@ export const SingleSelectControl = <
60
62
  render={({ field: { onChange, value } }) => (
61
63
  <Select
62
64
  {...rest}
65
+ variant="default"
63
66
  onClick={() => setOpen(!open)}
64
67
  onOpenChange={() => setOpen(false)}
65
68
  selected={
@@ -81,7 +84,8 @@ export const SingleSelectControl = <
81
84
  isExpanded={open}
82
85
  isFullWidth
83
86
  status={get(errors, name) ? MenuToggleStatus.danger : undefined}
84
- aria-label="toggle"
87
+ aria-label={label}
88
+ isDisabled={isDisabled}
85
89
  >
86
90
  {isSelectBasedOptions(options)
87
91
  ? options.find(
@@ -92,13 +96,18 @@ export const SingleSelectControl = <
92
96
  </MenuToggle>
93
97
  )}
94
98
  onSelect={(_event, v) => {
95
- const option = v?.toString();
96
- onChange(Array.isArray(value) ? [option] : option);
99
+ const option = v?.toString()!;
100
+ const convertedValue = Array.isArray(value) ? [option] : option;
101
+ if (onSelect) {
102
+ onSelect(convertedValue, onChange);
103
+ } else {
104
+ onChange(convertedValue);
105
+ }
97
106
  setOpen(false);
98
107
  }}
99
108
  isOpen={open}
100
109
  >
101
- <SelectList>
110
+ <SelectList data-testid={`select-${name}`}>
102
111
  {options.map((option) => (
103
112
  <SelectOption key={key(option)} value={key(option)}>
104
113
  {isString(option) ? option : option.value}
@@ -63,6 +63,7 @@ export const TypeaheadSelectControl = <
63
63
  const [focusedItemIndex, setFocusedItemIndex] = useState<number>(0);
64
64
  const textInputRef = useRef<HTMLInputElement>();
65
65
  const required = getRuleValue(controller.rules?.required) === true;
66
+ const isTypeaheadMulti = variant === SelectVariant.typeaheadMulti;
66
67
 
67
68
  const filteredOptions = options.filter((option) =>
68
69
  getValue(option).toLowerCase().startsWith(filterValue.toLowerCase()),
@@ -93,7 +94,7 @@ export const TypeaheadSelectControl = <
93
94
  case "Enter": {
94
95
  event.preventDefault();
95
96
 
96
- if (variant !== SelectVariant.typeaheadMulti) {
97
+ if (!isTypeaheadMulti) {
97
98
  setFilterValue(getValue(focusedItem));
98
99
  } else {
99
100
  setFilterValue("");
@@ -164,7 +165,6 @@ export const TypeaheadSelectControl = <
164
165
  render={({ field }) => (
165
166
  <Select
166
167
  {...rest}
167
- onClick={() => setOpen(!open)}
168
168
  onOpenChange={() => setOpen(false)}
169
169
  selected={
170
170
  isSelectBasedOptions(options)
@@ -177,12 +177,16 @@ export const TypeaheadSelectControl = <
177
177
  .map((o) => o.value)
178
178
  : field.value
179
179
  }
180
+ shouldFocusFirstItemOnOpen={false}
180
181
  toggle={(ref) => (
181
182
  <MenuToggle
182
183
  ref={ref}
183
184
  id={id || name.slice(name.lastIndexOf(".") + 1)}
184
185
  variant="typeahead"
185
- onClick={() => setOpen(!open)}
186
+ onClick={() => {
187
+ setOpen(!open);
188
+ textInputRef.current?.focus();
189
+ }}
186
190
  isExpanded={open}
187
191
  isFullWidth
188
192
  status={get(errors, name) ? MenuToggleStatus.danger : undefined}
@@ -247,7 +251,7 @@ export const TypeaheadSelectControl = <
247
251
  variant="plain"
248
252
  onClick={() => {
249
253
  setFilterValue("");
250
- field.onChange("");
254
+ field.onChange(isTypeaheadMulti ? [] : "");
251
255
  textInputRef?.current?.focus();
252
256
  }}
253
257
  aria-label="Clear input value"
@@ -262,10 +266,7 @@ export const TypeaheadSelectControl = <
262
266
  onSelect={(event, v) => {
263
267
  event?.stopPropagation();
264
268
  const option = v?.toString();
265
- if (
266
- variant === SelectVariant.typeaheadMulti &&
267
- Array.isArray(field.value)
268
- ) {
269
+ if (isTypeaheadMulti && Array.isArray(field.value)) {
269
270
  if (field.value.includes(option)) {
270
271
  field.onChange(
271
272
  field.value.filter((item: string) => item !== option),
@@ -3,6 +3,7 @@
3
3
  // @ts-nocheck
4
4
 
5
5
  import { Button, ButtonVariant, ToolbarItem } from "../../../@patternfly/react-core";
6
+ import { SyncAltIcon } from "../../../@patternfly/react-icons";
6
7
  import type { SVGIconProps } from "@patternfly/react-icons/dist/js/createIcon";
7
8
  import {
8
9
  ActionsColumn,
@@ -23,7 +24,7 @@ import {
23
24
  Thead,
24
25
  Tr,
25
26
  } from "../../../@patternfly/react-table";
26
- import { cloneDeep, differenceBy, get } from "lodash-es";
27
+ import { cloneDeep, get, intersectionBy } from "lodash-es";
27
28
  import {
28
29
  ComponentClass,
29
30
  ReactNode,
@@ -36,13 +37,11 @@ import {
36
37
  type JSX,
37
38
  } from "react";
38
39
  import { useTranslation } from "react-i18next";
39
-
40
- import { useStoredState } from "../../utils/useStoredState";
41
40
  import { useFetch } from "../../utils/useFetch";
41
+ import { useStoredState } from "../../utils/useStoredState";
42
+ import { KeycloakSpinner } from "../KeycloakSpinner";
42
43
  import { ListEmptyState } from "./ListEmptyState";
43
44
  import { PaginatingTableToolbar } from "./PaginatingTableToolbar";
44
- import { SyncAltIcon } from "../../../@patternfly/react-icons";
45
- import { KeycloakSpinner } from "../KeycloakSpinner";
46
45
 
47
46
  type TitleCell = { title: JSX.Element };
48
47
  type Cell<T> = keyof T | JSX.Element | TitleCell;
@@ -69,24 +68,50 @@ type DataTableProps<T> = {
69
68
  rows: (Row<T> | SubRow<T>)[];
70
69
  actions?: IActions;
71
70
  actionResolver?: IActionsResolver;
72
- onSelect?: (isSelected: boolean, rowIndex: number) => void;
71
+ selected?: T[];
72
+ onSelect?: (value: T[]) => void;
73
73
  onCollapse?: (isOpen: boolean, rowIndex: number) => void;
74
74
  canSelectAll: boolean;
75
+ canSelect: boolean;
75
76
  isNotCompact?: boolean;
76
77
  isRadio?: boolean;
77
78
  };
78
79
 
79
80
  type CellRendererProps = {
80
81
  row: IRow;
82
+ index?: number;
83
+ actions?: IActions;
84
+ actionResolver?: IActionsResolver;
81
85
  };
82
86
 
83
- const CellRenderer = ({ row }: CellRendererProps) => {
84
- const isRow = (c: ReactNode | IRowCell): c is IRowCell =>
85
- !!c && (c as IRowCell).title !== undefined;
86
- return row.cells!.map((c, i) => (
87
- <Td key={`cell-${i}`}>{(isRow(c) ? c.title : c) as ReactNode}</Td>
87
+ const isRow = (c: ReactNode | IRowCell): c is IRowCell =>
88
+ !!c && (c as IRowCell).title !== undefined;
89
+
90
+ const CellRenderer = ({
91
+ row,
92
+ index,
93
+ actions,
94
+ actionResolver,
95
+ }: CellRendererProps) => (
96
+ <>
97
+ {row.cells!.map((c, i) => (
98
+ <Td key={`cell-${i}`}>{(isRow(c) ? c.title : c) as ReactNode}</Td>
99
+ ))}
100
+ {(actions || actionResolver) && (
101
+ <Td isActionCell>
102
+ <ActionsColumn
103
+ items={actions || actionResolver?.(row, {})!}
104
+ extraData={{ rowIndex: index }}
105
+ />
106
+ </Td>
107
+ )}
108
+ </>
109
+ );
110
+
111
+ const ExpandableRowRenderer = ({ row }: CellRendererProps) =>
112
+ row.cells!.map((c, i) => (
113
+ <div key={`cell-${i}`}>{(isRow(c) ? c.title : c) as ReactNode}</div>
88
114
  ));
89
- };
90
115
 
91
116
  function DataTable<T>({
92
117
  columns,
@@ -94,37 +119,75 @@ function DataTable<T>({
94
119
  actions,
95
120
  actionResolver,
96
121
  ariaLabelKey,
122
+ selected,
97
123
  onSelect,
98
124
  onCollapse,
99
125
  canSelectAll,
126
+ canSelect,
100
127
  isNotCompact,
101
128
  isRadio,
102
129
  ...props
103
130
  }: DataTableProps<T>) {
104
131
  const { t } = useTranslation();
105
-
106
- const [selectedRows, setSelectedRows] = useState<boolean[]>([]);
132
+ const [selectedRows, setSelectedRows] = useState<T[]>(selected || []);
107
133
  const [expandedRows, setExpandedRows] = useState<boolean[]>([]);
108
134
 
109
- const updateState = (rowIndex: number, isSelected: boolean) => {
110
- const items = [
111
- ...(rowIndex === -1 ? Array(rows.length).fill(isSelected) : selectedRows),
112
- ];
113
- items[rowIndex] = isSelected;
114
- setSelectedRows(items);
115
- };
135
+ const rowsSelectedOnPage = useMemo(
136
+ () =>
137
+ intersectionBy(
138
+ selectedRows,
139
+ rows.map((row) => row.data),
140
+ "id",
141
+ ),
142
+ [selectedRows, rows],
143
+ );
116
144
 
117
145
  useEffect(() => {
118
146
  if (canSelectAll) {
119
147
  const selectAllCheckbox = document.getElementsByName("check-all").item(0);
120
148
  if (selectAllCheckbox) {
121
149
  const checkbox = selectAllCheckbox as HTMLInputElement;
122
- const selected = selectedRows.filter((r) => r === true);
123
150
  checkbox.indeterminate =
124
- selected.length < rows.length && selected.length > 0;
151
+ rowsSelectedOnPage.length < rows.length &&
152
+ rowsSelectedOnPage.length > 0;
125
153
  }
126
154
  }
127
- }, [selectedRows]);
155
+ }, [selectedRows, canSelectAll, rows]);
156
+
157
+ const updateSelectedRows = (selected: T[]) => {
158
+ setSelectedRows(selected);
159
+ onSelect?.(selected);
160
+ };
161
+
162
+ const updateState = (rowIndex: number, isSelected: boolean) => {
163
+ if (isRadio) {
164
+ const selectedRow = isSelected ? [rows[rowIndex].data] : [];
165
+ updateSelectedRows(selectedRow);
166
+ } else {
167
+ if (rowIndex === -1) {
168
+ const rowsSelectedOnPageIds = rowsSelectedOnPage.map((v) =>
169
+ get(v, "id"),
170
+ );
171
+ updateSelectedRows(
172
+ isSelected
173
+ ? [...selectedRows, ...rows.map((row) => row.data)]
174
+ : selectedRows.filter(
175
+ (v) => !rowsSelectedOnPageIds.includes(get(v, "id")),
176
+ ),
177
+ );
178
+ } else {
179
+ if (isSelected) {
180
+ updateSelectedRows([...selectedRows, rows[rowIndex].data]);
181
+ } else {
182
+ updateSelectedRows(
183
+ selectedRows.filter(
184
+ (v) => get(v, "id") !== (rows[rowIndex] as IRow).data.id,
185
+ ),
186
+ );
187
+ }
188
+ }
189
+ }
190
+ };
128
191
 
129
192
  return (
130
193
  <Table
@@ -134,19 +197,17 @@ function DataTable<T>({
134
197
  >
135
198
  <Thead>
136
199
  <Tr>
137
- {onCollapse && <Th />}
200
+ {onCollapse && <Th screenReaderText={t("expandRow")} />}
138
201
  {canSelectAll && (
139
202
  <Th
203
+ screenReaderText={t("selectAll")}
140
204
  select={
141
205
  !isRadio
142
206
  ? {
143
- onSelect: (_, isSelected, rowIndex) => {
144
- onSelect!(isSelected, rowIndex);
207
+ onSelect: (_, isSelected) => {
145
208
  updateState(-1, isSelected);
146
209
  },
147
- isSelected:
148
- selectedRows.filter((r) => r === true).length ===
149
- rows.length,
210
+ isSelected: rowsSelectedOnPage.length === rows.length,
150
211
  }
151
212
  : undefined
152
213
  }
@@ -154,7 +215,8 @@ function DataTable<T>({
154
215
  )}
155
216
  {columns.map((column) => (
156
217
  <Th
157
- key={column.displayKey}
218
+ screenReaderText={t("expandRow")}
219
+ key={column.displayKey || column.name}
158
220
  className={column.transforms?.[0]().className}
159
221
  >
160
222
  {t(column.displayKey || column.name)}
@@ -166,28 +228,26 @@ function DataTable<T>({
166
228
  <Tbody>
167
229
  {(rows as IRow[]).map((row, index) => (
168
230
  <Tr key={index} isExpanded={expandedRows[index]}>
169
- {onSelect && (
231
+ {canSelect && (
170
232
  <Td
171
233
  select={{
172
234
  rowIndex: index,
173
235
  onSelect: (_, isSelected, rowIndex) => {
174
- onSelect!(isSelected, rowIndex);
175
236
  updateState(rowIndex, isSelected);
176
237
  },
177
- isSelected: selectedRows[index],
238
+ isSelected: !!selectedRows.find(
239
+ (v) => get(v, "id") === row.data.id,
240
+ ),
178
241
  variant: isRadio ? "radio" : "checkbox",
179
242
  }}
180
243
  />
181
244
  )}
182
- <CellRenderer row={row} />
183
- {(actions || actionResolver) && (
184
- <Td isActionCell>
185
- <ActionsColumn
186
- items={actions || actionResolver?.(row, {})!}
187
- extraData={{ rowIndex: index }}
188
- />
189
- </Td>
190
- )}
245
+ <CellRenderer
246
+ row={row}
247
+ index={index}
248
+ actions={actions}
249
+ actionResolver={actionResolver}
250
+ />
191
251
  </Tr>
192
252
  ))}
193
253
  </Tbody>
@@ -197,26 +257,35 @@ function DataTable<T>({
197
257
  {index % 2 === 0 ? (
198
258
  <Tr>
199
259
  <Td
200
- expand={{
201
- isExpanded: !!expandedRows[index],
202
- rowIndex: index,
203
- expandId: `${index}`,
204
- onToggle: (_, rowIndex, isOpen) => {
205
- onCollapse(isOpen, rowIndex);
206
- const expand = [...expandedRows];
207
- expand[index] = isOpen;
208
- setExpandedRows(expand);
209
- },
210
- }}
260
+ expand={
261
+ rows[index + 1].cells.length === 0
262
+ ? undefined
263
+ : {
264
+ isExpanded: !!expandedRows[index],
265
+ rowIndex: index,
266
+ expandId: "expandable-row-",
267
+ onToggle: (_, rowIndex, isOpen) => {
268
+ onCollapse(isOpen, rowIndex);
269
+ const expand = [...expandedRows];
270
+ expand[index] = isOpen;
271
+ setExpandedRows(expand);
272
+ },
273
+ }
274
+ }
275
+ />
276
+ <CellRenderer
277
+ row={row}
278
+ index={index}
279
+ actions={actions}
280
+ actionResolver={actionResolver}
211
281
  />
212
- <CellRenderer row={row} />
213
282
  </Tr>
214
283
  ) : (
215
284
  <Tr isExpanded={!!expandedRows[index - 1]}>
216
285
  <Td />
217
286
  <Td colSpan={columns.length}>
218
287
  <ExpandableRowContent>
219
- <CellRenderer row={row} />
288
+ <ExpandableRowRenderer row={row} />
220
289
  </ExpandableRowContent>
221
290
  </Td>
222
291
  </Tr>
@@ -480,38 +549,6 @@ export function KeycloakDataTable<T>({
480
549
  return action;
481
550
  });
482
551
 
483
- const _onSelect = (isSelected: boolean, rowIndex: number) => {
484
- const data = filteredData || rows;
485
- if (rowIndex === -1) {
486
- setRows(
487
- data!.map((row) => {
488
- (row as Row<T>).selected = isSelected;
489
- return row;
490
- }),
491
- );
492
- } else {
493
- (data![rowIndex] as Row<T>).selected = isSelected;
494
-
495
- setRows([...rows!]);
496
- }
497
-
498
- // Keeps selected items when paginating
499
- const difference = differenceBy(
500
- selected,
501
- data!.map((row) => row.data),
502
- "id",
503
- );
504
-
505
- // Selected rows are any rows previously selected from a different page, plus current page selections
506
- const selectedRows = [
507
- ...difference,
508
- ...data!.filter((row) => (row as Row<T>).selected).map((row) => row.data),
509
- ];
510
-
511
- setSelected(selectedRows);
512
- onSelect!(selectedRows);
513
- };
514
-
515
552
  const onCollapse = (isOpen: boolean, rowIndex: number) => {
516
553
  (data![rowIndex] as Row<T>).isOpen = isOpen;
517
554
  setRows([...data!]);
@@ -526,7 +563,7 @@ export function KeycloakDataTable<T>({
526
563
 
527
564
  return (
528
565
  <>
529
- {(loading || !noData || searching) && (
566
+ {(!noData || searching) && (
530
567
  <PaginatingTableToolbar
531
568
  id={id}
532
569
  count={rowLength}
@@ -549,7 +586,7 @@ export function KeycloakDataTable<T>({
549
586
  <>
550
587
  {toolbarItem} <ToolbarItem variant="separator" />{" "}
551
588
  <ToolbarItem>
552
- <Button variant="link" onClick={refresh}>
589
+ <Button variant="link" onClick={refresh} data-testid="refresh">
553
590
  <SyncAltIcon /> {t("refresh")}
554
591
  </Button>
555
592
  </ToolbarItem>
@@ -561,7 +598,12 @@ export function KeycloakDataTable<T>({
561
598
  <DataTable
562
599
  {...props}
563
600
  canSelectAll={canSelectAll}
564
- onSelect={onSelect ? _onSelect : undefined}
601
+ canSelect={!!onSelect}
602
+ selected={selected}
603
+ onSelect={(selected) => {
604
+ setSelected(selected);
605
+ onSelect?.(selected);
606
+ }}
565
607
  onCollapse={detailColumns ? onCollapse : undefined}
566
608
  actions={convertAction()}
567
609
  actionResolver={actionResolver}
@@ -592,9 +634,9 @@ export function KeycloakDataTable<T>({
592
634
  }
593
635
  />
594
636
  )}
595
- {loading && <KeycloakSpinner />}
596
637
  </PaginatingTableToolbar>
597
638
  )}
639
+ {loading && <KeycloakSpinner />}
598
640
  {!loading && noData && !searching && emptyState}
599
641
  </>
600
642
  );
@@ -36,25 +36,20 @@ export const TableToolbar = ({
36
36
  const { t } = useTranslation();
37
37
  const [searchValue, setSearchValue] = useState<string>("");
38
38
 
39
- const onSearch = () => {
40
- if (searchValue !== "") {
41
- setSearchValue(searchValue);
42
- inputGroupOnEnter?.(searchValue);
43
- } else {
44
- setSearchValue("");
45
- inputGroupOnEnter?.("");
46
- }
39
+ const onSearch = (searchValue: string) => {
40
+ setSearchValue(searchValue.trim());
41
+ inputGroupOnEnter?.(searchValue.trim());
47
42
  };
48
43
 
49
44
  const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
50
45
  if (e.key === "Enter") {
51
- onSearch();
46
+ onSearch(searchValue);
52
47
  }
53
48
  };
54
49
 
55
50
  return (
56
51
  <>
57
- <Toolbar>
52
+ <Toolbar data-testid="table-toolbar">
58
53
  <ToolbarContent>
59
54
  {inputGroupName && (
60
55
  <ToolbarItem>
@@ -69,12 +64,9 @@ export const TableToolbar = ({
69
64
  onChange={(_, value) => {
70
65
  setSearchValue(value);
71
66
  }}
72
- onSearch={onSearch}
67
+ onSearch={() => onSearch(searchValue)}
73
68
  onKeyDown={handleKeyDown}
74
- onClear={() => {
75
- setSearchValue("");
76
- inputGroupOnEnter?.("");
77
- }}
69
+ onClear={() => onSearch("")}
78
70
  />
79
71
  )}
80
72
  </InputGroup>
@@ -46,7 +46,6 @@ function getIcon(icon: string) {
46
46
  case "linkedin-openid-connect":
47
47
  return LinkedinIcon;
48
48
 
49
- case "openshift-v3":
50
49
  case "openshift-v4":
51
50
  return OpenshiftIcon;
52
51
  case "stackoverflow":
@@ -97,4 +97,3 @@ export {
97
97
  } from "./utils/ErrorBoundary";
98
98
  export type { FallbackProps } from "./utils/ErrorBoundary";
99
99
  export { OrganizationTable } from "./controls/OrganizationTable";
100
- export { initializeDarkMode } from "./utils/darkMode";
@@ -58,6 +58,7 @@ type KeycloakMastheadProps = MastheadMainProps & {
58
58
  kebabDropdownItems?: ReactNode[];
59
59
  dropdownItems?: ReactNode[];
60
60
  toolbarItems?: ReactNode[];
61
+ toolbar?: ReactNode;
61
62
  };
62
63
 
63
64
  const KeycloakMasthead = ({
@@ -72,6 +73,7 @@ const KeycloakMasthead = ({
72
73
  kebabDropdownItems,
73
74
  dropdownItems = [],
74
75
  toolbarItems,
76
+ toolbar,
75
77
  ...rest
76
78
  }: KeycloakMastheadProps) => {
77
79
  const { t } = useTranslation();
@@ -106,6 +108,7 @@ const KeycloakMasthead = ({
106
108
  <img src={src} alt={alt} className={className} />
107
109
  </MastheadBrand>
108
110
  <MastheadContent>
111
+ {toolbar}
109
112
  <Toolbar>
110
113
  <ToolbarContent>
111
114
  {toolbarItems?.map((item, index) => (
@@ -19,7 +19,7 @@ export const propertyToString = (prop: string | number | undefined) =>
19
19
 
20
20
  export type KeycloakSelectProps = Omit<
21
21
  SelectProps,
22
- "name" | "toggle" | "selected" | "onClick" | "onSelect"
22
+ "name" | "toggle" | "selected" | "onClick" | "onSelect" | "variant"
23
23
  > & {
24
24
  toggleId?: string;
25
25
  onFilter?: (value: string) => JSX.Element[];
@@ -119,7 +119,11 @@ export const TypeaheadSelect = ({
119
119
  {...rest}
120
120
  onClick={toggle}
121
121
  onOpenChange={(isOpen) => onToggle?.(isOpen)}
122
- onSelect={(_, value) => onSelect?.(value || "")}
122
+ onSelect={(_, value) => {
123
+ onSelect?.(value || "");
124
+ onFilter?.("");
125
+ setFilterValue("");
126
+ }}
123
127
  maxMenuHeight={propertyToString(maxHeight)}
124
128
  popperProps={{ direction, width: propertyToString(width) }}
125
129
  toggle={(ref) => (
@@ -47,7 +47,7 @@ export const LocaleSelector = ({
47
47
  name="attributes.locale"
48
48
  label={t("selectALocale")}
49
49
  controller={{ defaultValue: "" }}
50
- options={locales}
50
+ options={[{ key: "", value: t("defaultLocale") }, ...locales]}
51
51
  variant={locales.length >= 10 ? "typeahead" : "single"}
52
52
  />
53
53
  </FormProvider>
@@ -86,7 +86,7 @@ export const SelectComponent = (props: UserProfileFieldProps) => {
86
86
  }}
87
87
  selections={
88
88
  isMultiValue && Array.isArray(field.value)
89
- ? field.value
89
+ ? field.value.map((option) => fetchLabel(option))
90
90
  : fetchLabel(field.value)
91
91
  }
92
92
  variant={
@@ -21,12 +21,18 @@ export const TextComponent = (props: UserProfileFieldProps) => {
21
21
  id={attribute.name}
22
22
  data-testid={attribute.name}
23
23
  type={type}
24
- placeholder={label(
25
- props.t,
26
- attribute.annotations?.["inputTypePlaceholder"] as string,
27
- attribute.name,
28
- attribute.annotations?.["inputOptionLabelsI18nPrefix"] as string,
29
- )}
24
+ placeholder={
25
+ attribute.readOnly
26
+ ? ""
27
+ : label(
28
+ props.t,
29
+ attribute.annotations?.["inputTypePlaceholder"] as string,
30
+ "",
31
+ attribute.annotations?.[
32
+ "inputOptionLabelsI18nPrefix"
33
+ ] as string,
34
+ )
35
+ }
30
36
  readOnly={attribute.readOnly}
31
37
  isRequired={isRequired}
32
38
  {...form.register(fieldName(attribute.name))}
@@ -77,12 +77,13 @@ export function setUserProfileServerError<T>(
77
77
  ).forEach((e) => {
78
78
  const params = Object.assign(
79
79
  {},
80
- e.params?.map((p) => (isBundleKey(p.toString()) ? t(unWrap(p)) : p)),
80
+ e.params?.map((p) => (isBundleKey(p?.toString()) ? t(unWrap(p)) : p)),
81
81
  );
82
82
  setError(fieldName(e.field) as keyof T, {
83
83
  message: t(
84
84
  isBundleKey(e.errorMessage) ? unWrap(e.errorMessage) : e.errorMessage,
85
85
  {
86
+ /* eslint-disable @typescript-eslint/no-misused-spread */
86
87
  ...params,
87
88
  defaultValue: e.errorMessage || e.field,
88
89
  },
@@ -94,34 +95,9 @@ export function setUserProfileServerError<T>(
94
95
 
95
96
  export function isRequiredAttribute({
96
97
  required,
97
- validators,
98
98
  }: UserProfileAttributeMetadata): boolean {
99
- // Check if required is true or if the validators include a validation that would make the attribute implicitly required.
100
- return required || hasRequiredValidators(validators);
101
- }
102
-
103
- /**
104
- * Checks whether the given validators include a validation that would make the attribute implicitly required.
105
- */
106
- function hasRequiredValidators(
107
- validators?: UserProfileAttributeMetadata["validators"],
108
- ): boolean {
109
- // If we don't have any validators, the attribute is not required.
110
- if (!validators) {
111
- return false;
112
- }
113
-
114
- // If the 'length' validator is defined and has a minimal length greater than zero the attribute is implicitly required.
115
- // We have to do a lot of defensive coding here, because we don't have type information for the validators.
116
- if (
117
- "length" in validators &&
118
- "min" in validators.length &&
119
- typeof validators.length.min === "number"
120
- ) {
121
- return validators.length.min > 0;
122
- }
123
-
124
- return false;
99
+ // Check if required is true
100
+ return required as boolean;
125
101
  }
126
102
 
127
103
  export function isUserProfileError(error: unknown): error is UserProfileError {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@keycloakify/keycloak-ui-shared",
3
- "version": "260007.0.5",
3
+ "version": "260200.0.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git://github.com/keycloakify/keycloak-ui-shared.git"
@@ -9,19 +9,19 @@
9
9
  "author": "The Keycloak Team, re-packaged by u/garronej",
10
10
  "homepage": "https://github.com/keycloakify/keycloak-ui-shared",
11
11
  "peerDependencies": {
12
- "@patternfly/react-core": "^5.4.1",
13
- "@patternfly/react-icons": "^5.4.0",
14
- "@patternfly/react-styles": "^5.4.0",
15
- "@patternfly/react-table": "^5.4.1",
16
- "i18next": "^23.15.1",
12
+ "@patternfly/react-core": "^5.4.14",
13
+ "@patternfly/react-icons": "^5.4.2",
14
+ "@patternfly/react-styles": "^5.4.1",
15
+ "@patternfly/react-table": "^5.4.16",
16
+ "i18next": "^24.2.3",
17
+ "keycloak-js": "^26.2.0",
17
18
  "lodash-es": "^4.17.21",
18
19
  "react": "^18.3.1",
19
- "react-hook-form": "7.53.0",
20
- "react-i18next": "^15.0.2",
21
- "@keycloak/keycloak-admin-client": "26.0.7",
22
- "keycloak-js": "26.0.7",
20
+ "react-hook-form": "7.54.2",
21
+ "react-i18next": "^15.4.1",
22
+ "@keycloak/keycloak-admin-client": "26.2.0",
23
23
  "@types/lodash-es": "^4.17.12",
24
- "@types/react": "^18.3.11"
24
+ "@types/react": "^18.3.18"
25
25
  },
26
26
  "publishConfig": {
27
27
  "access": "public"