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");
cy.wait(100);
// Add associated realm role
associatedRolesPage.addAssociatedRealmRole();

View file

@ -29,7 +29,7 @@ export default class AssociatedRolesPage {
cy.wait(100);
cy.get(this.checkbox).eq(1).check();
cy.get(this.checkbox).eq(2).check();
cy.get(this.addAssociatedRolesModalButton).contains("Add").click();
@ -53,7 +53,7 @@ export default class AssociatedRolesPage {
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();

View file

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

View file

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

View file

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

View file

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

View file

@ -1,9 +1,4 @@
import React, {
FormEvent,
Fragment,
MouseEventHandler,
ReactNode,
} from "react";
import React, { FormEvent, Fragment, ReactNode } from "react";
import {
Toolbar,
ToolbarContent,
@ -28,7 +23,7 @@ type TableToolbarProps = {
newInput: string,
event: FormEvent<HTMLInputElement>
) => void;
inputGroupOnClick?: MouseEventHandler;
inputGroupOnEnter?: (value: string) => void;
};
export const TableToolbar = ({
@ -39,9 +34,35 @@ export const TableToolbar = ({
inputGroupName,
inputGroupPlaceholder,
inputGroupOnChange,
inputGroupOnClick,
inputGroupOnEnter,
}: TableToolbarProps) => {
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 (
<>
<Toolbar>
@ -59,12 +80,13 @@ export const TableToolbar = ({
type="search"
aria-label={t("search")}
placeholder={inputGroupPlaceholder}
onChange={inputGroupOnChange}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
/>
<Button
variant={ButtonVariant.control}
aria-label={t("search")}
onClick={inputGroupOnClick}
onClick={onSearch}
>
<SearchIcon />
</Button>

View file

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

View file

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

View file

@ -93,8 +93,21 @@ export const RealmRoleTabs = () => {
useEffect(() => append({ key: "", value: "" }), [append, role]);
const save = async (role: RoleFormType) => {
const save = async () => {
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 roleRepresentation: RoleRepresentation = rest;
if (id) {

View file

@ -28,3 +28,8 @@
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 { ViewHeader } from "../components/view-header/ViewHeader";
import { useAdminClient } from "../context/auth/AdminClient";
import { asyncStateFetch, useAdminClient } from "../context/auth/AdminClient";
import { RolesList } from "./RolesList";
import { useErrorHandler } from "react-error-boundary";
export const RealmRolesSection = () => {
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 params: { [name: string]: string | number } = {
first: first!,
max: max!,
search: search!,
};
const searchParam = search || "";
if (searchParam) {
params.search = searchParam;
}
if (listRoles) {
return [];
}
return await adminClient.roles.find(params);
};
return (
<>
<ViewHeader titleKey="roles:title" subKey="roles:roleExplain" />
<PageSection variant="light">
<PageSection variant="light" padding={{ default: "noPadding" }}>
<RolesList loader={loader} />
</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`);
return (
<>
<DeleteConfirm />

View file

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