Changes from Realm Roles UX Review [List] (#433)

* realm roles UX review progress wip

* filter realm roles on Enter key press, add filter functionality

* remove chip group filters

* clean up

* format

* filterChips logic now in table toolbar

* fix lint and format

* save with erik

* remove filter chips functionality

* fix check-types

* fix realm roles cypress test

* format

* revert changes to group attributes

* cypress test

* use filter

* remove log

* remove unused prop
This commit is contained in:
Eugenia 2021-03-22 04:14:24 -04:00 committed by GitHub
parent 6c399c1484
commit bf4cae6735
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 294 additions and 70 deletions

View file

@ -73,8 +73,6 @@ describe("Realm roles test", function () {
masthead.checkNotificationMessage("Role created"); masthead.checkNotificationMessage("Role created");
cy.wait(100);
// Add associated realm role // Add associated realm role
associatedRolesPage.addAssociatedRealmRole(); associatedRolesPage.addAssociatedRealmRole();

View file

@ -29,7 +29,7 @@ export default class AssociatedRolesPage {
cy.wait(100); cy.wait(100);
cy.get(this.checkbox).eq(1).check(); cy.get(this.checkbox).eq(2).check();
cy.get(this.addAssociatedRolesModalButton).contains("Add").click(); cy.get(this.addAssociatedRolesModalButton).contains("Add").click();
@ -53,7 +53,7 @@ export default class AssociatedRolesPage {
cy.wait(2500); cy.wait(2500);
cy.get(this.checkbox).eq(40).check({ force: true }); cy.get(this.checkbox).eq(12).check({ force: true });
cy.get(this.addAssociatedRolesModalButton).contains("Add").click(); cy.get(this.addAssociatedRolesModalButton).contains("Add").click();

View file

@ -10,7 +10,7 @@ export default class CreateRealmRolePage {
this.realmRoleNameError = "#kc-name-helper"; this.realmRoleNameError = "#kc-name-helper";
this.realmRoleDescriptionInput = "#kc-role-description"; this.realmRoleDescriptionInput = "#kc-role-description";
this.saveBtn = '[type="submit"]'; this.saveBtn = 'realm-roles-save-button';
this.cancelBtn = '[type="button"]'; this.cancelBtn = '[type="button"]';
} }
@ -37,7 +37,7 @@ export default class CreateRealmRolePage {
//#endregion //#endregion
save() { save() {
cy.get(this.saveBtn).click(); cy.getId(this.saveBtn).click();
return this; return this;
} }

View file

@ -58,9 +58,9 @@ export const attributesToArray = (attributes?: {
export const AttributesForm = ({ export const AttributesForm = ({
form: { handleSubmit, register, formState, errors, watch }, form: { handleSubmit, register, formState, errors, watch },
save,
array: { fields, append, remove }, array: { fields, append, remove },
reset, reset,
save,
}: AttributesFormProps) => { }: AttributesFormProps) => {
const { t } = useTranslation("roles"); const { t } = useTranslation("roles");
@ -96,7 +96,7 @@ export const AttributesForm = ({
> >
<TextInput <TextInput
name={`attributes[${rowIndex}].key`} name={`attributes[${rowIndex}].key`}
ref={register({ required: true })} ref={register()}
aria-label="key-input" aria-label="key-input"
defaultValue={attribute.key} defaultValue={attribute.key}
validated={ validated={

View file

@ -139,7 +139,7 @@ export function KeycloakDataTable<T>({
const [max, setMax] = useState(10); const [max, setMax] = useState(10);
const [first, setFirst] = useState(0); const [first, setFirst] = useState(0);
const [search, setSearch] = useState(""); const [search, setSearch] = useState<string>("");
const [key, setKey] = useState(0); const [key, setKey] = useState(0);
const refresh = () => setKey(new Date().getTime()); const refresh = () => setKey(new Date().getTime());
@ -172,7 +172,7 @@ export function KeycloakDataTable<T>({
}, },
handleError handleError
); );
}, [key, first, max]); }, [key, first, max, search]);
const getNodeText = (node: keyof T | JSX.Element): string => { const getNodeText = (node: keyof T | JSX.Element): string => {
if (["string", "number"].includes(typeof node)) { if (["string", "number"].includes(typeof node)) {
@ -197,6 +197,7 @@ export function KeycloakDataTable<T>({
) )
) )
); );
setSearch;
}; };
const convertAction = () => const convertAction = () =>
@ -214,13 +215,6 @@ export function KeycloakDataTable<T>({
return action; return action;
}); });
const searchOnChange = (value: string) => {
if (value === "") {
refresh();
}
setSearch(value);
};
const Loading = () => ( const Loading = () => (
<div className="pf-u-text-align-center"> <div className="pf-u-text-align-center">
<Spinner /> <Spinner />
@ -258,8 +252,7 @@ export function KeycloakDataTable<T>({
inputGroupName={ inputGroupName={
searchPlaceholderKey ? `${ariaLabelKey}input` : undefined searchPlaceholderKey ? `${ariaLabelKey}input` : undefined
} }
inputGroupOnChange={searchOnChange} inputGroupOnEnter={setSearch}
inputGroupOnClick={refresh}
inputGroupPlaceholder={t(searchPlaceholderKey || "")} inputGroupPlaceholder={t(searchPlaceholderKey || "")}
searchTypeComponent={searchTypeComponent} searchTypeComponent={searchTypeComponent}
toolbarItem={toolbarItem} toolbarItem={toolbarItem}
@ -291,8 +284,7 @@ export function KeycloakDataTable<T>({
inputGroupName={ inputGroupName={
searchPlaceholderKey ? `${ariaLabelKey}input` : undefined searchPlaceholderKey ? `${ariaLabelKey}input` : undefined
} }
inputGroupOnChange={searchOnChange} inputGroupOnEnter={(search) => filter(search)}
inputGroupOnClick={() => filter(search)}
inputGroupPlaceholder={t(searchPlaceholderKey || "")} inputGroupPlaceholder={t(searchPlaceholderKey || "")}
toolbarItem={toolbarItem} toolbarItem={toolbarItem}
searchTypeComponent={searchTypeComponent} searchTypeComponent={searchTypeComponent}

View file

@ -1,4 +1,4 @@
import React, { MouseEventHandler } from "react"; import React from "react";
import { import {
Pagination, Pagination,
ToggleTemplateProps, ToggleTemplateProps,
@ -22,7 +22,7 @@ type TableToolbarProps = {
newInput: string, newInput: string,
event: React.FormEvent<HTMLInputElement> event: React.FormEvent<HTMLInputElement>
) => void; ) => void;
inputGroupOnClick?: MouseEventHandler; inputGroupOnEnter?: (value: string) => void;
}; };
export const PaginatingTableToolbar = ({ export const PaginatingTableToolbar = ({
@ -38,7 +38,7 @@ export const PaginatingTableToolbar = ({
inputGroupName, inputGroupName,
inputGroupPlaceholder, inputGroupPlaceholder,
inputGroupOnChange, inputGroupOnChange,
inputGroupOnClick, inputGroupOnEnter,
}: TableToolbarProps) => { }: TableToolbarProps) => {
const page = Math.round(first / max); const page = Math.round(first / max);
const pagination = (variant: "top" | "bottom" = "top") => ( const pagination = (variant: "top" | "bottom" = "top") => (
@ -59,24 +59,23 @@ export const PaginatingTableToolbar = ({
/> />
); );
if (count === 0) {
<>{children}</>;
}
return ( return (
<TableToolbar <TableToolbar
searchTypeComponent={searchTypeComponent} searchTypeComponent={searchTypeComponent}
toolbarItem={ toolbarItem={
<> <>
{toolbarItem} {toolbarItem}
{count !== 0 && ( <ToolbarItem variant="pagination">{pagination()}</ToolbarItem>
<ToolbarItem variant="pagination">{pagination()}</ToolbarItem>
)}
</> </>
} }
toolbarItemFooter={ toolbarItemFooter={<ToolbarItem>{pagination("bottom")}</ToolbarItem>}
count !== 0 && <ToolbarItem>{pagination("bottom")}</ToolbarItem>
}
inputGroupName={inputGroupName} inputGroupName={inputGroupName}
inputGroupPlaceholder={inputGroupPlaceholder} inputGroupPlaceholder={inputGroupPlaceholder}
inputGroupOnChange={inputGroupOnChange} inputGroupOnChange={inputGroupOnChange}
inputGroupOnClick={inputGroupOnClick} inputGroupOnEnter={inputGroupOnEnter}
> >
{children} {children}
</TableToolbar> </TableToolbar>

View file

@ -1,9 +1,4 @@
import React, { import React, { FormEvent, Fragment, ReactNode } from "react";
FormEvent,
Fragment,
MouseEventHandler,
ReactNode,
} from "react";
import { import {
Toolbar, Toolbar,
ToolbarContent, ToolbarContent,
@ -28,7 +23,7 @@ type TableToolbarProps = {
newInput: string, newInput: string,
event: FormEvent<HTMLInputElement> event: FormEvent<HTMLInputElement>
) => void; ) => void;
inputGroupOnClick?: MouseEventHandler; inputGroupOnEnter?: (value: string) => void;
}; };
export const TableToolbar = ({ export const TableToolbar = ({
@ -39,9 +34,35 @@ export const TableToolbar = ({
inputGroupName, inputGroupName,
inputGroupPlaceholder, inputGroupPlaceholder,
inputGroupOnChange, inputGroupOnChange,
inputGroupOnClick, inputGroupOnEnter,
}: TableToolbarProps) => { }: TableToolbarProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [searchValue, setSearchValue] = React.useState<string>("");
const onSearch = () => {
if (searchValue !== "") {
setSearchValue(searchValue);
inputGroupOnEnter && inputGroupOnEnter(searchValue);
} else {
setSearchValue("");
inputGroupOnEnter && inputGroupOnEnter("");
}
};
const handleKeyDown = (e: any) => {
if (e.key === "Enter") {
onSearch();
}
};
const handleInputChange = (
value: string,
event: FormEvent<HTMLInputElement>
) => {
inputGroupOnChange && inputGroupOnChange(value, event);
setSearchValue(value);
};
return ( return (
<> <>
<Toolbar> <Toolbar>
@ -59,12 +80,13 @@ export const TableToolbar = ({
type="search" type="search"
aria-label={t("search")} aria-label={t("search")}
placeholder={inputGroupPlaceholder} placeholder={inputGroupPlaceholder}
onChange={inputGroupOnChange} onChange={handleInputChange}
onKeyDown={handleKeyDown}
/> />
<Button <Button
variant={ButtonVariant.control} variant={ButtonVariant.control}
aria-label={t("search")} aria-label={t("search")}
onClick={inputGroupOnClick} onClick={onSearch}
> >
<SearchIcon /> <SearchIcon />
</Button> </Button>

View file

@ -123,8 +123,6 @@ export const AssociatedRolesTab = ({
); );
}; };
console.log(inheritanceMap);
const toggleModal = () => setOpen(!open); const toggleModal = () => setOpen(!open);
const [toggleDeleteDialog, DeleteConfirm] = useConfirmDialog({ const [toggleDeleteDialog, DeleteConfirm] = useConfirmDialog({
@ -173,7 +171,7 @@ export const AssociatedRolesTab = ({
const goToCreate = () => history.push(`${url}/add-role`); const goToCreate = () => history.push(`${url}/add-role`);
return ( return (
<> <>
<PageSection variant="light"> <PageSection variant="light" padding={{ default: "noPadding" }}>
<DeleteConfirm /> <DeleteConfirm />
<DeleteAssociatedRolesConfirm /> <DeleteAssociatedRolesConfirm />
<AssociatedRolesModal <AssociatedRolesModal

View file

@ -9,28 +9,28 @@ import {
} from "@patternfly/react-core"; } from "@patternfly/react-core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { UseFormMethods } from "react-hook-form"; import { UseFormMethods } from "react-hook-form";
import { FormAccess } from "../components/form-access/FormAccess";
import { RoleFormType } from "./RealmRoleTabs"; import { RoleFormType } from "./RealmRoleTabs";
import { FormAccess } from "../components/form-access/FormAccess";
export type RealmRoleFormProps = { export type RealmRoleFormProps = {
form: UseFormMethods<RoleFormType>; form: UseFormMethods<RoleFormType>;
save: (role: RoleFormType) => void; save: () => void;
editMode: boolean; editMode: boolean;
reset: () => void; reset: () => void;
}; };
export const RealmRoleForm = ({ export const RealmRoleForm = ({
form, form: { handleSubmit, errors, register },
save, save,
editMode, editMode,
reset, reset,
}: RealmRoleFormProps) => { }: RealmRoleFormProps) => {
const { t } = useTranslation("roles"); const { t } = useTranslation("roles");
return ( return (
<FormAccess <FormAccess
isHorizontal isHorizontal
onSubmit={form.handleSubmit(save)} onSubmit={handleSubmit(save)}
role="manage-realm" role="manage-realm"
className="pf-u-mt-lg" className="pf-u-mt-lg"
> >
@ -38,11 +38,11 @@ export const RealmRoleForm = ({
label={t("roleName")} label={t("roleName")}
fieldId="kc-name" fieldId="kc-name"
isRequired isRequired
validated={form.errors.name ? "error" : "default"} validated={errors.name ? "error" : "default"}
helperTextInvalid={t("common:required")} helperTextInvalid={t("common:required")}
> >
<TextInput <TextInput
ref={form.register({ required: !editMode })} ref={register({ required: !editMode })}
type="text" type="text"
id="kc-name" id="kc-name"
name="name" name="name"
@ -53,15 +53,13 @@ export const RealmRoleForm = ({
label={t("common:description")} label={t("common:description")}
fieldId="kc-description" fieldId="kc-description"
validated={ validated={
form.errors.description errors.description ? ValidatedOptions.error : ValidatedOptions.default
? ValidatedOptions.error
: ValidatedOptions.default
} }
helperTextInvalid={form.errors.description?.message} helperTextInvalid={errors.description?.message}
> >
<TextArea <TextArea
name="description" name="description"
ref={form.register({ ref={register({
maxLength: { maxLength: {
value: 255, value: 255,
message: t("common:maxLength", { length: 255 }), message: t("common:maxLength", { length: 255 }),
@ -69,7 +67,7 @@ export const RealmRoleForm = ({
})} })}
type="text" type="text"
validated={ validated={
form.errors.description errors.description
? ValidatedOptions.error ? ValidatedOptions.error
: ValidatedOptions.default : ValidatedOptions.default
} }
@ -77,7 +75,11 @@ export const RealmRoleForm = ({
/> />
</FormGroup> </FormGroup>
<ActionGroup> <ActionGroup>
<Button variant="primary" type="submit"> <Button
variant="primary"
onClick={save}
data-testid="realm-roles-save-button"
>
{t("common:save")} {t("common:save")}
</Button> </Button>
<Button onClick={() => reset()} variant="link"> <Button onClick={() => reset()} variant="link">

View file

@ -93,8 +93,21 @@ export const RealmRoleTabs = () => {
useEffect(() => append({ key: "", value: "" }), [append, role]); useEffect(() => append({ key: "", value: "" }), [append, role]);
const save = async (role: RoleFormType) => { const save = async () => {
try { try {
const role = form.getValues();
if (
role.attributes &&
role.attributes[role.attributes.length - 1].key === ""
) {
form.setValue(
"attributes",
role.attributes.slice(0, role.attributes.length - 1)
);
}
if (!(await form.trigger())) {
return;
}
const { attributes, ...rest } = role; const { attributes, ...rest } = role;
const roleRepresentation: RoleRepresentation = rest; const roleRepresentation: RoleRepresentation = rest;
if (id) { if (id) {

View file

@ -28,3 +28,8 @@
padding-right: var(--pf-global--spacer--xs); padding-right: var(--pf-global--spacer--xs);
} }
.pf-c-chip-group.kc-filter-chip-group__table {
margin-left: var(--pf-global--spacer--md);
margin-bottom: var(--pf-global--spacer--md);
}

View file

@ -1,24 +1,51 @@
import React from "react"; import React, { useEffect, useState } from "react";
import { PageSection } from "@patternfly/react-core"; import { PageSection } from "@patternfly/react-core";
import { ViewHeader } from "../components/view-header/ViewHeader"; import { ViewHeader } from "../components/view-header/ViewHeader";
import { useAdminClient } from "../context/auth/AdminClient"; import { asyncStateFetch, useAdminClient } from "../context/auth/AdminClient";
import { RolesList } from "./RolesList"; import { RolesList } from "./RolesList";
import { useErrorHandler } from "react-error-boundary";
export const RealmRolesSection = () => { export const RealmRolesSection = () => {
const adminClient = useAdminClient(); const adminClient = useAdminClient();
const [listRoles, setListRoles] = useState(false);
const handleError = useErrorHandler();
useEffect(() => {
return asyncStateFetch(
() => {
return Promise.all([adminClient.roles.find()]);
},
(response) => {
setListRoles(!(response[0] && response[0].length > 0));
},
handleError
);
}, []);
const loader = async (first?: number, max?: number, search?: string) => { const loader = async (first?: number, max?: number, search?: string) => {
const params: { [name: string]: string | number } = { const params: { [name: string]: string | number } = {
first: first!, first: first!,
max: max!, max: max!,
search: search!,
}; };
const searchParam = search || "";
if (searchParam) {
params.search = searchParam;
}
if (listRoles) {
return [];
}
return await adminClient.roles.find(params); return await adminClient.roles.find(params);
}; };
return ( return (
<> <>
<ViewHeader titleKey="roles:title" subKey="roles:roleExplain" /> <ViewHeader titleKey="roles:title" subKey="roles:roleExplain" />
<PageSection variant="light"> <PageSection variant="light" padding={{ default: "noPadding" }}>
<RolesList loader={loader} /> <RolesList loader={loader} />
</PageSection> </PageSection>
</> </>

View file

@ -0,0 +1,160 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { ArrayField, UseFormMethods } from "react-hook-form";
import { ActionGroup, Button, TextInput } from "@patternfly/react-core";
import {
TableComposable,
Tbody,
Td,
Th,
Thead,
Tr,
} from "@patternfly/react-table";
import { MinusCircleIcon, PlusCircleIcon } from "@patternfly/react-icons";
import { FormAccess } from "../components/form-access/FormAccess";
import { RoleFormType } from "./RealmRoleTabs";
import "./RealmRolesSection.css";
export type KeyValueType = { key: string; value: string };
type RoleAttributesProps = {
form: UseFormMethods<RoleFormType>;
save: () => void;
reset: () => void;
array: {
fields: Partial<ArrayField<Record<string, any>, "id">>[];
append: (
value: Partial<Record<string, any>> | Partial<Record<string, any>>[],
shouldFocus?: boolean | undefined
) => void;
remove: (index?: number | number[] | undefined) => void;
};
};
export const RoleAttributes = ({
form: { register, formState, errors, watch },
save,
array: { fields, append, remove },
reset,
}: RoleAttributesProps) => {
const { t } = useTranslation("roles");
const columns = ["Key", "Value"];
const watchFirstKey = watch("attributes[0].key", "");
return (
<>
<FormAccess role="manage-realm">
<TableComposable
className="kc-role-attributes__table"
aria-label="Role attribute keys and values"
variant="compact"
borders={false}
>
<Thead>
<Tr>
<Th id="key" width={40}>
{columns[0]}
</Th>
<Th id="value" width={40}>
{columns[1]}
</Th>
</Tr>
</Thead>
<Tbody>
{fields.map((attribute, rowIndex) => (
<Tr key={attribute.id}>
<Td
key={`${attribute.id}-key`}
id={`text-input-${rowIndex}-key`}
dataLabel={columns[0]}
>
<TextInput
name={`attributes[${rowIndex}].key`}
ref={register()}
aria-label="key-input"
defaultValue={attribute.key}
validated={
errors.attributes && errors.attributes[rowIndex]
? "error"
: "default"
}
/>
</Td>
<Td
key={`${attribute}-value`}
id={`text-input-${rowIndex}-value`}
dataLabel={columns[1]}
>
<TextInput
name={`attributes[${rowIndex}].value`}
ref={register()}
aria-label="value-input"
defaultValue={attribute.value}
validated={errors.description ? "error" : "default"}
/>
</Td>
{rowIndex !== fields.length - 1 && fields.length - 1 !== 0 && (
<Td
key="minus-button"
id={`kc-minus-button-${rowIndex}`}
dataLabel={columns[2]}
>
<Button
id={`minus-button-${rowIndex}`}
aria-label={`remove ${attribute.key} with value ${attribute.value} `}
variant="link"
className="kc-role-attributes__minus-icon"
onClick={() => remove(rowIndex)}
>
<MinusCircleIcon />
</Button>
</Td>
)}
{rowIndex === fields.length - 1 && (
<Td key="add-button" id="add-button" dataLabel={columns[2]}>
{fields[rowIndex].key === "" && (
<Button
id={`minus-button-${rowIndex}`}
aria-label={`remove ${attribute.key} with value ${attribute.value} `}
variant="link"
className="kc-role-attributes__minus-icon"
onClick={() => remove(rowIndex)}
>
<MinusCircleIcon />
</Button>
)}
<Button
aria-label={t("roles:addAttributeText")}
id="plus-icon"
variant="link"
className="kc-role-attributes__plus-icon"
onClick={() => append({ key: "", value: "" })}
icon={<PlusCircleIcon />}
isDisabled={!formState.isValid}
/>
</Td>
)}
</Tr>
))}
</Tbody>
</TableComposable>
<ActionGroup className="kc-role-attributes__action-group">
<Button
data-testid="realm-roles-save-button"
variant="primary"
isDisabled={!watchFirstKey}
onClick={save}
>
{t("common:save")}
</Button>
<Button onClick={reset} variant="link">
{t("common:reload")}
</Button>
</ActionGroup>
</FormAccess>
</>
);
};

View file

@ -79,6 +79,7 @@ export const RolesList = ({
}); });
const goToCreate = () => history.push(`${url}/add-role`); const goToCreate = () => history.push(`${url}/add-role`);
return ( return (
<> <>
<DeleteConfirm /> <DeleteConfirm />

View file

@ -66,6 +66,7 @@
"userName": "Username", "userName": "Username",
"email": "Email", "email": "Email",
"lastName": "Last name", "lastName": "Last name",
"firstName": "First name" "firstName": "First name",
"clearAllFilters": "Clear all filters"
} }
} }

View file

@ -45,6 +45,7 @@ export const UsersSection = () => {
const [listUsers, setListUsers] = useState(false); const [listUsers, setListUsers] = useState(false);
const [initialSearch, setInitialSearch] = useState(""); const [initialSearch, setInitialSearch] = useState("");
const [selectedRows, setSelectedRows] = useState<UserRepresentation[]>([]); const [selectedRows, setSelectedRows] = useState<UserRepresentation[]>([]);
const [search, setSearch] = useState("");
const [key, setKey] = useState(""); const [key, setKey] = useState("");
const refresh = () => setKey(`${new Date().getTime()}`); const refresh = () => setKey(`${new Date().getTime()}`);
@ -87,6 +88,7 @@ export const UsersSection = () => {
const searchParam = search || initialSearch || ""; const searchParam = search || initialSearch || "";
if (searchParam) { if (searchParam) {
params.search = searchParam; params.search = searchParam;
setSearch(searchParam);
} }
if (!listUsers && !searchParam) { if (!listUsers && !searchParam) {
@ -192,12 +194,16 @@ export const UsersSection = () => {
canSelectAll canSelectAll
onSelect={(rows) => setSelectedRows([...rows])} onSelect={(rows) => setSelectedRows([...rows])}
emptyState={ emptyState={
<ListEmptyState !search ? (
message={t("noUsersFound")} <ListEmptyState
instructions={t("emptyInstructions")} message={t("noUsersFound")}
primaryActionText={t("createNewUser")} instructions={t("emptyInstructions")}
onPrimaryAction={goToCreate} primaryActionText={t("createNewUser")}
/> onPrimaryAction={goToCreate}
/>
) : (
""
)
} }
toolbarItem={ toolbarItem={
<> <>