added default values for user profile annotations (#20949)
* initial version of the "known" annotation picker * fixed empty value * add input type select when validator is options * fixed test * Update js/apps/admin-ui/public/locales/en/common.json Co-authored-by: Jon Koops <jonkoops@gmail.com> --------- Co-authored-by: Jon Koops <jonkoops@gmail.com>
This commit is contained in:
parent
39e3820e9e
commit
0ca9b21765
8 changed files with 258 additions and 31 deletions
|
@ -18,8 +18,8 @@ export default class UserProfile {
|
||||||
private newAttributeRequiredWhen = 'input[name="requiredWhen"]';
|
private newAttributeRequiredWhen = 'input[name="requiredWhen"]';
|
||||||
private newAttributeEmptyValidators = ".kc-emptyValidators";
|
private newAttributeEmptyValidators = ".kc-emptyValidators";
|
||||||
private newAttributeAnnotationBtn = "annotations-add-row";
|
private newAttributeAnnotationBtn = "annotations-add-row";
|
||||||
private newAttributeAnnotationKey = "annotations-key";
|
private newAttributeAnnotationKey = "annotations.0.key";
|
||||||
private newAttributeAnnotationValue = "annotations-value";
|
private newAttributeAnnotationValue = "annotations.0.value";
|
||||||
private validatorRolesList = "#validator";
|
private validatorRolesList = "#validator";
|
||||||
private validatorsList = 'tbody [data-label="name"]';
|
private validatorsList = 'tbody [data-label="name"]';
|
||||||
private saveNewAttributeBtn = "attribute-create";
|
private saveNewAttributeBtn = "attribute-create";
|
||||||
|
|
|
@ -214,5 +214,6 @@
|
||||||
"forbidden_one": "Forbidden, permission needed:",
|
"forbidden_one": "Forbidden, permission needed:",
|
||||||
"forbidden_other": "Forbidden, permissions needed:",
|
"forbidden_other": "Forbidden, permissions needed:",
|
||||||
"noRealmRolesToAssign": "There are no realm roles to assign",
|
"noRealmRolesToAssign": "There are no realm roles to assign",
|
||||||
"loadingRealms": "Loading realms…"
|
"loadingRealms": "Loading realms…",
|
||||||
|
"customAttribute": "Custom Attribute…"
|
||||||
}
|
}
|
||||||
|
|
|
@ -18,6 +18,15 @@
|
||||||
"displayDescriptionField": "Display description",
|
"displayDescriptionField": "Display description",
|
||||||
"displayDescriptionHint": "A text that should be used as a tooltip when rendering user-facing forms.",
|
"displayDescriptionHint": "A text that should be used as a tooltip when rendering user-facing forms.",
|
||||||
"annotationsText": "Annotations",
|
"annotationsText": "Annotations",
|
||||||
|
"inputType": "Input type",
|
||||||
|
"inputHelperTextBefore": "Helper text (above) the input field",
|
||||||
|
"inputHelperTextAfter": "Helper text (under) the input field",
|
||||||
|
"inputOptionLabelsI18nPrefix": "Internationalization key prefix",
|
||||||
|
"inputTypePlaceholder": "Input placeholder",
|
||||||
|
"inputTypeSize": "Input size",
|
||||||
|
"inputTypeCols": "Input cols",
|
||||||
|
"inputTypeRows": "Input rows",
|
||||||
|
"inputTypeStep": "Input step size",
|
||||||
"removeAnnotationText": "Remove annotation",
|
"removeAnnotationText": "Remove annotation",
|
||||||
"keyPlaceholder": "Type a key",
|
"keyPlaceholder": "Type a key",
|
||||||
"keyLabel": "Key",
|
"keyLabel": "Key",
|
||||||
|
|
62
js/apps/admin-ui/src/components/key-value-form/KeySelect.tsx
Normal file
62
js/apps/admin-ui/src/components/key-value-form/KeySelect.tsx
Normal file
|
@ -0,0 +1,62 @@
|
||||||
|
import { Grid, GridItem, Select, SelectOption } from "@patternfly/react-core";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { UseControllerProps, useController } from "react-hook-form";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { KeycloakTextInput } from "ui-shared";
|
||||||
|
import useToggle from "../../utils/useToggle";
|
||||||
|
import { DefaultValue } from "./KeyValueInput";
|
||||||
|
|
||||||
|
type KeySelectProp = UseControllerProps & {
|
||||||
|
selectItems: DefaultValue[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const KeySelect = ({ selectItems, ...rest }: KeySelectProp) => {
|
||||||
|
const { t } = useTranslation("common");
|
||||||
|
const [open, toggle] = useToggle();
|
||||||
|
const { field } = useController(rest);
|
||||||
|
const [custom, setCustom] = useState(
|
||||||
|
!selectItems.map(({ key }) => key).includes(field.value)
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Grid>
|
||||||
|
<GridItem lg={custom ? 2 : 12}>
|
||||||
|
<Select
|
||||||
|
onToggle={() => toggle()}
|
||||||
|
isOpen={open}
|
||||||
|
onSelect={(_, value) => {
|
||||||
|
if (value) {
|
||||||
|
setCustom(false);
|
||||||
|
}
|
||||||
|
field.onChange(value);
|
||||||
|
toggle();
|
||||||
|
}}
|
||||||
|
selections={!custom ? [field.value] : ""}
|
||||||
|
>
|
||||||
|
{[
|
||||||
|
<SelectOption key="custom" onClick={() => setCustom(true)}>
|
||||||
|
{t("customAttribute")}
|
||||||
|
</SelectOption>,
|
||||||
|
...selectItems.map((item) => (
|
||||||
|
<SelectOption key={item.key} value={item.key}>
|
||||||
|
{item.label}
|
||||||
|
</SelectOption>
|
||||||
|
)),
|
||||||
|
]}
|
||||||
|
</Select>
|
||||||
|
</GridItem>
|
||||||
|
{custom && (
|
||||||
|
<GridItem lg={10}>
|
||||||
|
<KeycloakTextInput
|
||||||
|
id="customValue"
|
||||||
|
data-testid={rest.name}
|
||||||
|
placeholder={t("keyPlaceholder")}
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</GridItem>
|
||||||
|
)}
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
};
|
|
@ -8,20 +8,36 @@ import {
|
||||||
GridItem,
|
GridItem,
|
||||||
HelperText,
|
HelperText,
|
||||||
HelperTextItem,
|
HelperTextItem,
|
||||||
InputGroup,
|
|
||||||
} from "@patternfly/react-core";
|
} from "@patternfly/react-core";
|
||||||
import { MinusCircleIcon, PlusCircleIcon } from "@patternfly/react-icons";
|
import { MinusCircleIcon, PlusCircleIcon } from "@patternfly/react-icons";
|
||||||
import { Fragment } from "react";
|
import { Fragment } from "react";
|
||||||
import { useFieldArray, useFormContext } from "react-hook-form";
|
import {
|
||||||
|
FieldValues,
|
||||||
|
useFieldArray,
|
||||||
|
useFormContext,
|
||||||
|
useWatch,
|
||||||
|
} from "react-hook-form";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { KeycloakTextInput } from "../keycloak-text-input/KeycloakTextInput";
|
import { KeycloakTextInput } from "../keycloak-text-input/KeycloakTextInput";
|
||||||
|
import { KeySelect } from "./KeySelect";
|
||||||
|
import { ValueSelect } from "./ValueSelect";
|
||||||
|
|
||||||
|
export type DefaultValue = {
|
||||||
|
key: string;
|
||||||
|
values?: string[];
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
type KeyValueInputProps = {
|
type KeyValueInputProps = {
|
||||||
name: string;
|
name: string;
|
||||||
|
defaultKeyValue?: DefaultValue[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const KeyValueInput = ({ name }: KeyValueInputProps) => {
|
export const KeyValueInput = ({
|
||||||
|
name,
|
||||||
|
defaultKeyValue,
|
||||||
|
}: KeyValueInputProps) => {
|
||||||
const { t } = useTranslation("common");
|
const { t } = useTranslation("common");
|
||||||
const {
|
const {
|
||||||
control,
|
control,
|
||||||
|
@ -30,19 +46,26 @@ export const KeyValueInput = ({ name }: KeyValueInputProps) => {
|
||||||
} = useFormContext();
|
} = useFormContext();
|
||||||
|
|
||||||
const { fields, append, remove } = useFieldArray({
|
const { fields, append, remove } = useFieldArray({
|
||||||
|
shouldUnregister: true,
|
||||||
control,
|
control,
|
||||||
name,
|
name,
|
||||||
});
|
});
|
||||||
|
|
||||||
const appendNew = () => append({ key: "", value: "" });
|
const appendNew = () => append({ key: "", value: "" });
|
||||||
|
|
||||||
|
const values = useWatch<FieldValues>({
|
||||||
|
name,
|
||||||
|
control,
|
||||||
|
defaultValue: [],
|
||||||
|
});
|
||||||
|
|
||||||
return fields.length > 0 ? (
|
return fields.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
<Grid hasGutter>
|
<Grid hasGutter>
|
||||||
<GridItem className="pf-c-form__label" span={6}>
|
<GridItem className="pf-c-form__label" span={5}>
|
||||||
<span className="pf-c-form__label-text">{t("key")}</span>
|
<span className="pf-c-form__label-text">{t("key")}</span>
|
||||||
</GridItem>
|
</GridItem>
|
||||||
<GridItem className="pf-c-form__label" span={6}>
|
<GridItem className="pf-c-form__label" span={7}>
|
||||||
<span className="pf-c-form__label-text">{t("value")}</span>
|
<span className="pf-c-form__label-text">{t("value")}</span>
|
||||||
</GridItem>
|
</GridItem>
|
||||||
{fields.map((attribute, index) => {
|
{fields.map((attribute, index) => {
|
||||||
|
@ -51,15 +74,23 @@ export const KeyValueInput = ({ name }: KeyValueInputProps) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fragment key={attribute.id}>
|
<Fragment key={attribute.id}>
|
||||||
<GridItem span={6}>
|
<GridItem span={5}>
|
||||||
<KeycloakTextInput
|
{defaultKeyValue ? (
|
||||||
placeholder={t("keyPlaceholder")}
|
<KeySelect
|
||||||
aria-label={t("key")}
|
name={`${name}.${index}.key`}
|
||||||
data-testid={`${name}-key`}
|
selectItems={defaultKeyValue}
|
||||||
{...register(`${name}.${index}.key`, { required: true })}
|
rules={{ required: true }}
|
||||||
validated={keyError ? "error" : "default"}
|
/>
|
||||||
isRequired
|
) : (
|
||||||
/>
|
<KeycloakTextInput
|
||||||
|
placeholder={t("keyPlaceholder")}
|
||||||
|
aria-label={t("key")}
|
||||||
|
data-testid={`${name}-key`}
|
||||||
|
{...register(`${name}.${index}.key`, { required: true })}
|
||||||
|
validated={keyError ? "error" : "default"}
|
||||||
|
isRequired
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{keyError && (
|
{keyError && (
|
||||||
<HelperText>
|
<HelperText>
|
||||||
<HelperTextItem variant="error">
|
<HelperTextItem variant="error">
|
||||||
|
@ -68,8 +99,15 @@ export const KeyValueInput = ({ name }: KeyValueInputProps) => {
|
||||||
</HelperText>
|
</HelperText>
|
||||||
)}
|
)}
|
||||||
</GridItem>
|
</GridItem>
|
||||||
<GridItem span={6}>
|
<GridItem span={5}>
|
||||||
<InputGroup>
|
{defaultKeyValue ? (
|
||||||
|
<ValueSelect
|
||||||
|
name={`${name}.${index}.value`}
|
||||||
|
keyValue={values[index]?.key}
|
||||||
|
selectItems={defaultKeyValue}
|
||||||
|
rules={{ required: true }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<KeycloakTextInput
|
<KeycloakTextInput
|
||||||
placeholder={t("valuePlaceholder")}
|
placeholder={t("valuePlaceholder")}
|
||||||
aria-label={t("value")}
|
aria-label={t("value")}
|
||||||
|
@ -78,15 +116,7 @@ export const KeyValueInput = ({ name }: KeyValueInputProps) => {
|
||||||
validated={valueError ? "error" : "default"}
|
validated={valueError ? "error" : "default"}
|
||||||
isRequired
|
isRequired
|
||||||
/>
|
/>
|
||||||
<Button
|
)}
|
||||||
variant="link"
|
|
||||||
title={t("removeAttribute")}
|
|
||||||
onClick={() => remove(index)}
|
|
||||||
data-testid={`${name}-remove`}
|
|
||||||
>
|
|
||||||
<MinusCircleIcon />
|
|
||||||
</Button>
|
|
||||||
</InputGroup>
|
|
||||||
{valueError && (
|
{valueError && (
|
||||||
<HelperText>
|
<HelperText>
|
||||||
<HelperTextItem variant="error">
|
<HelperTextItem variant="error">
|
||||||
|
@ -95,6 +125,16 @@ export const KeyValueInput = ({ name }: KeyValueInputProps) => {
|
||||||
</HelperText>
|
</HelperText>
|
||||||
)}
|
)}
|
||||||
</GridItem>
|
</GridItem>
|
||||||
|
<GridItem span={2}>
|
||||||
|
<Button
|
||||||
|
variant="link"
|
||||||
|
title={t("removeAttribute")}
|
||||||
|
onClick={() => remove(index)}
|
||||||
|
data-testid={`${name}-remove`}
|
||||||
|
>
|
||||||
|
<MinusCircleIcon />
|
||||||
|
</Button>
|
||||||
|
</GridItem>
|
||||||
</Fragment>
|
</Fragment>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
|
@ -0,0 +1,46 @@
|
||||||
|
import { Select, SelectOption } from "@patternfly/react-core";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { UseControllerProps, useController } from "react-hook-form";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { KeycloakTextInput } from "ui-shared";
|
||||||
|
|
||||||
|
import { DefaultValue } from "./KeyValueInput";
|
||||||
|
|
||||||
|
type ValueSelectProps = UseControllerProps & {
|
||||||
|
selectItems: DefaultValue[];
|
||||||
|
keyValue: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ValueSelect = ({
|
||||||
|
selectItems,
|
||||||
|
keyValue,
|
||||||
|
...rest
|
||||||
|
}: ValueSelectProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { field } = useController(rest);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const defaultItem = useMemo(
|
||||||
|
() => selectItems.find((v) => v.key === keyValue),
|
||||||
|
[selectItems, keyValue]
|
||||||
|
);
|
||||||
|
|
||||||
|
return defaultItem?.values ? (
|
||||||
|
<Select
|
||||||
|
onToggle={(isOpen) => setOpen(isOpen)}
|
||||||
|
isOpen={open}
|
||||||
|
onSelect={(_, value) => {
|
||||||
|
field.onChange(value);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
selections={field.value ? [field.value] : t("choose")}
|
||||||
|
placeholder={t("valuePlaceholder")}
|
||||||
|
>
|
||||||
|
{defaultItem.values.map((item) => (
|
||||||
|
<SelectOption key={item} value={item} />
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
<KeycloakTextInput id="customValue" data-testid={rest.name} {...field} />
|
||||||
|
);
|
||||||
|
};
|
|
@ -19,7 +19,65 @@ export const AttributeAnnotations = () => {
|
||||||
>
|
>
|
||||||
<Grid className="kc-annotations">
|
<Grid className="kc-annotations">
|
||||||
<GridItem>
|
<GridItem>
|
||||||
<KeyValueInput name="annotations" />
|
<KeyValueInput
|
||||||
|
name="annotations"
|
||||||
|
defaultKeyValue={[
|
||||||
|
{
|
||||||
|
key: "inputType",
|
||||||
|
label: t("inputType"),
|
||||||
|
values: [
|
||||||
|
"text",
|
||||||
|
"textarea",
|
||||||
|
"select",
|
||||||
|
"select-radiobuttons",
|
||||||
|
"multiselect",
|
||||||
|
"multiselect-checkboxes",
|
||||||
|
"html5-email",
|
||||||
|
"html5-tel",
|
||||||
|
"html5-url",
|
||||||
|
"html5-number",
|
||||||
|
"html5-range",
|
||||||
|
"html5-datetime-local",
|
||||||
|
"html5-date",
|
||||||
|
"html5-month",
|
||||||
|
"html5-week",
|
||||||
|
"html5-time",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "inputHelperTextBefore",
|
||||||
|
label: t("inputHelperTextBefore"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "inputHelperTextAfter",
|
||||||
|
label: t("inputHelperTextAfter"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "inputOptionLabelsI18nPrefix",
|
||||||
|
label: t("inputOptionLabelsI18nPrefix"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "inputTypePlaceholder",
|
||||||
|
label: t("inputTypePlaceholder"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "inputTypeSize",
|
||||||
|
label: t("inputTypeSize"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "inputTypeCols",
|
||||||
|
label: t("inputTypeCols"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "inputTypeRows",
|
||||||
|
label: t("inputTypeRows"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "inputTypeStep",
|
||||||
|
label: t("inputTypeStep"),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
</GridItem>
|
</GridItem>
|
||||||
</Grid>
|
</Grid>
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
|
|
|
@ -19,6 +19,7 @@ import { useFormContext, useWatch } from "react-hook-form";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { useConfirmDialog } from "../../../components/confirm-dialog/ConfirmDialog";
|
import { useConfirmDialog } from "../../../components/confirm-dialog/ConfirmDialog";
|
||||||
|
import { DefaultValue } from "../../../components/key-value-form/KeyValueInput";
|
||||||
import useToggle from "../../../utils/useToggle";
|
import useToggle from "../../../utils/useToggle";
|
||||||
import type { IndexedValidations } from "../../NewAttributeSettings";
|
import type { IndexedValidations } from "../../NewAttributeSettings";
|
||||||
import { AddValidatorDialog } from "../attribute/AddValidatorDialog";
|
import { AddValidatorDialog } from "../attribute/AddValidatorDialog";
|
||||||
|
@ -29,7 +30,7 @@ export const AttributeValidations = () => {
|
||||||
const { t } = useTranslation("realm-settings");
|
const { t } = useTranslation("realm-settings");
|
||||||
const [addValidatorModalOpen, toggleModal] = useToggle();
|
const [addValidatorModalOpen, toggleModal] = useToggle();
|
||||||
const [validatorToDelete, setValidatorToDelete] = useState<string>();
|
const [validatorToDelete, setValidatorToDelete] = useState<string>();
|
||||||
const { setValue, control, register } = useFormContext();
|
const { setValue, control, register, getValues } = useFormContext();
|
||||||
|
|
||||||
const validators: IndexedValidations[] = useWatch({
|
const validators: IndexedValidations[] = useWatch({
|
||||||
name: "validations",
|
name: "validations",
|
||||||
|
@ -39,7 +40,7 @@ export const AttributeValidations = () => {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
register("validations");
|
register("validations");
|
||||||
}, []);
|
}, [register]);
|
||||||
|
|
||||||
const [toggleDeleteDialog, DeleteConfirm] = useConfirmDialog({
|
const [toggleDeleteDialog, DeleteConfirm] = useConfirmDialog({
|
||||||
titleKey: t("deleteValidatorConfirmTitle"),
|
titleKey: t("deleteValidatorConfirmTitle"),
|
||||||
|
@ -63,6 +64,16 @@ export const AttributeValidations = () => {
|
||||||
<AddValidatorDialog
|
<AddValidatorDialog
|
||||||
selectedValidators={validators}
|
selectedValidators={validators}
|
||||||
onConfirm={(newValidator) => {
|
onConfirm={(newValidator) => {
|
||||||
|
const annotations: DefaultValue[] = getValues("annotations");
|
||||||
|
if (
|
||||||
|
newValidator.id === "options" &&
|
||||||
|
!annotations.find((a) => a.key === "inputType")
|
||||||
|
) {
|
||||||
|
setValue("annotations", [
|
||||||
|
...annotations,
|
||||||
|
{ key: "inputType", value: "select" },
|
||||||
|
]);
|
||||||
|
}
|
||||||
setValue("validations", [
|
setValue("validations", [
|
||||||
...validators,
|
...validators,
|
||||||
{ key: newValidator.id, value: newValidator.config },
|
{ key: newValidator.id, value: newValidator.config },
|
||||||
|
|
Loading…
Reference in a new issue