Change the input fields based on access rights (#184)

* poc of possible way to change form dynamically

* fixed detail routs

* Update src/components/form-access/FormAccess.tsx

Co-authored-by: Stan Silvert <ssilvert@redhat.com>

* Update src/components/form-access/FormAccess.tsx

Co-authored-by: Stan Silvert <ssilvert@redhat.com>

* Update src/components/form-access/FormAccess.tsx

Co-authored-by: Stan Silvert <ssilvert@redhat.com>

* added more form access and logic for Controller

* render switches for boolean types

* better render of `<Controller` wrapped components

* added isDisabled property

* added test

* small refactor

* fixed types

* TextArea doesn't support isDisabled

* when field is disabled, disable button as well

* added jsdoc

Co-authored-by: Stan Silvert <ssilvert@redhat.com>
This commit is contained in:
Erik Jan de Wit 2020-10-28 19:17:15 +01:00 committed by GitHub
parent 889f8078d2
commit 2543893373
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 472 additions and 25 deletions

View file

@ -7,6 +7,8 @@ import "@testing-library/jest-dom/extend-expect";
import i18n from 'i18next'; import i18n from 'i18next';
import { initReactI18next } from 'react-i18next'; import { initReactI18next } from 'react-i18next';
import 'mutationobserver-shim';
i18n.use(initReactI18next).init({ i18n.use(initReactI18next).init({
lng: 'en', lng: 'en',
fallbackLng: 'en', fallbackLng: 'en',
@ -22,3 +24,6 @@ import { configure } from 'enzyme';
import Adapter from 'enzyme-adapter-react-16'; import Adapter from 'enzyme-adapter-react-16';
configure({ adapter: new Adapter() }); configure({ adapter: new Adapter() });
// eslint-disable-next-line no-undef
global.MutationObserver = window.MutationObserver;

View file

@ -68,6 +68,7 @@
"grunt": "^1.2.1", "grunt": "^1.2.1",
"grunt-contrib-copy": "^1.0.0", "grunt-contrib-copy": "^1.0.0",
"jest": "^25.4.0", "jest": "^25.4.0",
"mutationobserver-shim": "^0.3.7",
"postcss": "^7.0.32", "postcss": "^7.0.32",
"postcss-cli": "^7.1.1", "postcss-cli": "^7.1.1",
"postcss-import": "^12.0.1", "postcss-import": "^12.0.1",

View file

@ -67,6 +67,7 @@ export const PageNav: React.FunctionComponent = () => {
"view-realm", "view-realm",
"query-groups", "query-groups",
"query-users", "query-users",
"query-clients",
"view-events" "view-events"
); );

View file

@ -2,6 +2,7 @@ import React from "react";
import { FormGroup, TextInput } from "@patternfly/react-core"; import { FormGroup, TextInput } from "@patternfly/react-core";
import { UseFormMethods } from "react-hook-form"; import { UseFormMethods } from "react-hook-form";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { FormAccess } from "../components/form-access/FormAccess";
type ClientDescriptionProps = { type ClientDescriptionProps = {
form: UseFormMethods; form: UseFormMethods;
@ -11,7 +12,7 @@ export const ClientDescription = ({ form }: ClientDescriptionProps) => {
const { t } = useTranslation("clients"); const { t } = useTranslation("clients");
const { register, errors } = form; const { register, errors } = form;
return ( return (
<> <FormAccess role="manage-clients" unWrap>
<FormGroup <FormGroup
label={t("clientID")} label={t("clientID")}
fieldId="kc-client-id" fieldId="kc-client-id"
@ -37,6 +38,6 @@ export const ClientDescription = ({ form }: ClientDescriptionProps) => {
name="description" name="description"
/> />
</FormGroup> </FormGroup>
</> </FormAccess>
); );
}; };

View file

@ -32,6 +32,7 @@ import { ViewHeader } from "../components/view-header/ViewHeader";
import { exportClient } from "../util"; import { exportClient } from "../util";
import { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog"; import { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog";
import { useDownloadDialog } from "../components/download-dialog/DownloadDialog"; import { useDownloadDialog } from "../components/download-dialog/DownloadDialog";
import { FormAccess } from "../components/form-access/FormAccess";
export const ClientSettings = () => { export const ClientSettings = () => {
const { t } = useTranslation("clients"); const { t } = useTranslation("clients");
@ -163,7 +164,7 @@ export const ClientSettings = () => {
<Form isHorizontal> <Form isHorizontal>
<ClientDescription form={form} /> <ClientDescription form={form} />
</Form> </Form>
<Form isHorizontal> <FormAccess isHorizontal role="manage-clients">
<FormGroup label={t("rootUrl")} fieldId="kc-root-url"> <FormGroup label={t("rootUrl")} fieldId="kc-root-url">
<TextInput <TextInput
type="text" type="text"
@ -183,8 +184,8 @@ export const ClientSettings = () => {
ref={form.register} ref={form.register}
/> />
</FormGroup> </FormGroup>
</Form> </FormAccess>
<Form isHorizontal> <FormAccess isHorizontal role="manage-clients">
<FormGroup <FormGroup
label={t("consentRequired")} label={t("consentRequired")}
fieldId="kc-consent" fieldId="kc-consent"
@ -241,7 +242,7 @@ export const ClientSettings = () => {
</Button> </Button>
<Button variant="link">{t("common:cancel")}</Button> <Button variant="link">{t("common:cancel")}</Button>
</ActionGroup> </ActionGroup>
</Form> </FormAccess>
</ScrollForm> </ScrollForm>
</PageSection> </PageSection>
</> </>

View file

@ -6,10 +6,11 @@ import {
Checkbox, Checkbox,
Grid, Grid,
GridItem, GridItem,
Form,
} from "@patternfly/react-core"; } from "@patternfly/react-core";
import { UseFormMethods, Controller } from "react-hook-form"; import { UseFormMethods, Controller } from "react-hook-form";
import { FormAccess } from "../../components/form-access/FormAccess";
type CapabilityConfigProps = { type CapabilityConfigProps = {
form: UseFormMethods; form: UseFormMethods;
}; };
@ -17,7 +18,7 @@ type CapabilityConfigProps = {
export const CapabilityConfig = ({ form }: CapabilityConfigProps) => { export const CapabilityConfig = ({ form }: CapabilityConfigProps) => {
const { t } = useTranslation("clients"); const { t } = useTranslation("clients");
return ( return (
<Form isHorizontal> <FormAccess isHorizontal role="manage-clients">
<FormGroup <FormGroup
hasNoPaddingTop hasNoPaddingTop
label={t("clientAuthentication")} label={t("clientAuthentication")}
@ -132,6 +133,6 @@ export const CapabilityConfig = ({ form }: CapabilityConfigProps) => {
</GridItem> </GridItem>
</Grid> </Grid>
</FormGroup> </FormGroup>
</Form> </FormAccess>
); );
}; };

View file

@ -0,0 +1,117 @@
import React, {
Children,
cloneElement,
isValidElement,
ReactElement,
} from "react";
import { Controller } from "react-hook-form";
import {
ActionGroup,
Form,
FormGroup,
FormProps,
Grid,
GridItem,
TextArea,
} from "@patternfly/react-core";
import { useAccess } from "../../context/access/Access";
import { AccessType } from "../../context/whoami/who-am-i-model";
export type FormAccessProps = FormProps & {
/**
* One of the AccessType's that the user needs to have to view this form. Also see {@link useAccess}.
* @type {AccessType}
*/
role: AccessType;
/**
* An override property if fine grained access has been setup for this form.
* @type {boolean}
*/
fineGrainedAccess?: boolean;
/**
* Set unWrap when you don't want this component to wrap your "children" in a {@link Form} component.
* @type {boolean}
*/
unWrap?: boolean;
children: ReactElement[];
};
/**
* Use this in place of a patternfly Form component and add the `role` and `fineGrainedAccess` properties.
* @param {FormAccessProps} param0 - all properties of Form + role and fineGrainedAccess
*/
export const FormAccess = ({
children,
role,
fineGrainedAccess = false,
unWrap = false,
...rest
}: FormAccessProps) => {
const { hasAccess } = useAccess();
const recursiveCloneChildren = (
children: ReactElement[],
newProps: any
): ReactElement[] => {
return Children.map(children, (child) => {
if (!isValidElement(child)) {
return child;
}
if (child.props) {
const element = child as ReactElement;
if (child.type === Controller) {
return cloneElement(child, {
...element.props,
render: (props: any) => {
const renderElement = element.props.render(props);
return cloneElement(renderElement, {
value: props.value,
onChange: props.onChange,
...newProps,
});
},
});
}
const children = recursiveCloneChildren(
element.props.children,
newProps
);
if (child.type === TextArea) {
return cloneElement(child, {
readOnly: newProps.isDisabled,
children,
} as any);
}
return cloneElement(
child,
child.type === FormGroup ||
child.type === GridItem ||
child.type === Grid ||
child.type === ActionGroup
? { children }
: { ...newProps, children }
);
}
return child;
});
};
return (
<>
{!unWrap && (
<Form {...rest}>
{recursiveCloneChildren(children, {
isDisabled: !hasAccess(role) && !fineGrainedAccess,
})}
</Form>
)}
{unWrap &&
recursiveCloneChildren(children, {
isDisabled: !hasAccess(role) && !fineGrainedAccess,
})}
</>
);
};

View file

@ -0,0 +1,62 @@
import React from "react";
import { mount } from "enzyme";
import { Controller, useForm } from "react-hook-form";
import { FormGroup, Switch, TextInput } from "@patternfly/react-core";
import { WhoAmI, WhoAmIContext } from "../../../context/whoami/WhoAmI";
import whoami from "../../../context/whoami/__tests__/mock-whoami.json";
import { RealmContext } from "../../../context/realm-context/RealmContext";
import { AccessContextProvider } from "../../../context/access/Access";
import { FormAccess } from "../FormAccess";
describe("<FormAccess />", () => {
const Form = ({ realm }: { realm: string }) => {
const { register, control } = useForm();
return (
<WhoAmIContext.Provider value={new WhoAmI("master", whoami)}>
<RealmContext.Provider value={{ realm, setRealm: () => {} }}>
<AccessContextProvider>
<FormAccess role="manage-clients">
<FormGroup label="test" fieldId="field">
<TextInput
type="text"
id="field"
name="fieldName"
ref={register()}
/>
</FormGroup>
<Controller
name="consentRequired"
defaultValue={false}
control={control}
render={({ onChange, value }) => (
<Switch
id="kc-consent"
label={"on"}
labelOff="off"
isChecked={value}
onChange={onChange}
/>
)}
/>
</FormAccess>
</AccessContextProvider>
</RealmContext.Provider>
</WhoAmIContext.Provider>
);
};
it("render normal form", () => {
const comp = mount(<Form realm="master" />);
expect(comp).toMatchSnapshot();
});
it("render form disabled for test realm", () => {
const container = mount(<Form realm="test" />);
const disabled = container.find("input#field").props().disabled;
expect(disabled).toBe(true);
expect(container.find("input#kc-consent").props().disabled).toBe(true);
});
});

View file

@ -0,0 +1,242 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<FormAccess /> render normal form 1`] = `
<Form
realm="master"
>
<AccessContextProvider>
<FormAccess
role="manage-clients"
>
<Form>
<form
className="pf-c-form"
noValidate={true}
>
<FormGroup
fieldId="field"
key=".0"
label="test"
>
<div
className="pf-c-form__group"
>
<div
className="pf-c-form__group-label"
>
<label
className="pf-c-form__label"
htmlFor="field"
>
<span
className="pf-c-form__label-text"
>
test
</span>
</label>
</div>
<div
className="pf-c-form__group-control"
>
<ForwardRef
id="field"
isDisabled={false}
key=".0"
name="fieldName"
type="text"
>
<TextInputBase
aria-label={null}
className=""
id="field"
innerRef={[Function]}
isDisabled={false}
isLeftTruncated={false}
isReadOnly={false}
isRequired={false}
name="fieldName"
onChange={[Function]}
type="text"
validated="default"
>
<input
aria-invalid={false}
aria-label={null}
className="pf-c-form-control"
disabled={false}
id="field"
name="fieldName"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
readOnly={false}
required={false}
type="text"
/>
</TextInputBase>
</ForwardRef>
</div>
</div>
</FormGroup>
<Controller
control={
Object {
"defaultValuesRef": Object {
"current": Object {},
},
"fieldArrayDefaultValuesRef": Object {
"current": Object {},
},
"fieldArrayNamesRef": Object {
"current": Set {},
},
"fieldsRef": Object {
"current": Object {
"consentRequired": Object {
"ref": Object {
"focus": undefined,
"name": "consentRequired",
},
},
"fieldName": Object {
"ref": <input
aria-invalid="false"
class="pf-c-form-control"
id="field"
name="fieldName"
type="text"
value=""
/>,
},
},
},
"fieldsWithValidationRef": Object {
"current": Object {},
},
"formStateRef": Object {
"current": Object {
"dirtyFields": Object {},
"errors": Object {},
"isDirty": false,
"isSubmitSuccessful": false,
"isSubmitted": false,
"isSubmitting": false,
"isValid": false,
"submitCount": 0,
"touched": Object {},
},
},
"getValues": [Function],
"isWatchAllRef": Object {
"current": false,
},
"mode": Object {
"isOnAll": false,
"isOnBlur": false,
"isOnChange": false,
"isOnSubmit": true,
"isOnTouch": false,
},
"reValidateMode": Object {
"isReValidateOnBlur": false,
"isReValidateOnChange": true,
},
"readFormStateRef": Object {
"current": Object {
"dirtyFields": false,
"isDirty": false,
"isSubmitting": false,
"isValid": false,
"touched": false,
},
},
"register": [Function],
"removeFieldEventListener": [Function],
"renderWatchedInputs": [Function],
"resetFieldArrayFunctionRef": Object {
"current": Object {},
},
"setValue": [Function],
"shallowFieldsStateRef": Object {
"current": Object {},
},
"shouldUnregister": true,
"trigger": [Function],
"unregister": [Function],
"updateFormState": [Function],
"useWatchFieldsRef": Object {
"current": Object {},
},
"useWatchRenderFunctionsRef": Object {
"current": Object {},
},
"validFieldsRef": Object {
"current": Object {},
},
"validateResolver": undefined,
"watchFieldsRef": Object {
"current": Set {},
},
"watchInternal": [Function],
}
}
defaultValue={false}
key=".1"
name="consentRequired"
render={[Function]}
>
<Switch
aria-label=""
id="kc-consent"
isChecked={false}
isDisabled={false}
label="on"
labelOff="off"
onChange={[Function]}
value={false}
>
<label
className="pf-c-switch"
data-ouia-component-id="OUIA-Generated-Switch-1"
data-ouia-component-type="PF4/Switch"
data-ouia-safe={true}
htmlFor="kc-consent"
>
<input
aria-label=""
aria-labelledby="kc-consent-on"
checked={false}
className="pf-c-switch__input"
disabled={false}
id="kc-consent"
onChange={[Function]}
type="checkbox"
value={false}
/>
<span
className="pf-c-switch__toggle"
/>
<span
aria-hidden="true"
className="pf-c-switch__label pf-m-on"
id="kc-consent-on"
>
on
</span>
<span
aria-hidden="true"
className="pf-c-switch__label pf-m-off"
id="kc-consent-off"
>
off
</span>
</label>
</Switch>
</Controller>
</form>
</Form>
</FormAccess>
</AccessContextProvider>
</Form>
`;

View file

@ -6,6 +6,7 @@ import {
SplitItem, SplitItem,
Button, Button,
ButtonVariant, ButtonVariant,
TextInputProps,
} from "@patternfly/react-core"; } from "@patternfly/react-core";
import { MinusIcon, PlusIcon } from "@patternfly/react-icons"; import { MinusIcon, PlusIcon } from "@patternfly/react-icons";
@ -23,12 +24,16 @@ export function toValue(formValue: MultiLine[]): string[] {
return formValue.map((field) => field.value); return formValue.map((field) => field.value);
} }
export type MultiLineInputProps = { export type MultiLineInputProps = Omit<TextInputProps, "form"> & {
form: UseFormMethods; form: UseFormMethods;
name: string; name: string;
}; };
export const MultiLineInput = ({ name, form }: MultiLineInputProps) => { export const MultiLineInput = ({
name,
form,
...rest
}: MultiLineInputProps) => {
const { register, control } = form; const { register, control } = form;
const { fields, append, remove } = useFieldArray({ const { fields, append, remove } = useFieldArray({
name, name,
@ -49,6 +54,7 @@ export const MultiLineInput = ({ name, form }: MultiLineInputProps) => {
ref={register()} ref={register()}
name={`${name}[${index}].value`} name={`${name}[${index}].value`}
defaultValue={value} defaultValue={value}
{...rest}
/> />
</SplitItem> </SplitItem>
<SplitItem> <SplitItem>
@ -57,6 +63,7 @@ export const MultiLineInput = ({ name, form }: MultiLineInputProps) => {
variant={ButtonVariant.link} variant={ButtonVariant.link}
onClick={() => append({})} onClick={() => append({})}
tabIndex={-1} tabIndex={-1}
isDisabled={rest.isDisabled}
> >
<PlusIcon /> <PlusIcon />
</Button> </Button>

View file

@ -20,7 +20,7 @@ test("can not create realm", () => {
test("getRealmAccess", () => { test("getRealmAccess", () => {
const whoami = new WhoAmI("master", whoamiMock); const whoami = new WhoAmI("master", whoamiMock);
expect(Object.keys(whoami.getRealmAccess()).length).toEqual(2); expect(Object.keys(whoami.getRealmAccess()).length).toEqual(3);
expect(whoami.getRealmAccess()["master"].length).toEqual(18); expect(whoami.getRealmAccess()["master"].length).toEqual(18);
}); });

View file

@ -5,6 +5,10 @@
"locale": "en", "locale": "en",
"createRealm": false, "createRealm": false,
"realm_access": { "realm_access": {
"test": [
"query-clients",
"view-clients"
],
"aaa": [ "aaa": [
"view-identity-providers", "view-identity-providers",
"view-realm", "view-realm",

View file

@ -37,18 +37,18 @@ export const routes: RoutesFn = (t: TFunction) => [
breadcrumb: t("realm:createRealm"), breadcrumb: t("realm:createRealm"),
access: "manage-realm", access: "manage-realm",
}, },
{
path: "/clients",
component: ClientsSection,
breadcrumb: t("clients:clientList"),
access: "query-clients",
},
{ {
path: "/clients/:id", path: "/clients/:id",
component: ClientSettings, component: ClientSettings,
breadcrumb: t("clients:clientSettings"), breadcrumb: t("clients:clientSettings"),
access: "view-clients", access: "view-clients",
}, },
{
path: "/clients",
component: ClientsSection,
breadcrumb: t("clients:clientList"),
access: "query-clients",
},
{ {
path: "/add-client", path: "/add-client",
component: NewClientForm, component: NewClientForm,
@ -61,12 +61,6 @@ export const routes: RoutesFn = (t: TFunction) => [
breadcrumb: t("clients:importClient"), breadcrumb: t("clients:importClient"),
access: "manage-clients", access: "manage-clients",
}, },
{
path: "/client-scopes",
component: ClientScopesSection,
breadcrumb: t("client-scopes:clientScopeList"),
access: "view-clients",
},
{ {
path: "/client-scopes/new", path: "/client-scopes/new",
component: ClientScopeForm, component: ClientScopeForm,
@ -91,6 +85,12 @@ export const routes: RoutesFn = (t: TFunction) => [
breadcrumb: t("client-scopes:clientScopeDetails"), breadcrumb: t("client-scopes:clientScopeDetails"),
access: "view-clients", access: "view-clients",
}, },
{
path: "/client-scopes",
component: ClientScopesSection,
breadcrumb: t("client-scopes:clientScopeList"),
access: "view-clients",
},
{ {
path: "/roles", path: "/roles",
component: RealmRolesSection, component: RealmRolesSection,

View file

@ -13365,6 +13365,11 @@ multicast-dns@^6.0.1:
dns-packet "^1.3.1" dns-packet "^1.3.1"
thunky "^1.0.2" thunky "^1.0.2"
mutationobserver-shim@^0.3.7:
version "0.3.7"
resolved "https://registry.yarnpkg.com/mutationobserver-shim/-/mutationobserver-shim-0.3.7.tgz#8bf633b0c0b0291a1107255ed32c13088a8c5bf3"
integrity sha512-oRIDTyZQU96nAiz2AQyngwx1e89iApl2hN5AOYwyxLUB47UYsU3Wv9lJWqH5y/QdiYkc5HQLi23ZNB3fELdHcQ==
mute-stream@0.0.8: mute-stream@0.0.8:
version "0.0.8" version "0.0.8"
resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.8.tgz#1630c42b2251ff81e2a283de96a5497ea92e5e0d" resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.8.tgz#1630c42b2251ff81e2a283de96a5497ea92e5e0d"
@ -15237,7 +15242,7 @@ prepend-http@^1.0.0:
resolved "https://registry.yarnpkg.com/prepend-http/-/prepend-http-1.0.4.tgz#d4f4562b0ce3696e41ac52d0e002e57a635dc6dc" resolved "https://registry.yarnpkg.com/prepend-http/-/prepend-http-1.0.4.tgz#d4f4562b0ce3696e41ac52d0e002e57a635dc6dc"
integrity sha1-1PRWKwzjaW5BrFLQ4ALlemNdxtw= integrity sha1-1PRWKwzjaW5BrFLQ4ALlemNdxtw=
prettier@2.1.2: prettier@^2.0.5:
version "2.1.2" version "2.1.2"
resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.1.2.tgz#3050700dae2e4c8b67c4c3f666cdb8af405e1ce5" resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.1.2.tgz#3050700dae2e4c8b67c4c3f666cdb8af405e1ce5"
integrity sha512-16c7K+x4qVlJg9rEbXl7HEGmQyZlG4R9AgP+oHKRMsMsuk8s+ATStlf1NpDqyBI1HpVyfjLOeMhH2LvuNvV5Vg== integrity sha512-16c7K+x4qVlJg9rEbXl7HEGmQyZlG4R9AgP+oHKRMsMsuk8s+ATStlf1NpDqyBI1HpVyfjLOeMhH2LvuNvV5Vg==