Modal with clients (#380)
* WIP modal * modal WIP add modal place modal in separate file format wip implementation getCompositeRoles with Jeff add associated roles tab WIP addComposites function WIP fix post call additional roles fetch big rebase WIP refresh resolve conflicts with Erik latest -> fixes role creation cypress tests, bump react-hook-form to remove console warnings delete add refresh with Jeff, update cypress tests, select additional roles tab on add make dropdownId optional format add additionalRolesModal to associated roles tab add toolbar items add toolbaritems to associated role tab, matches mock rebase add descriptions to alert add badge fix badge logic fix URL when associate roles are deleted, format update cypress test format add associated roles refresh, PR feedback from Erik add associated roles refresh, PR feedback from Erik lint WIP modal with client roles add clients to modal WIP label labels WIP promises wip wip add clients to modal with labels modal with Clients format * rebase * fix check types * PR feedback from Erik
This commit is contained in:
parent
a83ae98175
commit
e370859690
8 changed files with 205 additions and 10 deletions
|
@ -70,6 +70,7 @@ describe("Realm roles test", function () {
|
||||||
|
|
||||||
masthead.checkNotificationMessage("Role created");
|
masthead.checkNotificationMessage("Role created");
|
||||||
|
|
||||||
|
// Add associated realm role
|
||||||
cy.get("#roles-actions-dropdown").last().click();
|
cy.get("#roles-actions-dropdown").last().click();
|
||||||
|
|
||||||
cy.get("#add-roles").click();
|
cy.get("#add-roles").click();
|
||||||
|
@ -83,6 +84,24 @@ describe("Realm roles test", function () {
|
||||||
cy.url().should("include", "/AssociatedRoles");
|
cy.url().should("include", "/AssociatedRoles");
|
||||||
|
|
||||||
cy.get("#composite-role-badge").should("contain.text", "Composite");
|
cy.get("#composite-role-badge").should("contain.text", "Composite");
|
||||||
|
|
||||||
|
// Add associated client role
|
||||||
|
|
||||||
|
cy.get('[data-cy=add-role-button]').click();
|
||||||
|
|
||||||
|
cy.wait(100);
|
||||||
|
|
||||||
|
cy.get('[data-cy=filter-type-dropdown]').click()
|
||||||
|
|
||||||
|
cy.get('[data-cy=filter-type-dropdown-item]').click()
|
||||||
|
|
||||||
|
cy.wait(2500);
|
||||||
|
|
||||||
|
cy.get('[type="checkbox"]').eq(4).check({force: true});
|
||||||
|
|
||||||
|
cy.get("#add-associated-roles-button").contains("Add").click();
|
||||||
|
|
||||||
|
cy.wait(2500);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -89,6 +89,7 @@ export type DataListProps<T> = {
|
||||||
columns: Field<T>[];
|
columns: Field<T>[];
|
||||||
actions?: Action<T>[];
|
actions?: Action<T>[];
|
||||||
actionResolver?: IActionsResolver;
|
actionResolver?: IActionsResolver;
|
||||||
|
searchTypeComponent?: ReactNode;
|
||||||
toolbarItem?: ReactNode;
|
toolbarItem?: ReactNode;
|
||||||
emptyState?: ReactNode;
|
emptyState?: ReactNode;
|
||||||
};
|
};
|
||||||
|
@ -126,6 +127,7 @@ export function KeycloakDataTable<T>({
|
||||||
columns,
|
columns,
|
||||||
actions,
|
actions,
|
||||||
actionResolver,
|
actionResolver,
|
||||||
|
searchTypeComponent,
|
||||||
toolbarItem,
|
toolbarItem,
|
||||||
emptyState,
|
emptyState,
|
||||||
}: DataListProps<T>) {
|
}: DataListProps<T>) {
|
||||||
|
@ -262,6 +264,7 @@ export function KeycloakDataTable<T>({
|
||||||
inputGroupOnChange={searchOnChange}
|
inputGroupOnChange={searchOnChange}
|
||||||
inputGroupOnClick={refresh}
|
inputGroupOnClick={refresh}
|
||||||
inputGroupPlaceholder={t(searchPlaceholderKey)}
|
inputGroupPlaceholder={t(searchPlaceholderKey)}
|
||||||
|
searchTypeComponent={searchTypeComponent}
|
||||||
toolbarItem={toolbarItem}
|
toolbarItem={toolbarItem}
|
||||||
>
|
>
|
||||||
{!loading && (emptyState === undefined || rows.length !== 0) && (
|
{!loading && (emptyState === undefined || rows.length !== 0) && (
|
||||||
|
@ -286,6 +289,7 @@ export function KeycloakDataTable<T>({
|
||||||
inputGroupOnClick={() => {}}
|
inputGroupOnClick={() => {}}
|
||||||
inputGroupPlaceholder={t(searchPlaceholderKey)}
|
inputGroupPlaceholder={t(searchPlaceholderKey)}
|
||||||
toolbarItem={toolbarItem}
|
toolbarItem={toolbarItem}
|
||||||
|
searchTypeComponent={searchTypeComponent}
|
||||||
>
|
>
|
||||||
{(emptyState === undefined || rows.length !== 0) && (
|
{(emptyState === undefined || rows.length !== 0) && (
|
||||||
<DataTable
|
<DataTable
|
||||||
|
|
|
@ -13,6 +13,7 @@ type TableToolbarProps = {
|
||||||
onNextClick: (page: number) => void;
|
onNextClick: (page: number) => void;
|
||||||
onPreviousClick: (page: number) => void;
|
onPreviousClick: (page: number) => void;
|
||||||
onPerPageSelect: (max: number, first: number) => void;
|
onPerPageSelect: (max: number, first: number) => void;
|
||||||
|
searchTypeComponent?: React.ReactNode;
|
||||||
toolbarItem?: React.ReactNode;
|
toolbarItem?: React.ReactNode;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
inputGroupName?: string;
|
inputGroupName?: string;
|
||||||
|
@ -31,6 +32,7 @@ export const PaginatingTableToolbar = ({
|
||||||
onNextClick,
|
onNextClick,
|
||||||
onPreviousClick,
|
onPreviousClick,
|
||||||
onPerPageSelect,
|
onPerPageSelect,
|
||||||
|
searchTypeComponent,
|
||||||
toolbarItem,
|
toolbarItem,
|
||||||
children,
|
children,
|
||||||
inputGroupName,
|
inputGroupName,
|
||||||
|
@ -59,6 +61,7 @@ export const PaginatingTableToolbar = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableToolbar
|
<TableToolbar
|
||||||
|
searchTypeComponent={searchTypeComponent}
|
||||||
toolbarItem={
|
toolbarItem={
|
||||||
<>
|
<>
|
||||||
{toolbarItem}
|
{toolbarItem}
|
||||||
|
|
|
@ -18,6 +18,7 @@ import { SearchIcon } from "@patternfly/react-icons";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
type TableToolbarProps = {
|
type TableToolbarProps = {
|
||||||
|
filterToolbarDropdown?: ReactNode;
|
||||||
toolbarItem?: ReactNode;
|
toolbarItem?: ReactNode;
|
||||||
toolbarItemFooter?: ReactNode;
|
toolbarItemFooter?: ReactNode;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
|
@ -32,6 +33,7 @@ type TableToolbarProps = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const TableToolbar = ({
|
export const TableToolbar = ({
|
||||||
|
filterToolbarDropdown,
|
||||||
toolbarItem,
|
toolbarItem,
|
||||||
toolbarItemFooter,
|
toolbarItemFooter,
|
||||||
children,
|
children,
|
||||||
|
@ -50,6 +52,7 @@ export const TableToolbar = ({
|
||||||
{inputGroupName && (
|
{inputGroupName && (
|
||||||
<ToolbarItem>
|
<ToolbarItem>
|
||||||
<InputGroup>
|
<InputGroup>
|
||||||
|
{filterToolbarDropdown}
|
||||||
{searchTypeComponent}
|
{searchTypeComponent}
|
||||||
<TextInput
|
<TextInput
|
||||||
name={inputGroupName}
|
name={inputGroupName}
|
||||||
|
|
|
@ -1,13 +1,65 @@
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { Button, Modal, ModalVariant } from "@patternfly/react-core";
|
import {
|
||||||
|
Button,
|
||||||
|
Dropdown,
|
||||||
|
DropdownItem,
|
||||||
|
DropdownToggle,
|
||||||
|
Label,
|
||||||
|
Modal,
|
||||||
|
ModalVariant,
|
||||||
|
} from "@patternfly/react-core";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { useAdminClient } from "../context/auth/AdminClient";
|
import { useAdminClient } from "../context/auth/AdminClient";
|
||||||
import RoleRepresentation from "keycloak-admin/lib/defs/roleRepresentation";
|
import RoleRepresentation from "keycloak-admin/lib/defs/roleRepresentation";
|
||||||
import { KeycloakDataTable } from "../components/table-toolbar/KeycloakDataTable";
|
import { KeycloakDataTable } from "../components/table-toolbar/KeycloakDataTable";
|
||||||
import { ListEmptyState } from "../components/list-empty-state/ListEmptyState";
|
import { ListEmptyState } from "../components/list-empty-state/ListEmptyState";
|
||||||
import { boolFormatter } from "../util";
|
import { CaretDownIcon, FilterIcon } from "@patternfly/react-icons";
|
||||||
|
import KeycloakAdminClient from "keycloak-admin";
|
||||||
|
|
||||||
|
type AliasRendererComponentProps = {
|
||||||
|
name?: string;
|
||||||
|
containerId?: string;
|
||||||
|
filterType: string;
|
||||||
|
adminClient: KeycloakAdminClient;
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AliasRendererComponent = ({
|
||||||
|
name,
|
||||||
|
containerId,
|
||||||
|
filterType,
|
||||||
|
adminClient,
|
||||||
|
id,
|
||||||
|
}: AliasRendererComponentProps) => {
|
||||||
|
const [containerName, setContainerName] = useState<string>("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
adminClient.clients
|
||||||
|
.findOne({ id: containerId! })
|
||||||
|
.then((client) => setContainerName(client.clientId as string));
|
||||||
|
}, [containerId]);
|
||||||
|
|
||||||
|
if (filterType === "roles") {
|
||||||
|
return <>{name}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filterType === "clients") {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{containerId && (
|
||||||
|
<Label color="blue" key={`label-${id}`}>
|
||||||
|
{containerName}
|
||||||
|
</Label>
|
||||||
|
)}{" "}
|
||||||
|
{name}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
export type AssociatedRolesModalProps = {
|
export type AssociatedRolesModalProps = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
@ -37,16 +89,38 @@ export const AssociatedRolesModal = (props: AssociatedRolesModalProps) => {
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const adminClient = useAdminClient();
|
const adminClient = useAdminClient();
|
||||||
const [selectedRows, setSelectedRows] = useState<RoleRepresentation[]>([]);
|
const [selectedRows, setSelectedRows] = useState<RoleRepresentation[]>([]);
|
||||||
|
const [allClientRoles, setAllClientRoles] = useState<RoleRepresentation[]>(
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const [isFilterDropdownOpen, setIsFilterDropdownOpen] = useState(false);
|
||||||
|
const [filterType, setFilterType] = useState("roles");
|
||||||
|
const tableRefresher = React.useRef<() => void>();
|
||||||
|
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
|
|
||||||
|
const alphabetize = (rolesList: RoleRepresentation[]) => {
|
||||||
|
return rolesList.sort((r1, r2) => {
|
||||||
|
const r1Name = r1.name?.toUpperCase();
|
||||||
|
const r2Name = r2.name?.toUpperCase();
|
||||||
|
if (r1Name! < r2Name!) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
if (r1Name! > r2Name!) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const loader = async () => {
|
const loader = async () => {
|
||||||
const allRoles = await adminClient.roles.find();
|
const allRoles = await adminClient.roles.find();
|
||||||
const existingAdditionalRoles = await adminClient.roles.getCompositeRoles({
|
const existingAdditionalRoles = await adminClient.roles.getCompositeRoles({
|
||||||
id,
|
id,
|
||||||
});
|
});
|
||||||
|
|
||||||
return allRoles.filter((role: RoleRepresentation) => {
|
return alphabetize(allRoles).filter((role: RoleRepresentation) => {
|
||||||
return (
|
return (
|
||||||
existingAdditionalRoles.find(
|
existingAdditionalRoles.find(
|
||||||
(existing: RoleRepresentation) => existing.name === role.name
|
(existing: RoleRepresentation) => existing.name === role.name
|
||||||
|
@ -55,6 +129,52 @@ export const AssociatedRolesModal = (props: AssociatedRolesModalProps) => {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const AliasRenderer = (role: RoleRepresentation) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AliasRendererComponent
|
||||||
|
id={id}
|
||||||
|
name={role.name}
|
||||||
|
adminClient={adminClient}
|
||||||
|
filterType={filterType}
|
||||||
|
containerId={role.containerId}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const clientRolesLoader = async () => {
|
||||||
|
const clients = await adminClient.clients.find();
|
||||||
|
|
||||||
|
const clientIdArray = clients.map((client) => client.id);
|
||||||
|
|
||||||
|
let rolesList: RoleRepresentation[] = [];
|
||||||
|
for (const id of clientIdArray) {
|
||||||
|
const clientRolesList = await adminClient.clients.listRoles({
|
||||||
|
id: id as string,
|
||||||
|
});
|
||||||
|
rolesList = [...rolesList, ...clientRolesList];
|
||||||
|
}
|
||||||
|
const existingAdditionalRoles = await adminClient.roles.getCompositeRoles({
|
||||||
|
id,
|
||||||
|
});
|
||||||
|
|
||||||
|
setAllClientRoles(rolesList);
|
||||||
|
console.log(allClientRoles);
|
||||||
|
|
||||||
|
return alphabetize(rolesList).filter((role: RoleRepresentation) => {
|
||||||
|
return (
|
||||||
|
existingAdditionalRoles.find(
|
||||||
|
(existing: RoleRepresentation) => existing.name === role.name
|
||||||
|
) === undefined && role.name !== name
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
tableRefresher.current && tableRefresher.current();
|
||||||
|
}, [filterType]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
if (id) {
|
if (id) {
|
||||||
|
@ -77,6 +197,24 @@ export const AssociatedRolesModal = (props: AssociatedRolesModalProps) => {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onFilterDropdownToggle = () => {
|
||||||
|
setIsFilterDropdownOpen(!isFilterDropdownOpen);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onFilterDropdownSelect = (filterType: string) => {
|
||||||
|
if (filterType == "roles") {
|
||||||
|
setFilterType("clients");
|
||||||
|
}
|
||||||
|
if (filterType == "clients") {
|
||||||
|
setFilterType("roles");
|
||||||
|
}
|
||||||
|
setIsFilterDropdownOpen(!isFilterDropdownOpen);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setRefresher = (refresher: () => void) => {
|
||||||
|
tableRefresher.current = refresher;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
title={t("roles:associatedRolesModalTitle", { name })}
|
title={t("roles:associatedRolesModalTitle", { name })}
|
||||||
|
@ -109,9 +247,37 @@ export const AssociatedRolesModal = (props: AssociatedRolesModalProps) => {
|
||||||
>
|
>
|
||||||
<KeycloakDataTable
|
<KeycloakDataTable
|
||||||
key="role-list-modal"
|
key="role-list-modal"
|
||||||
loader={loader}
|
loader={filterType == "roles" ? loader : clientRolesLoader}
|
||||||
ariaLabelKey="roles:roleList"
|
ariaLabelKey="roles:roleList"
|
||||||
searchPlaceholderKey="roles:searchFor"
|
searchPlaceholderKey="roles:searchFor"
|
||||||
|
setRefresher={setRefresher}
|
||||||
|
searchTypeComponent={
|
||||||
|
<Dropdown
|
||||||
|
onSelect={() => onFilterDropdownSelect(filterType)}
|
||||||
|
data-cy="filter-type-dropdown"
|
||||||
|
toggle={
|
||||||
|
<DropdownToggle
|
||||||
|
id="toggle-id-9"
|
||||||
|
onToggle={onFilterDropdownToggle}
|
||||||
|
toggleIndicator={CaretDownIcon}
|
||||||
|
icon={<FilterIcon />}
|
||||||
|
>
|
||||||
|
Filter by {filterType}
|
||||||
|
</DropdownToggle>
|
||||||
|
}
|
||||||
|
isOpen={isFilterDropdownOpen}
|
||||||
|
dropdownItems={[
|
||||||
|
<DropdownItem
|
||||||
|
data-cy="filter-type-dropdown-item"
|
||||||
|
key="filter-type"
|
||||||
|
>
|
||||||
|
{filterType == "roles"
|
||||||
|
? t("filterByClients")
|
||||||
|
: t("filterByRoles")}{" "}
|
||||||
|
</DropdownItem>,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
}
|
||||||
canSelectAll
|
canSelectAll
|
||||||
// isPaginated
|
// isPaginated
|
||||||
onSelect={(rows) => {
|
onSelect={(rows) => {
|
||||||
|
@ -121,15 +287,11 @@ export const AssociatedRolesModal = (props: AssociatedRolesModalProps) => {
|
||||||
{
|
{
|
||||||
name: "name",
|
name: "name",
|
||||||
displayKey: "roles:roleName",
|
displayKey: "roles:roleName",
|
||||||
},
|
cellRenderer: AliasRenderer,
|
||||||
{
|
|
||||||
name: "composite",
|
|
||||||
displayKey: "roles:composite",
|
|
||||||
cellFormatters: [boolFormatter()],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "description",
|
name: "description",
|
||||||
displayKey: "roles:description",
|
displayKey: "common:description",
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
emptyState={
|
emptyState={
|
||||||
|
|
|
@ -136,6 +136,7 @@ export const AssociatedRolesTab = ({
|
||||||
className="kc-add-role-button"
|
className="kc-add-role-button"
|
||||||
key="add-role-button"
|
key="add-role-button"
|
||||||
onClick={() => toggleModal()}
|
onClick={() => toggleModal()}
|
||||||
|
data-cy="add-role-button"
|
||||||
>
|
>
|
||||||
{t("addRole")}
|
{t("addRole")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
@ -6,6 +6,7 @@ import { RolesList } from "./RolesList";
|
||||||
|
|
||||||
export const RealmRolesSection = () => {
|
export const RealmRolesSection = () => {
|
||||||
const adminClient = useAdminClient();
|
const adminClient = useAdminClient();
|
||||||
|
|
||||||
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!,
|
||||||
|
|
|
@ -13,6 +13,8 @@
|
||||||
"importRole": "Import role",
|
"importRole": "Import role",
|
||||||
"roleID": "Role ID",
|
"roleID": "Role ID",
|
||||||
"homeURL": "Home URL",
|
"homeURL": "Home URL",
|
||||||
|
"filterByClients": "Filter by clients",
|
||||||
|
"filterByRoles": "Filter by roles",
|
||||||
"roleExplain": "Realm-level roles are a global namespace to define your roles.",
|
"roleExplain": "Realm-level roles are a global namespace to define your roles.",
|
||||||
"roleCreateExplain": "This is some description",
|
"roleCreateExplain": "This is some description",
|
||||||
"roleName": "Role name",
|
"roleName": "Role name",
|
||||||
|
|
Loading…
Reference in a new issue