From 254389337321f8bc24da20f3d8c4aa89013a7d74 Mon Sep 17 00:00:00 2001 From: Erik Jan de Wit Date: Wed, 28 Oct 2020 19:17:15 +0100 Subject: [PATCH] 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 * Update src/components/form-access/FormAccess.tsx Co-authored-by: Stan Silvert * Update src/components/form-access/FormAccess.tsx Co-authored-by: Stan Silvert * added more form access and logic for Controller * render switches for boolean types * better render of ` --- jest.setup.js | 5 + package.json | 1 + src/PageNav.tsx | 1 + src/clients/ClientDescription.tsx | 5 +- src/clients/ClientSettings.tsx | 9 +- src/clients/add/CapabilityConfig.tsx | 7 +- src/components/form-access/FormAccess.tsx | 117 +++++++++ .../form-access/__tests__/FormAccess.test.tsx | 62 +++++ .../__snapshots__/FormAccess.test.tsx.snap | 242 ++++++++++++++++++ .../multi-line-input/MultiLineInput.tsx | 11 +- src/context/whoami/__tests__/WhoAmI.test.tsx | 2 +- src/context/whoami/__tests__/mock-whoami.json | 4 + src/route-config.ts | 24 +- yarn.lock | 7 +- 14 files changed, 472 insertions(+), 25 deletions(-) create mode 100644 src/components/form-access/FormAccess.tsx create mode 100644 src/components/form-access/__tests__/FormAccess.test.tsx create mode 100644 src/components/form-access/__tests__/__snapshots__/FormAccess.test.tsx.snap diff --git a/jest.setup.js b/jest.setup.js index fe599c963c..0e27054010 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -7,6 +7,8 @@ import "@testing-library/jest-dom/extend-expect"; import i18n from 'i18next'; import { initReactI18next } from 'react-i18next'; +import 'mutationobserver-shim'; + i18n.use(initReactI18next).init({ lng: 'en', fallbackLng: 'en', @@ -22,3 +24,6 @@ import { configure } from 'enzyme'; import Adapter from 'enzyme-adapter-react-16'; configure({ adapter: new Adapter() }); + +// eslint-disable-next-line no-undef +global.MutationObserver = window.MutationObserver; \ No newline at end of file diff --git a/package.json b/package.json index 1bf07e565b..8f6471bb25 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "grunt": "^1.2.1", "grunt-contrib-copy": "^1.0.0", "jest": "^25.4.0", + "mutationobserver-shim": "^0.3.7", "postcss": "^7.0.32", "postcss-cli": "^7.1.1", "postcss-import": "^12.0.1", diff --git a/src/PageNav.tsx b/src/PageNav.tsx index 56f9c5b16b..954def4738 100644 --- a/src/PageNav.tsx +++ b/src/PageNav.tsx @@ -67,6 +67,7 @@ export const PageNav: React.FunctionComponent = () => { "view-realm", "query-groups", "query-users", + "query-clients", "view-events" ); diff --git a/src/clients/ClientDescription.tsx b/src/clients/ClientDescription.tsx index 438ddbe607..f3c5ba9e79 100644 --- a/src/clients/ClientDescription.tsx +++ b/src/clients/ClientDescription.tsx @@ -2,6 +2,7 @@ import React from "react"; import { FormGroup, TextInput } from "@patternfly/react-core"; import { UseFormMethods } from "react-hook-form"; import { useTranslation } from "react-i18next"; +import { FormAccess } from "../components/form-access/FormAccess"; type ClientDescriptionProps = { form: UseFormMethods; @@ -11,7 +12,7 @@ export const ClientDescription = ({ form }: ClientDescriptionProps) => { const { t } = useTranslation("clients"); const { register, errors } = form; return ( - <> + { name="description" /> - + ); }; diff --git a/src/clients/ClientSettings.tsx b/src/clients/ClientSettings.tsx index 4849889a39..3d02a16825 100644 --- a/src/clients/ClientSettings.tsx +++ b/src/clients/ClientSettings.tsx @@ -32,6 +32,7 @@ import { ViewHeader } from "../components/view-header/ViewHeader"; import { exportClient } from "../util"; import { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog"; import { useDownloadDialog } from "../components/download-dialog/DownloadDialog"; +import { FormAccess } from "../components/form-access/FormAccess"; export const ClientSettings = () => { const { t } = useTranslation("clients"); @@ -163,7 +164,7 @@ export const ClientSettings = () => {
-
+ { ref={form.register} /> - -
+ + { - +
diff --git a/src/clients/add/CapabilityConfig.tsx b/src/clients/add/CapabilityConfig.tsx index 6013b76616..98299aa469 100644 --- a/src/clients/add/CapabilityConfig.tsx +++ b/src/clients/add/CapabilityConfig.tsx @@ -6,10 +6,11 @@ import { Checkbox, Grid, GridItem, - Form, } from "@patternfly/react-core"; import { UseFormMethods, Controller } from "react-hook-form"; +import { FormAccess } from "../../components/form-access/FormAccess"; + type CapabilityConfigProps = { form: UseFormMethods; }; @@ -17,7 +18,7 @@ type CapabilityConfigProps = { export const CapabilityConfig = ({ form }: CapabilityConfigProps) => { const { t } = useTranslation("clients"); return ( -
+ { - +
); }; diff --git a/src/components/form-access/FormAccess.tsx b/src/components/form-access/FormAccess.tsx new file mode 100644 index 0000000000..3f143df292 --- /dev/null +++ b/src/components/form-access/FormAccess.tsx @@ -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 && ( +
+ {recursiveCloneChildren(children, { + isDisabled: !hasAccess(role) && !fineGrainedAccess, + })} +
+ )} + {unWrap && + recursiveCloneChildren(children, { + isDisabled: !hasAccess(role) && !fineGrainedAccess, + })} + + ); +}; diff --git a/src/components/form-access/__tests__/FormAccess.test.tsx b/src/components/form-access/__tests__/FormAccess.test.tsx new file mode 100644 index 0000000000..9117350c86 --- /dev/null +++ b/src/components/form-access/__tests__/FormAccess.test.tsx @@ -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("", () => { + const Form = ({ realm }: { realm: string }) => { + const { register, control } = useForm(); + return ( + + {} }}> + + + + + + ( + + )} + /> + + + + + ); + }; + it("render normal form", () => { + const comp = mount(
); + expect(comp).toMatchSnapshot(); + }); + + it("render form disabled for test realm", () => { + const container = mount(); + + const disabled = container.find("input#field").props().disabled; + expect(disabled).toBe(true); + + expect(container.find("input#kc-consent").props().disabled).toBe(true); + }); +}); diff --git a/src/components/form-access/__tests__/__snapshots__/FormAccess.test.tsx.snap b/src/components/form-access/__tests__/__snapshots__/FormAccess.test.tsx.snap new file mode 100644 index 0000000000..98b7ba0eb7 --- /dev/null +++ b/src/components/form-access/__tests__/__snapshots__/FormAccess.test.tsx.snap @@ -0,0 +1,242 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` render normal form 1`] = ` + + + + + + +
+
+ + +
+
+ + + + + +
+
+
+ , + }, + }, + }, + "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]} + > + + + + + + +
+
+ +`; diff --git a/src/components/multi-line-input/MultiLineInput.tsx b/src/components/multi-line-input/MultiLineInput.tsx index 0da4160b30..a901a7bfa9 100644 --- a/src/components/multi-line-input/MultiLineInput.tsx +++ b/src/components/multi-line-input/MultiLineInput.tsx @@ -6,6 +6,7 @@ import { SplitItem, Button, ButtonVariant, + TextInputProps, } from "@patternfly/react-core"; import { MinusIcon, PlusIcon } from "@patternfly/react-icons"; @@ -23,12 +24,16 @@ export function toValue(formValue: MultiLine[]): string[] { return formValue.map((field) => field.value); } -export type MultiLineInputProps = { +export type MultiLineInputProps = Omit & { form: UseFormMethods; name: string; }; -export const MultiLineInput = ({ name, form }: MultiLineInputProps) => { +export const MultiLineInput = ({ + name, + form, + ...rest +}: MultiLineInputProps) => { const { register, control } = form; const { fields, append, remove } = useFieldArray({ name, @@ -49,6 +54,7 @@ export const MultiLineInput = ({ name, form }: MultiLineInputProps) => { ref={register()} name={`${name}[${index}].value`} defaultValue={value} + {...rest} /> @@ -57,6 +63,7 @@ export const MultiLineInput = ({ name, form }: MultiLineInputProps) => { variant={ButtonVariant.link} onClick={() => append({})} tabIndex={-1} + isDisabled={rest.isDisabled} > diff --git a/src/context/whoami/__tests__/WhoAmI.test.tsx b/src/context/whoami/__tests__/WhoAmI.test.tsx index 19b3cf7f40..e1e9ff9ae2 100644 --- a/src/context/whoami/__tests__/WhoAmI.test.tsx +++ b/src/context/whoami/__tests__/WhoAmI.test.tsx @@ -20,7 +20,7 @@ test("can not create realm", () => { test("getRealmAccess", () => { 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); }); diff --git a/src/context/whoami/__tests__/mock-whoami.json b/src/context/whoami/__tests__/mock-whoami.json index be3778279c..d65e8809d3 100644 --- a/src/context/whoami/__tests__/mock-whoami.json +++ b/src/context/whoami/__tests__/mock-whoami.json @@ -5,6 +5,10 @@ "locale": "en", "createRealm": false, "realm_access": { + "test": [ + "query-clients", + "view-clients" + ], "aaa": [ "view-identity-providers", "view-realm", diff --git a/src/route-config.ts b/src/route-config.ts index 005f12906e..1022979c5f 100644 --- a/src/route-config.ts +++ b/src/route-config.ts @@ -37,18 +37,18 @@ export const routes: RoutesFn = (t: TFunction) => [ breadcrumb: t("realm:createRealm"), access: "manage-realm", }, - { - path: "/clients", - component: ClientsSection, - breadcrumb: t("clients:clientList"), - access: "query-clients", - }, { path: "/clients/:id", component: ClientSettings, breadcrumb: t("clients:clientSettings"), access: "view-clients", }, + { + path: "/clients", + component: ClientsSection, + breadcrumb: t("clients:clientList"), + access: "query-clients", + }, { path: "/add-client", component: NewClientForm, @@ -61,12 +61,6 @@ export const routes: RoutesFn = (t: TFunction) => [ breadcrumb: t("clients:importClient"), access: "manage-clients", }, - { - path: "/client-scopes", - component: ClientScopesSection, - breadcrumb: t("client-scopes:clientScopeList"), - access: "view-clients", - }, { path: "/client-scopes/new", component: ClientScopeForm, @@ -91,6 +85,12 @@ export const routes: RoutesFn = (t: TFunction) => [ breadcrumb: t("client-scopes:clientScopeDetails"), access: "view-clients", }, + { + path: "/client-scopes", + component: ClientScopesSection, + breadcrumb: t("client-scopes:clientScopeList"), + access: "view-clients", + }, { path: "/roles", component: RealmRolesSection, diff --git a/yarn.lock b/yarn.lock index 32a4142a96..3e0cdf1302 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13365,6 +13365,11 @@ multicast-dns@^6.0.1: dns-packet "^1.3.1" 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: version "0.0.8" 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" integrity sha1-1PRWKwzjaW5BrFLQ4ALlemNdxtw= -prettier@2.1.2: +prettier@^2.0.5: version "2.1.2" resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.1.2.tgz#3050700dae2e4c8b67c4c3f666cdb8af405e1ce5" integrity sha512-16c7K+x4qVlJg9rEbXl7HEGmQyZlG4R9AgP+oHKRMsMsuk8s+ATStlf1NpDqyBI1HpVyfjLOeMhH2LvuNvV5Vg==