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:
Jon Koops 2022-03-01 06:44:42 +01:00 committed by GitHub
parent baf2380d1e
commit 0152e5868d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 425 additions and 16 deletions

View file

@ -820,7 +820,8 @@ export default {
exportFail: "Could not export realm: '{{error}}'", exportFail: "Could not export realm: '{{error}}'",
}, },
"attributes-group": { "attributes-group": {
createButtonText: "Create attributes group", createGroupText: "Create attributes group",
editGroupText: "Edit attributes group",
tableTitle: "Attributes groups", tableTitle: "Attributes groups",
columnName: "Name", columnName: "Name",
columnDisplayName: "Display name", columnDisplayName: "Display name",
@ -833,5 +834,21 @@ export default {
"Are you sure you want to permanently delete the attributes group <1>{{group}}</1>?", "Are you sure you want to permanently delete the attributes group <1>{{group}}</1>?",
deleteSuccess: "Attributes group deleted.", deleteSuccess: "Attributes group deleted.",
deleteError: "Could not delete user attributes group: {{error}}", 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",
}, },
}; };

View file

@ -13,6 +13,8 @@ import { EditClientPolicyConditionRoute } from "./routes/EditCondition";
import { UserProfileRoute } from "./routes/UserProfile"; import { UserProfileRoute } from "./routes/UserProfile";
import { AddAttributeRoute } from "./routes/AddAttribute"; import { AddAttributeRoute } from "./routes/AddAttribute";
import { KeysRoute } from "./routes/KeysTab"; import { KeysRoute } from "./routes/KeysTab";
import { NewAttributesGroupRoute } from "./routes/NewAttributesGroup";
import { EditAttributesGroupRoute } from "./routes/EditAttributesGroup";
const routes: RouteDef[] = [ const routes: RouteDef[] = [
RealmSettingsRoute, RealmSettingsRoute,
@ -29,6 +31,8 @@ const routes: RouteDef[] = [
EditClientPolicyConditionRoute, EditClientPolicyConditionRoute,
UserProfileRoute, UserProfileRoute,
AddAttributeRoute, AddAttributeRoute,
NewAttributesGroupRoute,
EditAttributesGroupRoute,
]; ];
export default routes; export default routes;

View 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),
});

View 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),
});

View file

@ -13,7 +13,7 @@ export type UserProfileParams = {
export const UserProfileRoute: RouteDef = { export const UserProfileRoute: RouteDef = {
path: "/:realm/realm-settings/userProfile/:tab", path: "/:realm/realm-settings/userProfile/:tab",
component: lazy(() => import("../RealmSettingsSection")), component: lazy(() => import("../RealmSettingsSection")),
breadcrumb: (t) => t("realmSettings"), breadcrumb: (t) => t("realm-settings:userProfile"),
access: "view-realm", access: "view-realm",
}; };

View 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;

View 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>
</>
);
}

View file

@ -11,12 +11,16 @@ import { Link, useHistory } from "react-router-dom";
import { useConfirmDialog } from "../../components/confirm-dialog/ConfirmDialog"; import { useConfirmDialog } from "../../components/confirm-dialog/ConfirmDialog";
import { ListEmptyState } from "../../components/list-empty-state/ListEmptyState"; import { ListEmptyState } from "../../components/list-empty-state/ListEmptyState";
import { KeycloakDataTable } from "../../components/table-toolbar/KeycloakDataTable"; 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"; import { useUserProfile } from "./UserProfileContext";
export const AttributesGroupTab = () => { export const AttributesGroupTab = () => {
const { config, save } = useUserProfile(); const { config, save } = useUserProfile();
const { t } = useTranslation(); const { t } = useTranslation();
const history = useHistory(); const history = useHistory();
const { realm } = useRealm();
const [key, setKey] = useState(0); const [key, setKey] = useState(0);
const [groupToDelete, setGroupToDelete] = useState<UserProfileGroup>(); const [groupToDelete, setGroupToDelete] = useState<UserProfileGroup>();
@ -66,9 +70,12 @@ export const AttributesGroupTab = () => {
ariaLabelKey="attributes-group:tableTitle" ariaLabelKey="attributes-group:tableTitle"
toolbarItem={ toolbarItem={
<ToolbarItem> <ToolbarItem>
{/* TODO: Add link to page */} <Button
<Button component={(props) => <Link {...props} to={{}} />}> component={(props) => (
{t("attributes-group:createButtonText")} <Link {...props} to={toNewAttributesGroup({ realm })} />
)}
>
{t("attributes-group:createGroupText")}
</Button> </Button>
</ToolbarItem> </ToolbarItem>
} }
@ -76,6 +83,11 @@ export const AttributesGroupTab = () => {
{ {
name: "name", name: "name",
displayKey: "attributes-group:columnName", displayKey: "attributes-group:columnName",
cellRenderer: (group) => (
<Link to={toEditAttributesGroup({ realm, name: group.name! })}>
{group.name}
</Link>
),
}, },
{ {
name: "displayHeader", name: "displayHeader",
@ -87,11 +99,6 @@ export const AttributesGroupTab = () => {
}, },
]} ]}
actions={[ actions={[
{
title: t("common:edit"),
// TODO: Add link to page.
onRowClick: () => history.push({}),
},
{ {
title: t("common:delete"), title: t("common:delete"),
onRowClick: deleteAttributeGroup, onRowClick: deleteAttributeGroup,
@ -101,9 +108,10 @@ export const AttributesGroupTab = () => {
<ListEmptyState <ListEmptyState
message={t("attributes-group:emptyStateMessage")} message={t("attributes-group:emptyStateMessage")}
instructions={t("attributes-group:emptyStateInstructions")} instructions={t("attributes-group:emptyStateInstructions")}
primaryActionText={t("attributes-group:createButtonText")} primaryActionText={t("attributes-group:createGroupText")}
// TODO: Add link to page. onPrimaryAction={() =>
onPrimaryAction={() => history.push({})} history.push(toNewAttributesGroup({ realm }))
}
/> />
} }
/> />

View file

@ -16,7 +16,7 @@ type UserProfileProps = {
export type SaveCallback = ( export type SaveCallback = (
updatedConfig: UserProfileConfig, updatedConfig: UserProfileConfig,
options?: SaveOptions options?: SaveOptions
) => Promise<void>; ) => Promise<boolean>;
export type SaveOptions = { export type SaveOptions = {
successMessageKey?: string; successMessageKey?: string;
@ -51,19 +51,23 @@ export const UserProfileProvider: FunctionComponent = ({ children }) => {
realm, realm,
}); });
setIsSaving(false);
setRefreshCount(refreshCount + 1); setRefreshCount(refreshCount + 1);
addAlert( addAlert(
t(options?.successMessageKey ?? "realm-settings:userProfileSuccess"), t(options?.successMessageKey ?? "realm-settings:userProfileSuccess"),
AlertVariant.success AlertVariant.success
); );
return true;
} catch (error) { } catch (error) {
setIsSaving(false);
addError( addError(
options?.errorMessageKey ?? "realm-settings:userProfileError", options?.errorMessageKey ?? "realm-settings:userProfileError",
error error
); );
}
setIsSaving(false); return false;
}
}; };
return ( return (