From 0152e5868d8580d6cdac85b3088a14cae67862ad Mon Sep 17 00:00:00 2001 From: Jon Koops Date: Tue, 1 Mar 2022 06:44:42 +0100 Subject: [PATCH] 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 --- src/realm-settings/messages.ts | 19 +- src/realm-settings/routes.ts | 4 + .../routes/EditAttributesGroup.ts | 22 ++ .../routes/NewAttributesGroup.ts | 21 ++ src/realm-settings/routes/UserProfile.ts | 2 +- .../user-profile/AttributesGroupDetails.tsx | 11 + .../user-profile/AttributesGroupForm.tsx | 322 ++++++++++++++++++ .../user-profile/AttributesGroupTab.tsx | 30 +- .../user-profile/UserProfileContext.tsx | 10 +- 9 files changed, 425 insertions(+), 16 deletions(-) create mode 100644 src/realm-settings/routes/EditAttributesGroup.ts create mode 100644 src/realm-settings/routes/NewAttributesGroup.ts create mode 100644 src/realm-settings/user-profile/AttributesGroupDetails.tsx create mode 100644 src/realm-settings/user-profile/AttributesGroupForm.tsx diff --git a/src/realm-settings/messages.ts b/src/realm-settings/messages.ts index 1bb388f898..9e5a61bb05 100644 --- a/src/realm-settings/messages.ts +++ b/src/realm-settings/messages.ts @@ -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}}?", 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", }, }; diff --git a/src/realm-settings/routes.ts b/src/realm-settings/routes.ts index 40393dfbc1..b3bdbbe197 100644 --- a/src/realm-settings/routes.ts +++ b/src/realm-settings/routes.ts @@ -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; diff --git a/src/realm-settings/routes/EditAttributesGroup.ts b/src/realm-settings/routes/EditAttributesGroup.ts new file mode 100644 index 0000000000..ee0a4a7951 --- /dev/null +++ b/src/realm-settings/routes/EditAttributesGroup.ts @@ -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), +}); diff --git a/src/realm-settings/routes/NewAttributesGroup.ts b/src/realm-settings/routes/NewAttributesGroup.ts new file mode 100644 index 0000000000..e2ae715735 --- /dev/null +++ b/src/realm-settings/routes/NewAttributesGroup.ts @@ -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), +}); diff --git a/src/realm-settings/routes/UserProfile.ts b/src/realm-settings/routes/UserProfile.ts index 3e042563ae..5cf4deaa19 100644 --- a/src/realm-settings/routes/UserProfile.ts +++ b/src/realm-settings/routes/UserProfile.ts @@ -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", }; diff --git a/src/realm-settings/user-profile/AttributesGroupDetails.tsx b/src/realm-settings/user-profile/AttributesGroupDetails.tsx new file mode 100644 index 0000000000..c32446b0bd --- /dev/null +++ b/src/realm-settings/user-profile/AttributesGroupDetails.tsx @@ -0,0 +1,11 @@ +import React from "react"; +import AttributesGroupForm from "./AttributesGroupForm"; +import { UserProfileProvider } from "./UserProfileContext"; + +const AttributesGroupDetails = () => ( + + + +); + +export default AttributesGroupDetails; diff --git a/src/realm-settings/user-profile/AttributesGroupForm.tsx b/src/realm-settings/user-profile/AttributesGroupForm.tsx new file mode 100644 index 0000000000..f7b7b640e9 --- /dev/null +++ b/src/realm-settings/user-profile/AttributesGroupForm.tsx @@ -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) { + return Object.entries(input).map(([key, value]) => { + if (typeof value === "string") { + return { type: AnnotationType.String, key, value }; + } + + return { type: AnnotationType.Unknown, key, value }; + }); +} + +function transformAnnotations(input: Annotation[]): Record { + return Object.fromEntries( + input + .filter((annotation) => annotation.key.length > 0) + .map((annotation) => [annotation.key, annotation.value] as const) + ); +} + +type FormFields = Required> & { + 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>(); + const form = useForm({ defaultValues, shouldUnregister: false }); + const annotationsField = useFieldArray({ + 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 = 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 ( + <> + + + + + } + > + + + + } + > + + + + } + > + + + + {t("attributes-group:annotationsText")} + + + + {annotationsField.fields + .filter( + ( + annotation + ): annotation is Partial< + ArrayField + > => annotation.type === AnnotationType.String + ) + .map((item, index) => ( + + + + + + + + + + + + ))} + + + + + + + + + + + + + + + ); +} diff --git a/src/realm-settings/user-profile/AttributesGroupTab.tsx b/src/realm-settings/user-profile/AttributesGroupTab.tsx index 601b253039..f61baba403 100644 --- a/src/realm-settings/user-profile/AttributesGroupTab.tsx +++ b/src/realm-settings/user-profile/AttributesGroupTab.tsx @@ -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(); @@ -66,9 +70,12 @@ export const AttributesGroupTab = () => { ariaLabelKey="attributes-group:tableTitle" toolbarItem={ - {/* TODO: Add link to page */} - } @@ -76,6 +83,11 @@ export const AttributesGroupTab = () => { { name: "name", displayKey: "attributes-group:columnName", + cellRenderer: (group) => ( + + {group.name} + + ), }, { 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 = () => { history.push({})} + primaryActionText={t("attributes-group:createGroupText")} + onPrimaryAction={() => + history.push(toNewAttributesGroup({ realm })) + } /> } /> diff --git a/src/realm-settings/user-profile/UserProfileContext.tsx b/src/realm-settings/user-profile/UserProfileContext.tsx index 6a64a11f1f..7a907556b8 100644 --- a/src/realm-settings/user-profile/UserProfileContext.tsx +++ b/src/realm-settings/user-profile/UserProfileContext.tsx @@ -16,7 +16,7 @@ type UserProfileProps = { export type SaveCallback = ( updatedConfig: UserProfileConfig, options?: SaveOptions -) => Promise; +) => Promise; 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 (