Add form for User Profile attribute groups (#2021)
* Add form for User Profile attribute groups * Add breadcrumbs to create and edit pages * Add hints to form fields * Link directly to details from overview * Disable name field when editing * Disable adding annotations when not valid
This commit is contained in:
parent
baf2380d1e
commit
0152e5868d
9 changed files with 425 additions and 16 deletions
|
@ -820,7 +820,8 @@ export default {
|
|||
exportFail: "Could not export realm: '{{error}}'",
|
||||
},
|
||||
"attributes-group": {
|
||||
createButtonText: "Create attributes group",
|
||||
createGroupText: "Create attributes group",
|
||||
editGroupText: "Edit attributes group",
|
||||
tableTitle: "Attributes groups",
|
||||
columnName: "Name",
|
||||
columnDisplayName: "Display name",
|
||||
|
@ -833,5 +834,21 @@ export default {
|
|||
"Are you sure you want to permanently delete the attributes group <1>{{group}}</1>?",
|
||||
deleteSuccess: "Attributes group deleted.",
|
||||
deleteError: "Could not delete user attributes group: {{error}}",
|
||||
nameField: "Name",
|
||||
nameHint:
|
||||
"A unique name for the group. This name will be used to reference the group when binding an attribute to a group.",
|
||||
displayHeaderField: "Display name",
|
||||
displayHeaderHint:
|
||||
"A user-friendly name for the group that should be used when rendering a group of attributes in user-facing forms. Supports keys for localized values as well. For example: ${profile.attribute.group.address}.",
|
||||
displayDescriptionField: "Display description",
|
||||
displayDescriptionHint:
|
||||
"A text that should be used as a tooltip when rendering user-facing forms.",
|
||||
annotationsText: "Annotations",
|
||||
addAnnotationText: "Add annotation",
|
||||
removeAnnotationText: "Remove annotation",
|
||||
keyPlaceholder: "Type a key",
|
||||
keyLabel: "Key",
|
||||
valuePlaceholder: "Type a value",
|
||||
valueLabel: "Value",
|
||||
},
|
||||
};
|
||||
|
|
|
@ -13,6 +13,8 @@ import { EditClientPolicyConditionRoute } from "./routes/EditCondition";
|
|||
import { UserProfileRoute } from "./routes/UserProfile";
|
||||
import { AddAttributeRoute } from "./routes/AddAttribute";
|
||||
import { KeysRoute } from "./routes/KeysTab";
|
||||
import { NewAttributesGroupRoute } from "./routes/NewAttributesGroup";
|
||||
import { EditAttributesGroupRoute } from "./routes/EditAttributesGroup";
|
||||
|
||||
const routes: RouteDef[] = [
|
||||
RealmSettingsRoute,
|
||||
|
@ -29,6 +31,8 @@ const routes: RouteDef[] = [
|
|||
EditClientPolicyConditionRoute,
|
||||
UserProfileRoute,
|
||||
AddAttributeRoute,
|
||||
NewAttributesGroupRoute,
|
||||
EditAttributesGroupRoute,
|
||||
];
|
||||
|
||||
export default routes;
|
||||
|
|
22
src/realm-settings/routes/EditAttributesGroup.ts
Normal file
22
src/realm-settings/routes/EditAttributesGroup.ts
Normal file
|
@ -0,0 +1,22 @@
|
|||
import type { LocationDescriptorObject } from "history";
|
||||
import { lazy } from "react";
|
||||
import { generatePath } from "react-router-dom";
|
||||
import type { RouteDef } from "../../route-config";
|
||||
|
||||
export type EditAttributesGroupParams = {
|
||||
realm: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export const EditAttributesGroupRoute: RouteDef = {
|
||||
path: "/:realm/realm-settings/userProfile/attributesGroup/edit/:name",
|
||||
component: lazy(() => import("../user-profile/AttributesGroupDetails")),
|
||||
breadcrumb: (t) => t("attributes-group:editGroupText"),
|
||||
access: "view-realm",
|
||||
};
|
||||
|
||||
export const toEditAttributesGroup = (
|
||||
params: EditAttributesGroupParams
|
||||
): LocationDescriptorObject => ({
|
||||
pathname: generatePath(EditAttributesGroupRoute.path, params),
|
||||
});
|
21
src/realm-settings/routes/NewAttributesGroup.ts
Normal file
21
src/realm-settings/routes/NewAttributesGroup.ts
Normal file
|
@ -0,0 +1,21 @@
|
|||
import type { LocationDescriptorObject } from "history";
|
||||
import { lazy } from "react";
|
||||
import { generatePath } from "react-router-dom";
|
||||
import type { RouteDef } from "../../route-config";
|
||||
|
||||
export type NewAttributesGroupParams = {
|
||||
realm: string;
|
||||
};
|
||||
|
||||
export const NewAttributesGroupRoute: RouteDef = {
|
||||
path: "/:realm/realm-settings/userProfile/attributesGroup/new",
|
||||
component: lazy(() => import("../user-profile/AttributesGroupDetails")),
|
||||
breadcrumb: (t) => t("attributes-group:createGroupText"),
|
||||
access: "view-realm",
|
||||
};
|
||||
|
||||
export const toNewAttributesGroup = (
|
||||
params: NewAttributesGroupParams
|
||||
): LocationDescriptorObject => ({
|
||||
pathname: generatePath(NewAttributesGroupRoute.path, params),
|
||||
});
|
|
@ -13,7 +13,7 @@ export type UserProfileParams = {
|
|||
export const UserProfileRoute: RouteDef = {
|
||||
path: "/:realm/realm-settings/userProfile/:tab",
|
||||
component: lazy(() => import("../RealmSettingsSection")),
|
||||
breadcrumb: (t) => t("realmSettings"),
|
||||
breadcrumb: (t) => t("realm-settings:userProfile"),
|
||||
access: "view-realm",
|
||||
};
|
||||
|
||||
|
|
11
src/realm-settings/user-profile/AttributesGroupDetails.tsx
Normal file
11
src/realm-settings/user-profile/AttributesGroupDetails.tsx
Normal file
|
@ -0,0 +1,11 @@
|
|||
import React from "react";
|
||||
import AttributesGroupForm from "./AttributesGroupForm";
|
||||
import { UserProfileProvider } from "./UserProfileContext";
|
||||
|
||||
const AttributesGroupDetails = () => (
|
||||
<UserProfileProvider>
|
||||
<AttributesGroupForm />
|
||||
</UserProfileProvider>
|
||||
);
|
||||
|
||||
export default AttributesGroupDetails;
|
322
src/realm-settings/user-profile/AttributesGroupForm.tsx
Normal file
322
src/realm-settings/user-profile/AttributesGroupForm.tsx
Normal file
|
@ -0,0 +1,322 @@
|
|||
import type { UserProfileGroup } from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig";
|
||||
import {
|
||||
ActionGroup,
|
||||
ActionList,
|
||||
ActionListItem,
|
||||
Button,
|
||||
Flex,
|
||||
FlexItem,
|
||||
FormGroup,
|
||||
PageSection,
|
||||
Text,
|
||||
TextContent,
|
||||
TextInput,
|
||||
} from "@patternfly/react-core";
|
||||
import { MinusCircleIcon, PlusCircleIcon } from "@patternfly/react-icons";
|
||||
import React, { useEffect, useMemo } from "react";
|
||||
import {
|
||||
ArrayField,
|
||||
SubmitHandler,
|
||||
useFieldArray,
|
||||
useForm,
|
||||
useWatch,
|
||||
} from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link, useHistory, useParams } from "react-router-dom";
|
||||
import { FormAccess } from "../../components/form-access/FormAccess";
|
||||
import { HelpItem } from "../../components/help-enabler/HelpItem";
|
||||
import { ViewHeader } from "../../components/view-header/ViewHeader";
|
||||
import { useRealm } from "../../context/realm-context/RealmContext";
|
||||
import "../realm-settings-section.css";
|
||||
import type { EditAttributesGroupParams } from "../routes/EditAttributesGroup";
|
||||
import { toUserProfile } from "../routes/UserProfile";
|
||||
import { useUserProfile } from "./UserProfileContext";
|
||||
|
||||
enum AnnotationType {
|
||||
String = "string",
|
||||
Unknown = "unknown",
|
||||
}
|
||||
|
||||
type StringAnnotation = {
|
||||
type: AnnotationType.String;
|
||||
key: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
type UnknownAnnotation = {
|
||||
type: AnnotationType.Unknown;
|
||||
key: string;
|
||||
value: unknown;
|
||||
};
|
||||
|
||||
type Annotation = StringAnnotation | UnknownAnnotation;
|
||||
|
||||
function parseAnnotations(input: Record<string, unknown>) {
|
||||
return Object.entries(input).map<Annotation>(([key, value]) => {
|
||||
if (typeof value === "string") {
|
||||
return { type: AnnotationType.String, key, value };
|
||||
}
|
||||
|
||||
return { type: AnnotationType.Unknown, key, value };
|
||||
});
|
||||
}
|
||||
|
||||
function transformAnnotations(input: Annotation[]): Record<string, unknown> {
|
||||
return Object.fromEntries(
|
||||
input
|
||||
.filter((annotation) => annotation.key.length > 0)
|
||||
.map((annotation) => [annotation.key, annotation.value] as const)
|
||||
);
|
||||
}
|
||||
|
||||
type FormFields = Required<Omit<UserProfileGroup, "annotations">> & {
|
||||
annotations: Annotation[];
|
||||
};
|
||||
|
||||
const defaultValues: FormFields = {
|
||||
annotations: [{ type: AnnotationType.String, key: "", value: "" }],
|
||||
displayDescription: "",
|
||||
displayHeader: "",
|
||||
name: "",
|
||||
};
|
||||
|
||||
export default function AttributesGroupForm() {
|
||||
const { t } = useTranslation();
|
||||
const { realm } = useRealm();
|
||||
const { config, save } = useUserProfile();
|
||||
const history = useHistory();
|
||||
const params = useParams<Partial<EditAttributesGroupParams>>();
|
||||
const form = useForm<FormFields>({ defaultValues, shouldUnregister: false });
|
||||
const annotationsField = useFieldArray<Annotation>({
|
||||
control: form.control,
|
||||
name: "annotations",
|
||||
});
|
||||
|
||||
const annotations = useWatch({
|
||||
control: form.control,
|
||||
name: "annotations",
|
||||
defaultValue: defaultValues.annotations,
|
||||
});
|
||||
|
||||
const annotationsValid = annotations
|
||||
.filter(
|
||||
(annotation): annotation is StringAnnotation =>
|
||||
annotation.type === AnnotationType.String
|
||||
)
|
||||
.every(
|
||||
({ key, value }) => key.trim().length !== 0 && value.trim().length !== 0
|
||||
);
|
||||
|
||||
const matchingGroup = useMemo(
|
||||
() => config?.groups?.find(({ name }) => name === params.name),
|
||||
[config?.groups]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!matchingGroup) {
|
||||
return;
|
||||
}
|
||||
|
||||
const annotations = matchingGroup.annotations
|
||||
? parseAnnotations(matchingGroup.annotations)
|
||||
: [];
|
||||
|
||||
if (annotations.length === 0) {
|
||||
annotations.push({ type: AnnotationType.String, key: "", value: "" });
|
||||
}
|
||||
|
||||
form.reset({ ...defaultValues, ...matchingGroup, annotations });
|
||||
}, [matchingGroup]);
|
||||
|
||||
const onSubmit: SubmitHandler<FormFields> = async (values) => {
|
||||
if (!config) {
|
||||
return;
|
||||
}
|
||||
|
||||
const groups = [...(config.groups ?? [])];
|
||||
const updateAt = matchingGroup ? groups.indexOf(matchingGroup) : -1;
|
||||
const updatedGroup: UserProfileGroup = {
|
||||
...values,
|
||||
annotations: transformAnnotations(values.annotations),
|
||||
};
|
||||
|
||||
if (updateAt === -1) {
|
||||
groups.push(updatedGroup);
|
||||
} else {
|
||||
groups[updateAt] = updatedGroup;
|
||||
}
|
||||
|
||||
const success = await save({ ...config, groups });
|
||||
|
||||
if (success) {
|
||||
history.push(toUserProfile({ realm, tab: "attributesGroup" }));
|
||||
}
|
||||
};
|
||||
|
||||
function addAnnotation() {
|
||||
annotationsField.append({
|
||||
type: AnnotationType.String,
|
||||
key: "",
|
||||
value: "",
|
||||
});
|
||||
}
|
||||
|
||||
function removeAnnotation(index: number) {
|
||||
annotationsField.remove(index);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ViewHeader
|
||||
titleKey={
|
||||
matchingGroup
|
||||
? "attributes-group:editGroupText"
|
||||
: "attributes-group:createGroupText"
|
||||
}
|
||||
divider
|
||||
/>
|
||||
<PageSection variant="light" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<FormAccess isHorizontal role="manage-realm">
|
||||
<FormGroup
|
||||
label={t("attributes-group:nameField")}
|
||||
fieldId="kc-name"
|
||||
isRequired
|
||||
helperTextInvalid={t("common:required")}
|
||||
validated={form.errors.name ? "error" : "default"}
|
||||
labelIcon={
|
||||
<HelpItem
|
||||
helpText="attributes-group:nameHint"
|
||||
fieldLabelId="attributes-group:nameField"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<TextInput
|
||||
ref={form.register({ required: true })}
|
||||
type="text"
|
||||
id="kc-name"
|
||||
name="name"
|
||||
isDisabled={!!matchingGroup}
|
||||
/>
|
||||
</FormGroup>
|
||||
<FormGroup
|
||||
label={t("attributes-group:displayHeaderField")}
|
||||
fieldId="kc-display-header"
|
||||
labelIcon={
|
||||
<HelpItem
|
||||
helpText="attributes-group:displayHeaderHint"
|
||||
fieldLabelId="attributes-group:displayHeaderField"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<TextInput
|
||||
ref={form.register()}
|
||||
type="text"
|
||||
id="kc-display-header"
|
||||
name="displayHeader"
|
||||
/>
|
||||
</FormGroup>
|
||||
<FormGroup
|
||||
label={t("attributes-group:displayDescriptionField")}
|
||||
fieldId="kc-display-description"
|
||||
labelIcon={
|
||||
<HelpItem
|
||||
helpText="attributes-group:displayDescriptionHint"
|
||||
fieldLabelId="attributes-group:displayDescriptionField"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<TextInput
|
||||
ref={form.register()}
|
||||
type="text"
|
||||
id="kc-display-description"
|
||||
name="displayDescription"
|
||||
/>
|
||||
</FormGroup>
|
||||
<TextContent>
|
||||
<Text component="h2">{t("attributes-group:annotationsText")}</Text>
|
||||
</TextContent>
|
||||
<FormGroup
|
||||
label={t("attributes-group:annotationsText")}
|
||||
fieldId="kc-annotations"
|
||||
>
|
||||
<Flex direction={{ default: "column" }}>
|
||||
{annotationsField.fields
|
||||
.filter(
|
||||
(
|
||||
annotation
|
||||
): annotation is Partial<
|
||||
ArrayField<StringAnnotation, "id">
|
||||
> => annotation.type === AnnotationType.String
|
||||
)
|
||||
.map((item, index) => (
|
||||
<Flex key={item.id}>
|
||||
<FlexItem grow={{ default: "grow" }}>
|
||||
<TextInput
|
||||
name={`annotations[${index}].key`}
|
||||
ref={form.register()}
|
||||
placeholder={t("attributes-group:keyPlaceholder")}
|
||||
aria-label={t("attributes-group:keyLabel")}
|
||||
defaultValue={item.key}
|
||||
/>
|
||||
</FlexItem>
|
||||
<FlexItem
|
||||
grow={{ default: "grow" }}
|
||||
spacer={{ default: "spacerNone" }}
|
||||
>
|
||||
<TextInput
|
||||
name={`annotations[${index}].value`}
|
||||
ref={form.register()}
|
||||
placeholder={t("attributes-group:valuePlaceholder")}
|
||||
aria-label={t("attributes-group:valueLabel")}
|
||||
defaultValue={item.value}
|
||||
/>
|
||||
</FlexItem>
|
||||
<FlexItem>
|
||||
<Button
|
||||
variant="link"
|
||||
title={t("attributes-group:removeAnnotationText")}
|
||||
aria-label={t("attributes-group:removeAnnotationText")}
|
||||
isDisabled={annotationsField.fields.length === 1}
|
||||
onClick={() => removeAnnotation(index)}
|
||||
>
|
||||
<MinusCircleIcon />
|
||||
</Button>
|
||||
</FlexItem>
|
||||
</Flex>
|
||||
))}
|
||||
</Flex>
|
||||
<ActionList>
|
||||
<ActionListItem>
|
||||
<Button
|
||||
className="pf-u-px-0 pf-u-mt-sm"
|
||||
variant="link"
|
||||
icon={<PlusCircleIcon />}
|
||||
isDisabled={!annotationsValid}
|
||||
onClick={addAnnotation}
|
||||
>
|
||||
{t("attributes-group:addAnnotationText")}
|
||||
</Button>
|
||||
</ActionListItem>
|
||||
</ActionList>
|
||||
</FormGroup>
|
||||
<ActionGroup>
|
||||
<Button variant="primary" type="submit">
|
||||
{t("common:save")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="link"
|
||||
component={(props) => (
|
||||
<Link
|
||||
{...props}
|
||||
to={toUserProfile({ realm, tab: "attributesGroup" })}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
{t("common:cancel")}
|
||||
</Button>
|
||||
</ActionGroup>
|
||||
</FormAccess>
|
||||
</PageSection>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -11,12 +11,16 @@ import { Link, useHistory } from "react-router-dom";
|
|||
import { useConfirmDialog } from "../../components/confirm-dialog/ConfirmDialog";
|
||||
import { ListEmptyState } from "../../components/list-empty-state/ListEmptyState";
|
||||
import { KeycloakDataTable } from "../../components/table-toolbar/KeycloakDataTable";
|
||||
import { useRealm } from "../../context/realm-context/RealmContext";
|
||||
import { toEditAttributesGroup } from "../routes/EditAttributesGroup";
|
||||
import { toNewAttributesGroup } from "../routes/NewAttributesGroup";
|
||||
import { useUserProfile } from "./UserProfileContext";
|
||||
|
||||
export const AttributesGroupTab = () => {
|
||||
const { config, save } = useUserProfile();
|
||||
const { t } = useTranslation();
|
||||
const history = useHistory();
|
||||
const { realm } = useRealm();
|
||||
const [key, setKey] = useState(0);
|
||||
const [groupToDelete, setGroupToDelete] = useState<UserProfileGroup>();
|
||||
|
||||
|
@ -66,9 +70,12 @@ export const AttributesGroupTab = () => {
|
|||
ariaLabelKey="attributes-group:tableTitle"
|
||||
toolbarItem={
|
||||
<ToolbarItem>
|
||||
{/* TODO: Add link to page */}
|
||||
<Button component={(props) => <Link {...props} to={{}} />}>
|
||||
{t("attributes-group:createButtonText")}
|
||||
<Button
|
||||
component={(props) => (
|
||||
<Link {...props} to={toNewAttributesGroup({ realm })} />
|
||||
)}
|
||||
>
|
||||
{t("attributes-group:createGroupText")}
|
||||
</Button>
|
||||
</ToolbarItem>
|
||||
}
|
||||
|
@ -76,6 +83,11 @@ export const AttributesGroupTab = () => {
|
|||
{
|
||||
name: "name",
|
||||
displayKey: "attributes-group:columnName",
|
||||
cellRenderer: (group) => (
|
||||
<Link to={toEditAttributesGroup({ realm, name: group.name! })}>
|
||||
{group.name}
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "displayHeader",
|
||||
|
@ -87,11 +99,6 @@ export const AttributesGroupTab = () => {
|
|||
},
|
||||
]}
|
||||
actions={[
|
||||
{
|
||||
title: t("common:edit"),
|
||||
// TODO: Add link to page.
|
||||
onRowClick: () => history.push({}),
|
||||
},
|
||||
{
|
||||
title: t("common:delete"),
|
||||
onRowClick: deleteAttributeGroup,
|
||||
|
@ -101,9 +108,10 @@ export const AttributesGroupTab = () => {
|
|||
<ListEmptyState
|
||||
message={t("attributes-group:emptyStateMessage")}
|
||||
instructions={t("attributes-group:emptyStateInstructions")}
|
||||
primaryActionText={t("attributes-group:createButtonText")}
|
||||
// TODO: Add link to page.
|
||||
onPrimaryAction={() => history.push({})}
|
||||
primaryActionText={t("attributes-group:createGroupText")}
|
||||
onPrimaryAction={() =>
|
||||
history.push(toNewAttributesGroup({ realm }))
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
|
|
@ -16,7 +16,7 @@ type UserProfileProps = {
|
|||
export type SaveCallback = (
|
||||
updatedConfig: UserProfileConfig,
|
||||
options?: SaveOptions
|
||||
) => Promise<void>;
|
||||
) => Promise<boolean>;
|
||||
|
||||
export type SaveOptions = {
|
||||
successMessageKey?: string;
|
||||
|
@ -51,19 +51,23 @@ export const UserProfileProvider: FunctionComponent = ({ children }) => {
|
|||
realm,
|
||||
});
|
||||
|
||||
setIsSaving(false);
|
||||
setRefreshCount(refreshCount + 1);
|
||||
addAlert(
|
||||
t(options?.successMessageKey ?? "realm-settings:userProfileSuccess"),
|
||||
AlertVariant.success
|
||||
);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
setIsSaving(false);
|
||||
addError(
|
||||
options?.errorMessageKey ?? "realm-settings:userProfileError",
|
||||
error
|
||||
);
|
||||
}
|
||||
|
||||
setIsSaving(false);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
Loading…
Reference in a new issue