WIP role attributes

fix storybook demos

add attributes tab to realm roles section

use TableComposable

fix formatting

css updates

fix up styling of role attributes table

fix check-types erros

logic from call with Jeff

clean up, format, make eslint proud

delete roledetails

call with Erik

add delete function and css changes

fix storybook demos and format

make key input disabled once new attribute is created

minus icon gray

address PR feedback from Sarah

set add and save buttons to disabled when new input field is  empty

fix save/add

fix onChange formState

rebase with resolved conflicts

fix formatting

remove !important

Update RealmRolesSection.css

Update src/realm-roles/RoleAttributes.tsx

Co-authored-by: Erik Jan de Wit <erikjan.dewit@gmail.com>

address PR feedback from Sarah and Erik

fix paths and use error msg from response

remove log stmt

fix lint

remove minLength
This commit is contained in:
jenny-s51 2020-12-04 15:37:29 -05:00
parent 5d7d2b5636
commit 8ef7bd7ddb
10 changed files with 163 additions and 413 deletions

View file

@ -1,140 +0,0 @@
import React, { useEffect, useState } from "react";
import { useHistory, useParams } from "react-router-dom";
import {
ActionGroup,
AlertVariant,
Button,
FormGroup,
PageSection,
Tab,
Tabs,
TabTitleText,
TextArea,
TextInput,
ValidatedOptions,
} from "@patternfly/react-core";
import { useTranslation } from "react-i18next";
import { Controller, useForm } from "react-hook-form";
import { FormAccess } from "../components/form-access/FormAccess";
import { useAlerts } from "../components/alert/Alerts";
import { ViewHeader } from "../components/view-header/ViewHeader";
import { useAdminClient } from "../context/auth/AdminClient";
import RoleRepresentation from "keycloak-admin/lib/defs/roleRepresentation";
import { RoleAttributes } from "./RoleAttributes";
export const RolesForm = () => {
const { t } = useTranslation("roles");
const { register, handleSubmit, errors, control, setValue } = useForm<
RoleRepresentation
>();
const history = useHistory();
const [name, setName] = useState("");
const [activeTab, setActiveTab] = useState(0);
const adminClient = useAdminClient();
const form = useForm();
const { id } = useParams<{ id: string }>();
const { addAlert } = useAlerts();
useEffect(() => {
(async () => {
const fetchedRole = await adminClient.roles.findOneById({ id });
setName(fetchedRole.name!);
setupForm(fetchedRole);
})();
}, []);
const setupForm = (role: RoleRepresentation) => {
Object.entries(role).map((entry) => {
setValue(entry[0], entry[1]);
});
};
const save = async (role: RoleRepresentation) => {
try {
await adminClient.roles.updateById({ id }, role);
setupForm(role as RoleRepresentation);
addAlert(t("roleSaveSuccess"), AlertVariant.success);
} catch (error) {
addAlert(`${t("roleSaveError")} '${error}'`, AlertVariant.danger);
}
};
return (
<>
<ViewHeader titleKey={name} subKey="" />
<PageSection variant="light">
<Tabs
activeKey={activeTab}
onSelect={(_, key) => setActiveTab(key as number)}
isBox
>
<Tab eventKey={0} title={<TabTitleText>{t("details")}</TabTitleText>}>
<FormAccess
isHorizontal
onSubmit={handleSubmit(save)}
role="manage-realm"
className="pf-u-mt-lg"
>
<FormGroup
label={t("roleName")}
fieldId="kc-name"
isRequired
validated={errors.name ? "error" : "default"}
helperTextInvalid={t("common:required")}
>
{name ? (
<TextInput
ref={register({ required: true })}
type="text"
id="kc-name"
name="name"
isReadOnly
/>
) : undefined}
</FormGroup>
<FormGroup label={t("description")} fieldId="kc-description">
<Controller
name="description"
defaultValue=""
control={control}
rules={{ maxLength: 255 }}
render={({ onChange, value }) => (
<TextArea
type="text"
validated={
errors.description
? ValidatedOptions.error
: ValidatedOptions.default
}
id="kc-role-description"
value={value}
onChange={onChange}
/>
)}
/>
</FormGroup>
<ActionGroup>
<Button variant="primary" type="submit">
{t("common:save")}
</Button>
<Button variant="link" onClick={() => history.push("/roles/")}>
{t("common:reload")}
</Button>
</ActionGroup>
</FormAccess>
</Tab>
<Tab eventKey={1} title={<TabTitleText>{t("attributes")}</TabTitleText>}>
<RoleAttributes form={form} />
</Tab>
</Tabs>
</PageSection>
</>
);
};

View file

@ -1,48 +1,31 @@
import React, { useEffect, useState } from "react";
import { useHistory, useParams } from "react-router-dom";
import React from "react";
import {
ActionGroup,
AlertVariant,
Button,
ButtonVariant,
DropdownItem,
FormGroup,
PageSection,
Tab,
Tabs,
TabTitleText,
TextArea,
TextInput,
ValidatedOptions,
} from "@patternfly/react-core";
import { useTranslation } from "react-i18next";
import { SubmitHandler, useForm, UseFormMethods } from "react-hook-form";
import { SubmitHandler, UseFormMethods } from "react-hook-form";
import RoleRepresentation from "keycloak-admin/lib/defs/roleRepresentation";
import { FormAccess } from "../components/form-access/FormAccess";
import { useAlerts } from "../components/alert/Alerts";
import { ViewHeader } from "../components/view-header/ViewHeader";
import { useAdminClient, asyncStateFetch } from "../context/auth/AdminClient";
import { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog";
import { RoleAttributes } from "./RoleAttributes";
import { useRealm } from "../context/realm-context/RealmContext";
type RoleFormType = {
form?: UseFormMethods;
save?: SubmitHandler<RoleRepresentation>;
editMode?: boolean;
export type RealmRoleFormProps = {
form: UseFormMethods;
save: SubmitHandler<RoleRepresentation>;
editMode: boolean;
};
export const RoleForm = ({ form, save, editMode }: RoleFormType) => {
export const RealmRoleForm = ({ form, save, editMode }: RealmRoleFormProps) => {
const { t } = useTranslation("roles");
const history = useHistory();
const { realm } = useRealm();
return (
<FormAccess
isHorizontal
onSubmit={form!.handleSubmit(save!)}
onSubmit={form.handleSubmit(save)}
role="manage-realm"
className="pf-u-mt-lg"
>
@ -50,11 +33,11 @@ export const RoleForm = ({ form, save, editMode }: RoleFormType) => {
label={t("roleName")}
fieldId="kc-name"
isRequired
validated={form!.errors.name ? "error" : "default"}
validated={form.errors.name ? "error" : "default"}
helperTextInvalid={t("common:required")}
>
<TextInput
ref={form!.register({ required: true })}
ref={form.register({ required: !editMode })}
type="text"
id="kc-name"
name="name"
@ -65,15 +48,15 @@ export const RoleForm = ({ form, save, editMode }: RoleFormType) => {
label={t("description")}
fieldId="kc-description"
validated={
form!.errors.description
form.errors.description
? ValidatedOptions.error
: ValidatedOptions.default
}
helperTextInvalid={form!.errors.description?.message}
helperTextInvalid={form.errors.description?.message}
>
<TextArea
name="description"
ref={form!.register({
ref={form.register({
maxLength: {
value: 255,
message: t("common:maxLength", { length: 255 }),
@ -81,7 +64,7 @@ export const RoleForm = ({ form, save, editMode }: RoleFormType) => {
})}
type="text"
validated={
form!.errors.description
form.errors.description
? ValidatedOptions.error
: ValidatedOptions.default
}
@ -92,131 +75,10 @@ export const RoleForm = ({ form, save, editMode }: RoleFormType) => {
<Button variant="primary" type="submit">
{t("common:save")}
</Button>
<Button variant="link" onClick={() => history.push(`/${realm}/roles`)}>
<Button variant="link">
{editMode ? t("common:reload") : t("common:cancel")}
</Button>
</ActionGroup>
</FormAccess>
);
};
export const RealmRolesForm = () => {
const { t } = useTranslation("roles");
const form = useForm<RoleRepresentation>();
const adminClient = useAdminClient();
const { addAlert } = useAlerts();
const history = useHistory();
const { realm } = useRealm();
const { id } = useParams<{ id: string }>();
const [name, setName] = useState("");
const [activeTab, setActiveTab] = useState(0);
useEffect(() => {
return asyncStateFetch(
async () => {
if (id) {
const role = await adminClient.roles.findOneById({ id });
return { role, name: role.name };
} else {
return { name: t("createRole") };
}
},
({ role, name }) => {
setName(name!);
if (role) {
setupForm(role);
}
}
);
}, []);
const setupForm = (role: RoleRepresentation) => {
Object.entries(role).map((entry) => {
form.setValue(entry[0], entry[1]);
});
};
const save = async (role: RoleRepresentation) => {
try {
if (id) {
await adminClient.roles.updateById({ id }, role);
} else {
await adminClient.roles.create(role);
const createdRole = await adminClient.roles.findOneByName({
name: role.name!,
});
history.push(`/${realm}/roles/${createdRole.id}`);
}
addAlert(t(id ? "roleSaveSuccess" : "roleCreated"), AlertVariant.success);
} catch (error) {
addAlert(
t((id ? "roleSave" : "roleCreate") + "Error", { error }),
AlertVariant.danger
);
}
};
const [toggleDeleteDialog, DeleteConfirm] = useConfirmDialog({
titleKey: "roles:roleDeleteConfirm",
messageKey: t("roles:roleDeleteConfirmDialog", { name }),
continueButtonLabel: "common:delete",
continueButtonVariant: ButtonVariant.danger,
onConfirm: async () => {
try {
await adminClient.roles.delById({ id });
addAlert(t("roleDeletedSuccess"), AlertVariant.success);
history.push(`/${realm}/roles`);
} catch (error) {
addAlert(`${t("roleDeleteError")} ${error}`, AlertVariant.danger);
}
},
});
return (
<>
<DeleteConfirm />
<ViewHeader
titleKey={name}
subKey={id ? "" : "roles:roleCreateExplain"}
dropdownItems={
id
? [
<DropdownItem
key="action"
component="button"
onClick={() => toggleDeleteDialog()}
>
{t("deleteRole")}
</DropdownItem>,
]
: undefined
}
/>
<PageSection variant="light">
{id && (
<Tabs
activeKey={activeTab}
onSelect={(_, key) => setActiveTab(key as number)}
isBox
>
<Tab
eventKey={0}
title={<TabTitleText>{t("details")}</TabTitleText>}
>
<RoleForm form={form} save={save} editMode={true} />
</Tab>
<Tab
eventKey={1}
title={<TabTitleText>{t("attributes")}</TabTitleText>}
>
<RoleAttributes />
</Tab>
</Tabs>
)}
{!id && <RoleForm form={form} save={save} editMode={false} />}
</PageSection>
</>
);
};
};

View file

@ -1,31 +1,52 @@
import React, { useEffect, useState } from "react";
import { useHistory, useParams } from "react-router-dom";
import {
ActionGroup,
AlertVariant,
Button,
FormGroup,
ButtonVariant,
DropdownItem,
PageSection,
Tab,
Tabs,
TabTitleText,
TextArea,
TextInput,
ValidatedOptions,
} from "@patternfly/react-core";
import { useTranslation } from "react-i18next";
import { Controller, useForm } from "react-hook-form";
import { FormAccess } from "../components/form-access/FormAccess";
import { useForm } from "react-hook-form";
import { useAlerts } from "../components/alert/Alerts";
import { useAdminClient, asyncStateFetch } from "../context/auth/AdminClient";
import { useAdminClient } from "../context/auth/AdminClient";
import RoleRepresentation from "keycloak-admin/lib/defs/roleRepresentation";
import { RoleAttributes } from "./RoleAttributes";
import "./RealmRolesSection.css";
import { KeyValueType, RoleAttributes } from "./RoleAttributes";
import { ViewHeader } from "../components/view-header/ViewHeader";
import { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog";
import { RealmRoleForm } from "./RealmRoleForm";
import { useRealm } from "../context/realm-context/RealmContext";
export const RolesTabs = () => {
const arrayToAttributes = (attributeArray: KeyValueType[]) => {
const initValue: { [index: string]: string[] } = {};
return attributeArray.reduce((acc, attribute) => {
acc[attribute.key] = [attribute.value];
return acc;
}, initValue);
};
const attributesToArray = (attributes: { [key: string]: string }): any => {
if (!attributes || Object.keys(attributes).length == 0) {
return [
{
key: "",
value: "",
},
];
}
return Object.keys(attributes).map((key) => ({
key: key,
value: attributes[key],
}));
};
export const RealmRoleTabs = () => {
const { t } = useTranslation("roles");
const { errors, control, setValue } = useForm<RoleRepresentation>();
const form = useForm<RoleRepresentation>({ mode: "onChange" });
const history = useHistory();
const [name, setName] = useState("");
const adminClient = useAdminClient();
@ -37,116 +58,114 @@ export const RolesTabs = () => {
const { addAlert } = useAlerts();
useEffect(() => {
return asyncStateFetch(
() => adminClient.roles.findOneById({ id }),
(fetchedRole) => {
(async () => {
if (id) {
const fetchedRole = await adminClient.roles.findOneById({ id });
setName(fetchedRole.name!);
setupForm(fetchedRole);
} else {
setName(t("createRole"));
}
);
})();
}, []);
const setupForm = (role: RoleRepresentation) => {
Object.entries(role).map((entry) => {
setValue(entry[0], entry[1]);
if (entry[0] === "attributes") {
form.setValue(entry[0], attributesToArray(entry[1]));
} else {
form.setValue(entry[0], entry[1]);
}
});
};
const save = async (role: RoleRepresentation) => {
try {
await adminClient.roles.updateById({ id }, role);
setupForm(role as RoleRepresentation);
addAlert(t("roleSaveSuccess"), AlertVariant.success);
if (id) {
if (role.attributes) {
// react-hook-form will use `KeyValueType[]` here we convert it back into an indexed property of string[]
role.attributes = arrayToAttributes(
(role.attributes as unknown) as KeyValueType[]
);
}
await adminClient.roles.updateById({ id }, role);
} else {
await adminClient.roles.create(role);
const createdRole = await adminClient.roles.findOneByName({
name: role.name!,
});
history.push(`/${realm}/roles/${createdRole.id}`);
}
addAlert(t(id ? "roleSaveSuccess" : "roleCreated"), AlertVariant.success);
} catch (error) {
addAlert(`${t("roleSaveError")} '${error}'`, AlertVariant.danger);
addAlert(
t((id ? "roleSave" : "roleCreate") + "Error", {
error: error.response.data?.errorMessage || error,
}),
AlertVariant.danger
);
}
};
const form = useForm<RoleRepresentation>();
const [toggleDeleteDialog, DeleteConfirm] = useConfirmDialog({
titleKey: "roles:roleDeleteConfirm",
messageKey: t("roles:roleDeleteConfirmDialog", { name }),
continueButtonLabel: "common:delete",
continueButtonVariant: ButtonVariant.danger,
onConfirm: async () => {
try {
await adminClient.roles.delById({ id });
addAlert(t("roleDeletedSuccess"), AlertVariant.success);
history.replace(`/${realm}/roles`);
} catch (error) {
addAlert(`${t("roleDeleteError")} ${error}`, AlertVariant.danger);
}
},
});
return (
<>
<Tabs
activeKey={activeTab}
onSelect={(_, key) => setActiveTab(key as number)}
isBox
>
<Tab eventKey={0} title={<TabTitleText>{t("details")}</TabTitleText>}>
<FormAccess
isHorizontal
onSubmit={form.handleSubmit(save)}
role="manage-realm"
className="pf-u-mt-lg"
<DeleteConfirm />
<ViewHeader
titleKey={name}
subKey={id ? "" : "roles:roleCreateExplain"}
dropdownItems={
id
? [
<DropdownItem
key="action"
component="button"
onClick={() => toggleDeleteDialog()}
>
{t("deleteRole")}
</DropdownItem>,
]
: undefined
}
/>
<PageSection variant="light">
{id && (
<Tabs
activeKey={activeTab}
onSelect={(_, key) => setActiveTab(key as number)}
isBox
>
<FormGroup
label={t("roleName")}
fieldId="kc-name"
isRequired
validated={errors.name ? "error" : "default"}
helperTextInvalid={t("common:required")}
<Tab
eventKey={0}
title={<TabTitleText>{t("details")}</TabTitleText>}
>
{name ? (
<TextInput
ref={form.register({ required: true })}
type="text"
id="kc-name"
name="name"
isReadOnly
/>
) : undefined}
</FormGroup>
<FormGroup label={t("description")} fieldId="kc-description">
<Controller
name="description"
defaultValue=""
control={control}
rules={{ maxLength: 255 }}
render={({ onChange, value }) => (
<TextArea
type="text"
validated={
errors.description
? ValidatedOptions.error
: ValidatedOptions.default
}
id="kc-role-description"
value={value}
onChange={onChange}
/>
)}
/>
</FormGroup>
<ActionGroup>
<Button variant="primary" type="submit">
{t("common:save")}
</Button>
<Button
variant="link"
onClick={() => history.push(`/${realm}/roles`)}
>
{t("common:reload")}
</Button>
</ActionGroup>
</FormAccess>
</Tab>
<Tab
eventKey={1}
title={<TabTitleText>{t("attributes")}</TabTitleText>}
>
<RoleAttributes />
<ActionGroup className="kc-role-attributes__action-group">
<Button variant="primary" type="submit">
{t("common:save")}
</Button>
<Button
variant="link"
onClick={() => history.push(`/${realm}/roles`)}
<RealmRoleForm form={form} save={save} editMode={true} />
</Tab>
<Tab
eventKey={1}
title={<TabTitleText>{t("attributes")}</TabTitleText>}
>
{t("common:reload")}
</Button>
</ActionGroup>
</Tab>
</Tabs>
<RoleAttributes form={form} save={save} />
</Tab>
</Tabs>
)}
{!id && <RealmRoleForm form={form} save={save} editMode={false} />}
</PageSection>
</>
);
};
};

View file

@ -9,9 +9,16 @@
margin-left: calc(var(--pf-global--spacer--md) * -1);
}
.pf-c-button.kc-role-attributes__minus-icon {
/* shift the button left to adjust for table cell padding */
margin-left: calc(var(--pf-global--spacer--md) * -1);
color: var(--pf-c-button--m-plain--Color);
}
.kc-role-attributes__action-group {
/* subtract the padding at the bottom of the table from the action group margin */
--pf-c-form__group--m-action--MarginTop: calc(
var(--pf-global--spacer--2xl) - var(--pf-global--spacer--sm)
);
}

View file

@ -2,7 +2,11 @@
{
"name":"Admin",
"composite":true,
"description": "Lorem ipsum dolor sit amet"
"description": "Lorem ipsum dolor sit amet",
"attributes": {
"key input 1": "value input 1",
"key input 2": "value input 2"
}
},
{
"name":"Author",

View file

@ -1,10 +1,8 @@
{
"roles": {
"attributes": "Attributes",
<<<<<<< HEAD
"addAttributeText": "Add an attribute",
=======
>>>>>>> add role attributes, WIP
"deleteAttributeText": "Delete an attribute",
"title": "Realm roles",
"createRole": "Create role",
"importRole": "Import role",

View file

@ -11,7 +11,6 @@ import { EventsSection } from "./events/EventsSection";
import { GroupsSection } from "./groups/GroupsSection";
import { IdentityProvidersSection } from "./identity-providers/IdentityProvidersSection";
import { PageNotFoundSection } from "./PageNotFoundSection";
import { RealmRolesForm } from "./realm-roles/RealmRoleForm";
import { RealmRolesSection } from "./realm-roles/RealmRolesSection";
import { RealmSettingsSection } from "./realm-settings/RealmSettingsSection";
import { NewRealmForm } from "./realm/add/NewRealmForm";
@ -24,6 +23,7 @@ import { UserFederationKerberosSettings } from "./user-federation/UserFederation
import { UserFederationLdapSettings } from "./user-federation/UserFederationLdapSettings";
import { RoleMappingForm } from "./client-scopes/add/RoleMappingForm";
import { BreadcrumbsRoute } from "use-react-router-breadcrumbs";
import { RealmRoleTabs } from "./realm-roles/RealmRoleTabs";
export type RouteDef = BreadcrumbsRoute & {
component: () => JSX.Element;
@ -107,13 +107,13 @@ export const routes: RoutesFn = (t) => [
},
{
path: "/:realm/roles/add-role",
component: RealmRolesForm,
component: RealmRoleTabs,
breadcrumb: t("roles:createRole"),
access: "manage-realm",
},
{
path: "/:realm/roles/:id",
component: RealmRolesForm,
component: RealmRoleTabs,
breadcrumb: t("roles:roleDetails"),
access: "view-realm",
},

View file

@ -3,19 +3,19 @@ import { Meta } from "@storybook/react";
import { MockAdminClient } from "./MockAdminClient";
import { MemoryRouter, Route } from "react-router-dom";
import rolesMock from "../realm-roles/__tests__/mock-roles.json";
import { RolesTabs } from "../realm-roles/RealmRoleTabs";
import { RealmRoleTabs } from "../realm-roles/RealmRoleTabs";
export default {
title: "Roles tabs",
component: RolesTabs,
component: RealmRoleTabs,
} as Meta;
export const RoleTabsExample = () => {
export const RolesTabsExample = () => {
return (
<MockAdminClient mock={{ roles: { findOneById: () => rolesMock[0] } }}>
<MemoryRouter initialEntries={["/roles/1"]}>
<Route path="/roles/:id">
<RolesTabs />
<RealmRoleTabs />
</Route>
</MemoryRouter>
</MockAdminClient>

View file

@ -3,18 +3,18 @@ import { Page } from "@patternfly/react-core";
import { Meta } from "@storybook/react";
import { MockAdminClient } from "./MockAdminClient";
import { RealmRolesForm } from "../realm-roles/RealmRoleForm";
import { RealmRoleTabs } from "../realm-roles/RealmRoleTabs";
export default {
title: "New role form",
component: RealmRolesForm,
component: RealmRoleTabs,
} as Meta;
export const View = () => {
return (
<Page>
<MockAdminClient>
<RealmRolesForm />
<RealmRoleTabs />
</MockAdminClient>
</Page>
);

View file

@ -19351,9 +19351,9 @@ use-latest@^1.0.0:
dependencies:
use-isomorphic-layout-effect "^1.0.0"
use-react-router-breadcrumbs@^1.0.4:
use-react-router-breadcrumbs@^1.0.5:
version "1.0.5"
resolved "https://registry.yarnpkg.com/use-react-router-breadcrumbs/-/use-react-router-breadcrumbs-1.0.5.tgz#3b39a2c2a6ab72544c2fc8984f6825d0f1122877"
resolved "https://registry.npmjs.org/use-react-router-breadcrumbs/-/use-react-router-breadcrumbs-1.0.5.tgz#3b39a2c2a6ab72544c2fc8984f6825d0f1122877"
integrity sha512-NDMgWr5MdksqnATRvp84RtZ0ABfuztlsgR4VWlsBV0D3TVV6xhbmkhTdV3cWnyRIZqNlMXZhwJhyRHoC6fbAsQ==
use-sidecar@^1.0.1: