Split out the key based into a sperate component (#2177)

This commit is contained in:
Erik Jan de Wit 2022-03-16 10:39:58 +01:00 committed by GitHub
parent d45e53f350
commit 8d5b2f903a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 303 additions and 204 deletions

View file

@ -29,7 +29,7 @@ import type RoleRepresentation from "@keycloak/keycloak-admin-client/lib/defs/ro
import { useAdminClient, useFetch } from "../../context/auth/AdminClient";
import type ResourceEvaluation from "@keycloak/keycloak-admin-client/lib/defs/resourceEvaluation";
import { useRealm } from "../../context/realm-context/RealmContext";
import { AttributeInput } from "../../components/attribute-input/AttributeInput";
import { KeyBasedAttributeInput } from "./KeyBasedAttributeInput";
import { defaultContextAttributes } from "../utils";
import type EvaluationResultRepresentation from "@keycloak/keycloak-admin-client/lib/defs/evaluationResultRepresentation";
import type ResourceRepresentation from "@keycloak/keycloak-admin-client/lib/defs/resourceRepresentation";
@ -518,13 +518,12 @@ export const AuthorizationEvaluate = ({ client }: Props) => {
helperTextInvalid={t("common:required")}
fieldId="resourcesAndAuthScopes"
>
<AttributeInput
<KeyBasedAttributeInput
selectableValues={resources.map<AttributeType>((item) => ({
name: item.name!,
key: item._id!,
}))}
resources={resources}
isKeySelectable
name="resources"
/>
</FormGroup>
@ -613,9 +612,8 @@ export const AuthorizationEvaluate = ({ client }: Props) => {
helperTextInvalid={t("common:required")}
fieldId="contextualAttributes"
>
<AttributeInput
<KeyBasedAttributeInput
selectableValues={defaultContextAttributes}
isKeySelectable
name="context.attributes"
/>
</FormGroup>

View file

@ -0,0 +1,271 @@
import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Controller, useFieldArray, useFormContext } from "react-hook-form";
import {
Button,
Select,
SelectOption,
SelectVariant,
TextInput,
} from "@patternfly/react-core";
import {
TableComposable,
Tbody,
Td,
Th,
Thead,
Tr,
} from "@patternfly/react-table";
import { MinusCircleIcon, PlusCircleIcon } from "@patternfly/react-icons";
import type ResourceRepresentation from "@keycloak/keycloak-admin-client/lib/defs/resourceRepresentation";
import { defaultContextAttributes } from "../utils";
import { camelCase } from "lodash-es";
import "../../components/attribute-form/attribute-form.css";
export type AttributeType = {
key?: string;
name: string;
custom?: boolean;
values?: {
[key: string]: string;
}[];
};
type AttributeInputProps = {
name: string;
selectableValues?: AttributeType[];
resources?: ResourceRepresentation[];
};
type ValueInputProps = {
name: string;
rowIndex: number;
attribute: any;
selectableValues?: AttributeType[];
resources?: ResourceRepresentation[];
};
const ValueInput = ({
name,
rowIndex,
attribute,
selectableValues,
resources,
}: ValueInputProps) => {
const { t } = useTranslation("common");
const { control, register, getValues } = useFormContext();
const [isValueOpenArray, setIsValueOpenArray] = useState([false]);
const toggleValueSelect = (rowIndex: number, open: boolean) => {
const arr = [...isValueOpenArray];
arr[rowIndex] = open;
setIsValueOpenArray(arr);
};
let attributeValues: { key: string; name: string }[] | undefined = [];
const scopeValues = resources?.find(
(resource) => resource.name === getValues().resources[rowIndex]?.key
)?.scopes;
if (selectableValues) {
attributeValues = defaultContextAttributes.find(
(attr) => attr.key === getValues().context[rowIndex]?.key
)?.values;
}
const renderSelectOptionType = () => {
if (attributeValues?.length && !resources) {
return attributeValues.map((attr) => (
<SelectOption key={attr.key} value={attr.key}>
{attr.name}
</SelectOption>
));
} else if (scopeValues?.length) {
return scopeValues.map((scope) => (
<SelectOption key={scope.name} value={scope.name}>
{scope.name}
</SelectOption>
));
}
};
const getMessageBundleKey = (attributeName: string) =>
camelCase(attributeName).replace(/\W/g, "");
return (
<Td>
{resources || attributeValues?.length ? (
<Controller
name={`${name}[${rowIndex}].value`}
defaultValue={[]}
control={control}
render={({ onChange, value }) => (
<Select
id={`${attribute.id}-value`}
className="kc-attribute-value-selectable"
name={`${name}[${rowIndex}].value`}
chipGroupProps={{
numChips: 1,
expandedText: t("common:hide"),
collapsedText: t("common:showRemaining"),
}}
toggleId={`group-${name}`}
onToggle={(open) => toggleValueSelect(rowIndex, open)}
isOpen={isValueOpenArray[rowIndex]}
variant={SelectVariant.typeahead}
typeAheadAriaLabel={t("clients:selectOrTypeAKey")}
placeholderText={t("clients:selectOrTypeAKey")}
selections={value}
onSelect={(_, v) => {
onChange(v);
toggleValueSelect(rowIndex, false);
}}
>
{renderSelectOptionType()}
</Select>
)}
/>
) : (
<TextInput
id={`${getMessageBundleKey(attribute.key)}-value`}
className="value-input"
name={`${name}[${rowIndex}].value`}
ref={register()}
defaultValue={attribute.value}
data-testid="attribute-value-input"
/>
)}
</Td>
);
};
export const KeyBasedAttributeInput = ({
name,
selectableValues,
resources,
}: AttributeInputProps) => {
const { t } = useTranslation("common");
const { control, watch } = useFormContext();
const { fields, append, remove } = useFieldArray({
control: control,
name,
});
const [isKeyOpenArray, setIsKeyOpenArray] = useState([false]);
const toggleKeySelect = (rowIndex: number, open: boolean) => {
const arr = [...isKeyOpenArray];
arr[rowIndex] = open;
setIsKeyOpenArray(arr);
};
useEffect(() => {
if (!fields.length) {
append({ key: "", value: "" });
}
}, [fields]);
const watchLastValue = watch(`${name}[${fields.length - 1}].value`, "");
return (
<TableComposable
className="kc-attributes__table"
aria-label="Role attribute keys and values"
variant="compact"
borders={false}
>
<Thead>
<Tr>
<Th id="key" width={40}>
{t("key")}
</Th>
<Th id="value" width={40}>
{t("value")}
</Th>
</Tr>
</Thead>
<Tbody>
{fields.map((attribute, rowIndex) => (
<Tr key={attribute.id} data-testid="attribute-row">
<Td>
<Controller
name={`${name}[${rowIndex}].key`}
defaultValue={attribute.key}
control={control}
render={({ onChange, value }) => (
<Select
id={`${name}[${rowIndex}].key`}
className="kc-attribute-key-selectable"
name={`${name}[${rowIndex}].key`}
toggleId={`group-${name}`}
onToggle={(open) => toggleKeySelect(rowIndex, open)}
isOpen={isKeyOpenArray[rowIndex]}
variant={SelectVariant.typeahead}
typeAheadAriaLabel={t("clients:selectOrTypeAKey")}
placeholderText={t("clients:selectOrTypeAKey")}
selections={value}
onSelect={(_, v) => {
onChange(v.toString());
toggleKeySelect(rowIndex, false);
}}
>
{selectableValues?.map((attribute) => (
<SelectOption
selected={attribute.name === value}
key={attribute.key}
value={resources ? attribute.name : attribute.key}
>
{attribute.name}
</SelectOption>
))}
</Select>
)}
/>
</Td>
<ValueInput
name={name}
attribute={attribute}
rowIndex={rowIndex}
selectableValues={selectableValues}
resources={resources}
/>
<Td key="minus-button" id={`kc-minus-button-${rowIndex}`}>
<Button
id={`minus-button-${rowIndex}`}
variant="link"
className="kc-attributes__minus-icon"
onClick={() => remove(rowIndex)}
>
<MinusCircleIcon />
</Button>
</Td>
</Tr>
))}
<Tr>
<Td>
<Button
aria-label={t("roles:addAttributeText")}
id="plus-icon"
variant="link"
className="kc-attributes__plus-icon"
onClick={() => {
append({ key: "", value: "" });
setIsKeyOpenArray([...isKeyOpenArray, false]);
}}
icon={<PlusCircleIcon />}
isDisabled={!watchLastValue}
data-testid="attribute-add-row"
>
{t("roles:addAttributeText")}
</Button>
</Td>
</Tr>
</Tbody>
</TableComposable>
);
};

View file

@ -5,10 +5,7 @@ import { ActionGroup, Button } from "@patternfly/react-core";
import type { RoleRepresentation } from "../../model/role-model";
import type { KeyValueType } from "./attribute-convert";
import {
AttributeInput,
AttributeType,
} from "../attribute-input/AttributeInput";
import { AttributeInput } from "../attribute-input/AttributeInput";
import { FormAccess } from "../form-access/FormAccess";
export type AttributeForm = Omit<RoleRepresentation, "attributes"> & {
@ -17,19 +14,11 @@ export type AttributeForm = Omit<RoleRepresentation, "attributes"> & {
export type AttributesFormProps = {
form: UseFormMethods<AttributeForm>;
isKeySelectable?: boolean;
selectableValues?: AttributeType[];
save?: (model: AttributeForm) => void;
reset?: () => void;
};
export const AttributesForm = ({
form,
reset,
save,
isKeySelectable,
selectableValues,
}: AttributesFormProps) => {
export const AttributesForm = ({ form, reset, save }: AttributesFormProps) => {
const { t } = useTranslation("roles");
const noSaveCancelButtons = !save && !reset;
const {
@ -43,11 +32,7 @@ export const AttributesForm = ({
onSubmit={save ? handleSubmit(save) : undefined}
>
<FormProvider {...form}>
<AttributeInput
isKeySelectable={isKeySelectable}
selectableValues={selectableValues}
name="attributes"
/>
<AttributeInput name="attributes" />
</FormProvider>
{!noSaveCancelButtons && (
<ActionGroup className="kc-attributes__action-group">

View file

@ -1,13 +1,7 @@
import React, { useEffect, useState } from "react";
import React, { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Controller, useFieldArray, useFormContext } from "react-hook-form";
import {
Button,
Select,
SelectOption,
SelectVariant,
TextInput,
} from "@patternfly/react-core";
import { useFieldArray, useFormContext } from "react-hook-form";
import { Button, TextInput } from "@patternfly/react-core";
import {
TableComposable,
Tbody,
@ -19,142 +13,27 @@ import {
import { MinusCircleIcon, PlusCircleIcon } from "@patternfly/react-icons";
import "../attribute-form/attribute-form.css";
import { defaultContextAttributes } from "../../clients/utils";
import { camelCase } from "lodash-es";
import type ResourceRepresentation from "@keycloak/keycloak-admin-client/lib/defs/resourceRepresentation";
export type AttributeType = {
key?: string;
name: string;
custom?: boolean;
values?: {
[key: string]: string;
}[];
};
type AttributeInputProps = {
name: string;
selectableValues?: AttributeType[];
isKeySelectable?: boolean;
resources?: ResourceRepresentation[];
};
export const AttributeInput = ({
name,
isKeySelectable,
selectableValues,
resources,
}: AttributeInputProps) => {
export const AttributeInput = ({ name }: AttributeInputProps) => {
const { t } = useTranslation("common");
const { control, register, watch, getValues } = useFormContext();
const { control, register, watch } = useFormContext();
const { fields, append, remove } = useFieldArray({
control: control,
name,
});
const watchLast = watch(`${name}[${fields.length - 1}].key`, "");
useEffect(() => {
if (!fields.length) {
append({ key: "", value: "" });
}
}, [fields]);
const [isKeyOpenArray, setIsKeyOpenArray] = useState([false]);
const watchLastKey = watch(`${name}[${fields.length - 1}].key`, "");
const watchLastValue = watch(`${name}[${fields.length - 1}].value`, "");
const [isValueOpenArray, setIsValueOpenArray] = useState([false]);
const toggleKeySelect = (rowIndex: number, open: boolean) => {
const arr = [...isKeyOpenArray];
arr[rowIndex] = open;
setIsKeyOpenArray(arr);
};
const toggleValueSelect = (rowIndex: number, open: boolean) => {
const arr = [...isValueOpenArray];
arr[rowIndex] = open;
setIsValueOpenArray(arr);
};
const renderValueInput = (rowIndex: number, attribute: any) => {
let attributeValues: { key: string; name: string }[] | undefined = [];
const scopeValues = resources?.find(
(resource) => resource.name === getValues().resources[rowIndex]?.key
)?.scopes;
if (selectableValues) {
attributeValues = defaultContextAttributes.find(
(attr) => attr.key === getValues().context[rowIndex]?.key
)?.values;
}
const renderSelectOptionType = () => {
if (attributeValues?.length && !resources) {
return attributeValues.map((attr) => (
<SelectOption key={attr.key} value={attr.key}>
{attr.name}
</SelectOption>
));
} else if (scopeValues?.length) {
return scopeValues.map((scope) => (
<SelectOption key={scope.name} value={scope.name}>
{scope.name}
</SelectOption>
));
}
};
const getMessageBundleKey = (attributeName: string) =>
camelCase(attributeName).replace(/\W/g, "");
return (
<Td>
{resources || attributeValues?.length ? (
<Controller
name={`${name}[${rowIndex}].value`}
defaultValue={[]}
control={control}
render={({ onChange, value }) => (
<Select
id={`${attribute.id}-value`}
className="kc-attribute-value-selectable"
name={`${name}[${rowIndex}].value`}
chipGroupProps={{
numChips: 1,
expandedText: t("common:hide"),
collapsedText: t("common:showRemaining"),
}}
toggleId={`group-${name}`}
onToggle={(open) => toggleValueSelect(rowIndex, open)}
isOpen={isValueOpenArray[rowIndex]}
variant={SelectVariant.typeahead}
typeAheadAriaLabel={t("clients:selectOrTypeAKey")}
placeholderText={t("clients:selectOrTypeAKey")}
selections={value}
onSelect={(_, v) => {
onChange(v);
toggleValueSelect(rowIndex, false);
}}
>
{renderSelectOptionType()}
</Select>
)}
/>
) : (
<TextInput
id={`${getMessageBundleKey(attribute.key)}-value`}
className="value-input"
name={`${name}[${rowIndex}].value`}
ref={register()}
defaultValue={attribute.value}
data-testid="attribute-value-input"
/>
)}
</Td>
);
};
return (
<TableComposable
className="kc-attributes__table"
@ -176,52 +55,23 @@ export const AttributeInput = ({
{fields.map((attribute, rowIndex) => (
<Tr key={attribute.id} data-testid="attribute-row">
<Td>
{isKeySelectable ? (
<Controller
name={`${name}[${rowIndex}].key`}
defaultValue={attribute.key}
control={control}
render={({ onChange, value }) => (
<Select
id={`${name}[${rowIndex}].key`}
className="kc-attribute-key-selectable"
name={`${name}[${rowIndex}].key`}
toggleId={`group-${name}`}
onToggle={(open) => toggleKeySelect(rowIndex, open)}
isOpen={isKeyOpenArray[rowIndex]}
variant={SelectVariant.typeahead}
typeAheadAriaLabel={t("clients:selectOrTypeAKey")}
placeholderText={t("clients:selectOrTypeAKey")}
selections={value}
onSelect={(_, v) => {
onChange(v.toString());
toggleKeySelect(rowIndex, false);
}}
>
{selectableValues?.map((attribute) => (
<SelectOption
selected={attribute.name === value}
key={attribute.key}
value={resources ? attribute.name : attribute.key}
>
{attribute.name}
</SelectOption>
))}
</Select>
)}
/>
) : (
<TextInput
id={`${attribute.id}-key`}
name={`${name}[${rowIndex}].key`}
ref={register()}
defaultValue={attribute.key}
data-testid="attribute-key-input"
/>
)}
<TextInput
id={`${attribute.id}-key`}
name={`${name}[${rowIndex}].key`}
ref={register()}
defaultValue={attribute.key}
data-testid="attribute-key-input"
/>
</Td>
<Td>
<TextInput
id={`${attribute.id}-value`}
name={`${name}[${rowIndex}].value`}
ref={register()}
defaultValue={attribute.value}
data-testid="attribute-value-input"
/>
</Td>
{renderValueInput(rowIndex, attribute)}
<Td key="minus-button" id={`kc-minus-button-${rowIndex}`}>
<Button
id={`minus-button-${rowIndex}`}
@ -241,14 +91,9 @@ export const AttributeInput = ({
id="plus-icon"
variant="link"
className="kc-attributes__plus-icon"
onClick={() => {
append({ key: "", value: "" });
if (isKeySelectable) {
setIsKeyOpenArray([...isKeyOpenArray, false]);
}
}}
onClick={() => append({ key: "", value: "" })}
icon={<PlusCircleIcon />}
isDisabled={isKeySelectable ? !watchLastValue : !watchLastKey}
isDisabled={!watchLast}
data-testid="attribute-add-row"
>
{t("roles:addAttributeText")}

View file

@ -182,7 +182,7 @@ export default function RealmRoleTabs() {
const [toggleDeleteDialog, DeleteConfirm] = useConfirmDialog({
titleKey: "roles:roleDeleteConfirm",
messageKey: t("roles:roleDeleteConfirmDialog", {
selectedRoleName: role?.name || t("createRole"),
name: role?.name || t("createRole"),
}),
continueButtonLabel: "common:delete",
continueButtonVariant: ButtonVariant.danger,