Group details (#425)
* added details for groups * add includeSubGroups checkbox * added tests * fixed reload for group attributes * fixed spacing on associate roles tab * fixed group reload after save * fixed test
This commit is contained in:
parent
518b21c6ae
commit
e4d83d0fe3
19 changed files with 810 additions and 379 deletions
|
@ -1,10 +1,12 @@
|
||||||
import ListingPage from "../support/pages/admin_console/ListingPage";
|
import ListingPage from "../support/pages/admin_console/ListingPage";
|
||||||
import CreateGroupModal from "../support/pages/admin_console/manage/groups/CreateGroupModal";
|
import CreateGroupModal from "../support/pages/admin_console/manage/groups/CreateGroupModal";
|
||||||
|
import GroupDetailPage from "../support/pages/admin_console/manage/groups/GroupDetailPage";
|
||||||
import { SearchGroupPage } from "../support/pages/admin_console/manage/groups/SearchGroup";
|
import { SearchGroupPage } from "../support/pages/admin_console/manage/groups/SearchGroup";
|
||||||
import Masthead from "../support/pages/admin_console/Masthead";
|
import Masthead from "../support/pages/admin_console/Masthead";
|
||||||
import SidebarPage from "../support/pages/admin_console/SidebarPage";
|
import SidebarPage from "../support/pages/admin_console/SidebarPage";
|
||||||
import LoginPage from "../support/pages/LoginPage";
|
import LoginPage from "../support/pages/LoginPage";
|
||||||
import ViewHeaderPage from "../support/pages/ViewHeaderPage";
|
import ViewHeaderPage from "../support/pages/ViewHeaderPage";
|
||||||
|
import AdminClient from "../support/util/AdminClient";
|
||||||
|
|
||||||
describe("Group test", () => {
|
describe("Group test", () => {
|
||||||
const loginPage = new LoginPage();
|
const loginPage = new LoginPage();
|
||||||
|
@ -39,8 +41,6 @@ describe("Group test", () => {
|
||||||
// Delete
|
// Delete
|
||||||
listingPage.deleteItem(groupName);
|
listingPage.deleteItem(groupName);
|
||||||
masthead.checkNotificationMessage("Group deleted");
|
masthead.checkNotificationMessage("Group deleted");
|
||||||
|
|
||||||
listingPage.itemExist(groupName, false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const searchGroupPage = new SearchGroupPage();
|
const searchGroupPage = new SearchGroupPage();
|
||||||
|
@ -50,4 +50,56 @@ describe("Group test", () => {
|
||||||
searchGroupPage.checkTerm("group");
|
searchGroupPage.checkTerm("group");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Group details", () => {
|
||||||
|
const groups = ["level", "level1", "level2"];
|
||||||
|
const detailPage = new GroupDetailPage();
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
const client = new AdminClient();
|
||||||
|
const createdGroups = await client.createSubGroups(groups);
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
const username = "user" + i;
|
||||||
|
client.createUserInGroup(username, createdGroups[i % 3].id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
cy.visit("");
|
||||||
|
loginPage.logIn();
|
||||||
|
sidebarPage.goToGroups();
|
||||||
|
});
|
||||||
|
|
||||||
|
after(() => {
|
||||||
|
new AdminClient().deleteGroups();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Should display all the subgroups", () => {
|
||||||
|
listingPage.goToItemDetails(groups[0]);
|
||||||
|
detailPage.checkListSubGroup([groups[1]]);
|
||||||
|
|
||||||
|
const added = "addedGroup";
|
||||||
|
createGroupModal.open().fillGroupForm(added).clickCreate();
|
||||||
|
|
||||||
|
detailPage.checkListSubGroup([added, groups[1]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Should display members", () => {
|
||||||
|
listingPage.goToItemDetails(groups[0]);
|
||||||
|
detailPage.clickMembersTab().checkListMembers(["user0", "user3"]);
|
||||||
|
detailPage
|
||||||
|
.clickIncludeSubGroups()
|
||||||
|
.checkListMembers(["user0", "user3", "user1", "user4", "user2"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Attributes CRUD test", () => {
|
||||||
|
listingPage.goToItemDetails(groups[0]);
|
||||||
|
detailPage
|
||||||
|
.clickAttributesTab()
|
||||||
|
.fillAttribute("key", "value")
|
||||||
|
.saveAttribute();
|
||||||
|
|
||||||
|
masthead.checkNotificationMessage("Group updated");
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -0,0 +1,59 @@
|
||||||
|
const expect = chai.expect;
|
||||||
|
|
||||||
|
export default class GroupDetailPage {
|
||||||
|
private groupNamesColumn = '[data-label="Group name"] > a';
|
||||||
|
private memberTab = "members";
|
||||||
|
private attributesTab = "attributes";
|
||||||
|
private memberNameColumn = 'tbody > tr > [data-label="Name"]';
|
||||||
|
private includeSubGroupsCheck = "includeSubGroupsCheck";
|
||||||
|
|
||||||
|
private keyInput = '[name="attributes[0].key"]';
|
||||||
|
private valueInput = '[name="attributes[0].value"]';
|
||||||
|
private saveAttributeBtn = ".pf-c-form__actions > .pf-m-primary";
|
||||||
|
|
||||||
|
checkListSubGroup(subGroups: string[]) {
|
||||||
|
cy.get(this.groupNamesColumn).should((groups) => {
|
||||||
|
expect(groups).to.have.length(subGroups.length);
|
||||||
|
for (let index = 0; index < subGroups.length; index++) {
|
||||||
|
const subGroup = subGroups[index];
|
||||||
|
expect(groups).to.contain(subGroup);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
clickMembersTab() {
|
||||||
|
cy.getId(this.memberTab).click();
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
checkListMembers(members: string[]) {
|
||||||
|
cy.get(this.memberNameColumn).should((member) => {
|
||||||
|
expect(member).to.have.length(members.length);
|
||||||
|
for (let index = 0; index < members.length; index++) {
|
||||||
|
expect(member.eq(index)).to.contain(members[index]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
clickIncludeSubGroups() {
|
||||||
|
cy.getId(this.includeSubGroupsCheck).click();
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
clickAttributesTab() {
|
||||||
|
cy.getId(this.attributesTab).click();
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
fillAttribute(key: string, value: string) {
|
||||||
|
cy.get(this.keyInput).type(key).get(this.valueInput).type(value);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
saveAttribute() {
|
||||||
|
cy.get(this.saveAttributeBtn).click();
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
}
|
|
@ -30,4 +30,36 @@ export default class AdminClient {
|
||||||
)[0];
|
)[0];
|
||||||
await this.client.clients.del({ id: client.id! });
|
await this.client.clients.del({ id: client.id! });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async createSubGroups(groups: string[]) {
|
||||||
|
await this.login();
|
||||||
|
let parentGroup = undefined;
|
||||||
|
const createdGroups = [];
|
||||||
|
for (const group of groups) {
|
||||||
|
if (!parentGroup) {
|
||||||
|
parentGroup = await this.client.groups.create({ name: group });
|
||||||
|
} else {
|
||||||
|
parentGroup = await this.client.groups.setOrCreateChild(
|
||||||
|
{ id: parentGroup.id },
|
||||||
|
{ name: group }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
createdGroups.push(parentGroup);
|
||||||
|
}
|
||||||
|
return createdGroups;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteGroups() {
|
||||||
|
await this.login();
|
||||||
|
const groups = await this.client.groups.find();
|
||||||
|
for (const group of groups) {
|
||||||
|
await this.client.groups.del({ id: group.id! });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createUserInGroup(username: string, groupId: string) {
|
||||||
|
await this.login();
|
||||||
|
const user = await this.client.users.create({ username, enabled: true });
|
||||||
|
await this.client.users.addToGroup({ id: user.id!, groupId });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,7 +19,7 @@ import { AccessContextProvider, useAccess } from "./context/access/Access";
|
||||||
import { routes, RouteDef } from "./route-config";
|
import { routes, RouteDef } from "./route-config";
|
||||||
import { PageBreadCrumbs } from "./components/bread-crumb/PageBreadCrumbs";
|
import { PageBreadCrumbs } from "./components/bread-crumb/PageBreadCrumbs";
|
||||||
import { ForbiddenSection } from "./ForbiddenSection";
|
import { ForbiddenSection } from "./ForbiddenSection";
|
||||||
import { SubGroups } from "./groups/GroupsSection";
|
import { SubGroups } from "./groups/SubGroupsContext";
|
||||||
import { useRealm } from "./context/realm-context/RealmContext";
|
import { useRealm } from "./context/realm-context/RealmContext";
|
||||||
import { useAdminClient, asyncStateFetch } from "./context/auth/AdminClient";
|
import { useAdminClient, asyncStateFetch } from "./context/auth/AdminClient";
|
||||||
import { ErrorRenderer } from "./components/error/ErrorRenderer";
|
import { ErrorRenderer } from "./components/error/ErrorRenderer";
|
||||||
|
|
|
@ -12,16 +12,19 @@ import {
|
||||||
} from "@patternfly/react-table";
|
} from "@patternfly/react-table";
|
||||||
import { MinusCircleIcon, PlusCircleIcon } from "@patternfly/react-icons";
|
import { MinusCircleIcon, PlusCircleIcon } from "@patternfly/react-icons";
|
||||||
|
|
||||||
import { FormAccess } from "../components/form-access/FormAccess";
|
import { FormAccess } from "../form-access/FormAccess";
|
||||||
import { RoleFormType } from "./RealmRoleTabs";
|
|
||||||
|
|
||||||
import "./RealmRolesSection.css";
|
import "./attribute-form.css";
|
||||||
|
|
||||||
export type KeyValueType = { key: string; value: string };
|
export type KeyValueType = { key: string; value: string };
|
||||||
|
|
||||||
type RoleAttributesProps = {
|
export type AttributeForm = {
|
||||||
form: UseFormMethods<RoleFormType>;
|
attributes: KeyValueType[];
|
||||||
save: (role: RoleFormType) => void;
|
};
|
||||||
|
|
||||||
|
export type AttributesFormProps = {
|
||||||
|
form: UseFormMethods<AttributeForm>;
|
||||||
|
save: (model: AttributeForm) => void;
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
array: {
|
array: {
|
||||||
fields: Partial<ArrayField<Record<string, any>, "id">>[];
|
fields: Partial<ArrayField<Record<string, any>, "id">>[];
|
||||||
|
@ -33,12 +36,32 @@ type RoleAttributesProps = {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const RoleAttributes = ({
|
export const arrayToAttributes = (attributeArray: KeyValueType[]) => {
|
||||||
|
const initValue: { [index: string]: string[] } = {};
|
||||||
|
return attributeArray.reduce((acc, attribute) => {
|
||||||
|
acc[attribute.key] = [attribute.value];
|
||||||
|
return acc;
|
||||||
|
}, initValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const attributesToArray = (attributes?: {
|
||||||
|
[key: string]: string[];
|
||||||
|
}): KeyValueType[] => {
|
||||||
|
if (!attributes || Object.keys(attributes).length == 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return Object.keys(attributes).map((key) => ({
|
||||||
|
key: key,
|
||||||
|
value: attributes[key][0],
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AttributesForm = ({
|
||||||
form: { handleSubmit, register, formState, errors, watch },
|
form: { handleSubmit, register, formState, errors, watch },
|
||||||
save,
|
save,
|
||||||
array: { fields, append, remove },
|
array: { fields, append, remove },
|
||||||
reset,
|
reset,
|
||||||
}: RoleAttributesProps) => {
|
}: AttributesFormProps) => {
|
||||||
const { t } = useTranslation("roles");
|
const { t } = useTranslation("roles");
|
||||||
|
|
||||||
const columns = ["Key", "Value"];
|
const columns = ["Key", "Value"];
|
||||||
|
@ -48,7 +71,7 @@ export const RoleAttributes = ({
|
||||||
<>
|
<>
|
||||||
<FormAccess role="manage-realm" onSubmit={handleSubmit(save)}>
|
<FormAccess role="manage-realm" onSubmit={handleSubmit(save)}>
|
||||||
<TableComposable
|
<TableComposable
|
||||||
className="kc-role-attributes__table"
|
className="kc-attributes__table"
|
||||||
aria-label="Role attribute keys and values"
|
aria-label="Role attribute keys and values"
|
||||||
variant="compact"
|
variant="compact"
|
||||||
borders={false}
|
borders={false}
|
||||||
|
@ -93,7 +116,6 @@ export const RoleAttributes = ({
|
||||||
ref={register()}
|
ref={register()}
|
||||||
aria-label="value-input"
|
aria-label="value-input"
|
||||||
defaultValue={attribute.value}
|
defaultValue={attribute.value}
|
||||||
validated={errors.description ? "error" : "default"}
|
|
||||||
/>
|
/>
|
||||||
</Td>
|
</Td>
|
||||||
{rowIndex !== fields.length - 1 && fields.length - 1 !== 0 && (
|
{rowIndex !== fields.length - 1 && fields.length - 1 !== 0 && (
|
||||||
|
@ -106,7 +128,7 @@ export const RoleAttributes = ({
|
||||||
id={`minus-button-${rowIndex}`}
|
id={`minus-button-${rowIndex}`}
|
||||||
aria-label={`remove ${attribute.key} with value ${attribute.value} `}
|
aria-label={`remove ${attribute.key} with value ${attribute.value} `}
|
||||||
variant="link"
|
variant="link"
|
||||||
className="kc-role-attributes__minus-icon"
|
className="kc-attributes__minus-icon"
|
||||||
onClick={() => remove(rowIndex)}
|
onClick={() => remove(rowIndex)}
|
||||||
>
|
>
|
||||||
<MinusCircleIcon />
|
<MinusCircleIcon />
|
||||||
|
@ -120,7 +142,7 @@ export const RoleAttributes = ({
|
||||||
id={`minus-button-${rowIndex}`}
|
id={`minus-button-${rowIndex}`}
|
||||||
aria-label={`remove ${attribute.key} with value ${attribute.value} `}
|
aria-label={`remove ${attribute.key} with value ${attribute.value} `}
|
||||||
variant="link"
|
variant="link"
|
||||||
className="kc-role-attributes__minus-icon"
|
className="kc-attributes__minus-icon"
|
||||||
onClick={() => remove(rowIndex)}
|
onClick={() => remove(rowIndex)}
|
||||||
>
|
>
|
||||||
<MinusCircleIcon />
|
<MinusCircleIcon />
|
||||||
|
@ -130,7 +152,7 @@ export const RoleAttributes = ({
|
||||||
aria-label={t("roles:addAttributeText")}
|
aria-label={t("roles:addAttributeText")}
|
||||||
id="plus-icon"
|
id="plus-icon"
|
||||||
variant="link"
|
variant="link"
|
||||||
className="kc-role-attributes__plus-icon"
|
className="kc-attributes__plus-icon"
|
||||||
onClick={() => append({ key: "", value: "" })}
|
onClick={() => append({ key: "", value: "" })}
|
||||||
icon={<PlusCircleIcon />}
|
icon={<PlusCircleIcon />}
|
||||||
isDisabled={!formState.isValid}
|
isDisabled={!formState.isValid}
|
||||||
|
@ -141,7 +163,7 @@ export const RoleAttributes = ({
|
||||||
))}
|
))}
|
||||||
</Tbody>
|
</Tbody>
|
||||||
</TableComposable>
|
</TableComposable>
|
||||||
<ActionGroup className="kc-role-attributes__action-group">
|
<ActionGroup className="kc-attributes__action-group">
|
||||||
<Button variant="primary" type="submit" isDisabled={!watchFirstKey}>
|
<Button variant="primary" type="submit" isDisabled={!watchFirstKey}>
|
||||||
{t("common:save")}
|
{t("common:save")}
|
||||||
</Button>
|
</Button>
|
23
src/components/attribute-form/attribute-form.css
Normal file
23
src/components/attribute-form/attribute-form.css
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
.kc-attributes__table {
|
||||||
|
/* even though the table is borderless, make the border under the th transparent */
|
||||||
|
--pf-c-table--border-width--base: 0;
|
||||||
|
--pf-c-table--m-compact--cell--first-last-child--PaddingLeft: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kc-attributes__plus-icon {
|
||||||
|
/* shift the button left to adjust for table cell padding */
|
||||||
|
margin-left: calc(var(--pf-global--spacer--md) * -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pf-c-button.kc-attributes__minus-icon {
|
||||||
|
/* shift the button left to adjust for table cell padding */
|
||||||
|
margin-left: calc(var(--pf-global--spacer--md) * -1);
|
||||||
|
color: var(--pf-c-button--m-plain--Color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kc-attributes__action-group {
|
||||||
|
/* subtract the padding at the bottom of the table from the action group margin */
|
||||||
|
--pf-c-form__group--m-action--MarginTop: calc(
|
||||||
|
var(--pf-global--spacer--2xl) - var(--pf-global--spacer--sm)
|
||||||
|
);
|
||||||
|
}
|
|
@ -3,7 +3,7 @@ import { Link, useHistory } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Breadcrumb, BreadcrumbItem } from "@patternfly/react-core";
|
import { Breadcrumb, BreadcrumbItem } from "@patternfly/react-core";
|
||||||
|
|
||||||
import { useSubGroups } from "../../groups/GroupsSection";
|
import { useSubGroups } from "../../groups/SubGroupsContext";
|
||||||
import { useRealm } from "../../context/realm-context/RealmContext";
|
import { useRealm } from "../../context/realm-context/RealmContext";
|
||||||
|
|
||||||
export const GroupBreadCrumbs = () => {
|
export const GroupBreadCrumbs = () => {
|
||||||
|
|
|
@ -3,7 +3,7 @@ import { mount } from "enzyme";
|
||||||
import { MemoryRouter } from "react-router-dom";
|
import { MemoryRouter } from "react-router-dom";
|
||||||
|
|
||||||
import { GroupBreadCrumbs } from "../GroupBreadCrumbs";
|
import { GroupBreadCrumbs } from "../GroupBreadCrumbs";
|
||||||
import { SubGroups, useSubGroups } from "../../../groups/GroupsSection";
|
import { SubGroups, useSubGroups } from "../../../groups/SubGroupsContext";
|
||||||
|
|
||||||
const GroupCrumbs = () => {
|
const GroupCrumbs = () => {
|
||||||
const { setSubGroups } = useSubGroups();
|
const { setSubGroups } = useSubGroups();
|
||||||
|
|
70
src/groups/GroupAttributes.tsx
Normal file
70
src/groups/GroupAttributes.tsx
Normal file
|
@ -0,0 +1,70 @@
|
||||||
|
import React, { useEffect } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useFieldArray, useForm } from "react-hook-form";
|
||||||
|
import { AlertVariant } from "@patternfly/react-core";
|
||||||
|
|
||||||
|
import { useAlerts } from "../components/alert/Alerts";
|
||||||
|
import {
|
||||||
|
arrayToAttributes,
|
||||||
|
AttributeForm,
|
||||||
|
AttributesForm,
|
||||||
|
attributesToArray,
|
||||||
|
} from "../components/attribute-form/AttributeForm";
|
||||||
|
import { useAdminClient } from "../context/auth/AdminClient";
|
||||||
|
|
||||||
|
import { getLastId } from "./groupIdUtils";
|
||||||
|
import { useSubGroups } from "./SubGroupsContext";
|
||||||
|
|
||||||
|
export const GroupAttributes = () => {
|
||||||
|
const { t } = useTranslation("groups");
|
||||||
|
const adminClient = useAdminClient();
|
||||||
|
const { addAlert } = useAlerts();
|
||||||
|
const form = useForm<AttributeForm>({ mode: "onChange" });
|
||||||
|
const { fields, append, remove } = useFieldArray({
|
||||||
|
control: form.control,
|
||||||
|
name: "attributes",
|
||||||
|
});
|
||||||
|
|
||||||
|
const id = getLastId(location.pathname);
|
||||||
|
const { currentGroup, subGroups, setSubGroups } = useSubGroups();
|
||||||
|
|
||||||
|
const convertAttributes = (attr?: Record<string, any>) => {
|
||||||
|
const attributes = attributesToArray(attr || currentGroup().attributes!);
|
||||||
|
attributes.push({ key: "", value: "" });
|
||||||
|
return attributes;
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
form.setValue("attributes", convertAttributes());
|
||||||
|
}, [subGroups]);
|
||||||
|
|
||||||
|
const save = async (attributeForm: AttributeForm) => {
|
||||||
|
try {
|
||||||
|
const group = currentGroup();
|
||||||
|
const attributes = arrayToAttributes(attributeForm.attributes);
|
||||||
|
await adminClient.groups.update({ id: id! }, { ...group, attributes });
|
||||||
|
|
||||||
|
setSubGroups([
|
||||||
|
...subGroups.slice(0, subGroups.length - 1),
|
||||||
|
{ ...group, attributes },
|
||||||
|
]);
|
||||||
|
form.setValue("attributes", convertAttributes(attributes));
|
||||||
|
addAlert(t("groupUpdated"), AlertVariant.success);
|
||||||
|
} catch (error) {
|
||||||
|
addAlert(t("groupUpdateError", { error }), AlertVariant.danger);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AttributesForm
|
||||||
|
form={form}
|
||||||
|
save={save}
|
||||||
|
array={{ fields, append, remove }}
|
||||||
|
reset={() =>
|
||||||
|
form.reset({
|
||||||
|
attributes: convertAttributes(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
206
src/groups/GroupTable.tsx
Normal file
206
src/groups/GroupTable.tsx
Normal file
|
@ -0,0 +1,206 @@
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { Link, useHistory, useLocation } from "react-router-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import _ from "lodash";
|
||||||
|
import {
|
||||||
|
AlertVariant,
|
||||||
|
Button,
|
||||||
|
Dropdown,
|
||||||
|
DropdownItem,
|
||||||
|
KebabToggle,
|
||||||
|
ToolbarItem,
|
||||||
|
} from "@patternfly/react-core";
|
||||||
|
import { UsersIcon } from "@patternfly/react-icons";
|
||||||
|
|
||||||
|
import GroupRepresentation from "keycloak-admin/lib/defs/groupRepresentation";
|
||||||
|
import { useAdminClient } from "../context/auth/AdminClient";
|
||||||
|
import { useSubGroups } from "./SubGroupsContext";
|
||||||
|
import { useAlerts } from "../components/alert/Alerts";
|
||||||
|
import { useRealm } from "../context/realm-context/RealmContext";
|
||||||
|
import { KeycloakDataTable } from "../components/table-toolbar/KeycloakDataTable";
|
||||||
|
import { ListEmptyState } from "../components/list-empty-state/ListEmptyState";
|
||||||
|
import { GroupsCreateModal } from "./GroupsCreateModal";
|
||||||
|
import { getLastId } from "./groupIdUtils";
|
||||||
|
|
||||||
|
type GroupTableData = GroupRepresentation & {
|
||||||
|
membersLength?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const GroupTable = () => {
|
||||||
|
const { t } = useTranslation("groups");
|
||||||
|
|
||||||
|
const adminClient = useAdminClient();
|
||||||
|
const { addAlert } = useAlerts();
|
||||||
|
const { realm } = useRealm();
|
||||||
|
const [isKebabOpen, setIsKebabOpen] = useState(false);
|
||||||
|
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||||
|
const [selectedRows, setSelectedRows] = useState<GroupRepresentation[]>([]);
|
||||||
|
|
||||||
|
const { subGroups } = useSubGroups();
|
||||||
|
|
||||||
|
const [key, setKey] = useState(0);
|
||||||
|
const refresh = () => setKey(new Date().getTime());
|
||||||
|
|
||||||
|
const history = useHistory();
|
||||||
|
const location = useLocation();
|
||||||
|
const id = getLastId(location.pathname);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refresh();
|
||||||
|
}, [subGroups]);
|
||||||
|
|
||||||
|
const getMembers = async (id: string) => {
|
||||||
|
const response = await adminClient.groups.listMembers({ id });
|
||||||
|
return response ? response.length : 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const loader = async () => {
|
||||||
|
const groupsData = id
|
||||||
|
? (await adminClient.groups.findOne({ id })).subGroups
|
||||||
|
: await adminClient.groups.find();
|
||||||
|
|
||||||
|
if (groupsData) {
|
||||||
|
const memberPromises = groupsData.map((group) => getMembers(group.id!));
|
||||||
|
const memberData = await Promise.all(memberPromises);
|
||||||
|
return _.cloneDeep(groupsData).map((group: GroupTableData, i) => {
|
||||||
|
group.membersLength = memberData[i];
|
||||||
|
return group;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
history.push(`/${realm}/groups`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteGroup = async (group: GroupRepresentation) => {
|
||||||
|
try {
|
||||||
|
await adminClient.groups.del({
|
||||||
|
id: group.id!,
|
||||||
|
});
|
||||||
|
addAlert(t("groupDelete"), AlertVariant.success);
|
||||||
|
} catch (error) {
|
||||||
|
addAlert(t("groupDeleteError", { error }), AlertVariant.danger);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const multiDelete = async () => {
|
||||||
|
if (selectedRows!.length !== 0) {
|
||||||
|
const chainedPromises = selectedRows!.map((group) => deleteGroup(group));
|
||||||
|
|
||||||
|
await Promise.all(chainedPromises);
|
||||||
|
addAlert(t("groupsDeleted"), AlertVariant.success);
|
||||||
|
setSelectedRows([]);
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const GroupNameCell = (group: GroupTableData) => (
|
||||||
|
<>
|
||||||
|
<Link key={group.id} to={`${location.pathname}/${group.id}`}>
|
||||||
|
{group.name}
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const GroupMemberCell = (group: GroupTableData) => (
|
||||||
|
<div className="keycloak-admin--groups__member-count">
|
||||||
|
<UsersIcon key={`user-icon-${group.id}`} />
|
||||||
|
{group.membersLength}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleModalToggle = () => {
|
||||||
|
setIsCreateModalOpen(!isCreateModalOpen);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<KeycloakDataTable
|
||||||
|
key={key}
|
||||||
|
onSelect={(rows) => setSelectedRows([...rows])}
|
||||||
|
canSelectAll={false}
|
||||||
|
loader={loader}
|
||||||
|
ariaLabelKey="groups:groups"
|
||||||
|
searchPlaceholderKey="groups:searchForGroups"
|
||||||
|
toolbarItem={
|
||||||
|
<>
|
||||||
|
<ToolbarItem>
|
||||||
|
<Button
|
||||||
|
data-testid="openCreateGroupModal"
|
||||||
|
variant="primary"
|
||||||
|
onClick={handleModalToggle}
|
||||||
|
>
|
||||||
|
{t("createGroup")}
|
||||||
|
</Button>
|
||||||
|
</ToolbarItem>
|
||||||
|
<ToolbarItem>
|
||||||
|
<Dropdown
|
||||||
|
toggle={
|
||||||
|
<KebabToggle onToggle={() => setIsKebabOpen(!isKebabOpen)} />
|
||||||
|
}
|
||||||
|
isOpen={isKebabOpen}
|
||||||
|
isPlain
|
||||||
|
dropdownItems={[
|
||||||
|
<DropdownItem
|
||||||
|
key="action"
|
||||||
|
component="button"
|
||||||
|
onClick={() => {
|
||||||
|
multiDelete();
|
||||||
|
setIsKebabOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("common:delete")}
|
||||||
|
</DropdownItem>,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</ToolbarItem>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
title: t("moveTo"),
|
||||||
|
onRowClick: () => console.log("TO DO: Add move to functionality"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("common:delete"),
|
||||||
|
onRowClick: async (group: GroupRepresentation) => {
|
||||||
|
return deleteGroup(group);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
name: "name",
|
||||||
|
displayKey: "groups:groupName",
|
||||||
|
cellRenderer: GroupNameCell,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "members",
|
||||||
|
displayKey: "groups:members",
|
||||||
|
cellRenderer: GroupMemberCell,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
emptyState={
|
||||||
|
<ListEmptyState
|
||||||
|
hasIcon={true}
|
||||||
|
message={t(`noGroupsInThis${id ? "SubGroup" : "Realm"}`)}
|
||||||
|
instructions={t(
|
||||||
|
`noGroupsInThis${id ? "SubGroup" : "Realm"}Instructions`
|
||||||
|
)}
|
||||||
|
primaryActionText={t("createGroup")}
|
||||||
|
onPrimaryAction={handleModalToggle}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{isCreateModalOpen && (
|
||||||
|
<GroupsCreateModal
|
||||||
|
id={id}
|
||||||
|
handleModalToggle={handleModalToggle}
|
||||||
|
refresh={refresh}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
|
@ -1,153 +1,44 @@
|
||||||
import React, {
|
import React, { useEffect, useState } from "react";
|
||||||
createContext,
|
import { useHistory } from "react-router-dom";
|
||||||
ReactNode,
|
|
||||||
useContext,
|
|
||||||
useEffect,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import { Link, useHistory, useLocation } from "react-router-dom";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useErrorHandler } from "react-error-boundary";
|
||||||
import {
|
import {
|
||||||
Button,
|
|
||||||
Dropdown,
|
|
||||||
DropdownItem,
|
DropdownItem,
|
||||||
KebabToggle,
|
|
||||||
PageSection,
|
PageSection,
|
||||||
PageSectionVariants,
|
PageSectionVariants,
|
||||||
ToolbarItem,
|
|
||||||
AlertVariant,
|
AlertVariant,
|
||||||
|
Tab,
|
||||||
|
TabTitleText,
|
||||||
|
Tabs,
|
||||||
} from "@patternfly/react-core";
|
} from "@patternfly/react-core";
|
||||||
import { UsersIcon } from "@patternfly/react-icons";
|
|
||||||
import GroupRepresentation from "keycloak-admin/lib/defs/groupRepresentation";
|
import GroupRepresentation from "keycloak-admin/lib/defs/groupRepresentation";
|
||||||
|
|
||||||
import { GroupsCreateModal } from "./GroupsCreateModal";
|
|
||||||
import { ViewHeader } from "../components/view-header/ViewHeader";
|
import { ViewHeader } from "../components/view-header/ViewHeader";
|
||||||
import { ListEmptyState } from "../components/list-empty-state/ListEmptyState";
|
import { asyncStateFetch, useAdminClient } from "../context/auth/AdminClient";
|
||||||
import { useAdminClient } from "../context/auth/AdminClient";
|
|
||||||
import { useAlerts } from "../components/alert/Alerts";
|
import { useAlerts } from "../components/alert/Alerts";
|
||||||
import { KeycloakDataTable } from "../components/table-toolbar/KeycloakDataTable";
|
|
||||||
|
|
||||||
import "./GroupsSection.css";
|
|
||||||
import { useRealm } from "../context/realm-context/RealmContext";
|
import { useRealm } from "../context/realm-context/RealmContext";
|
||||||
|
|
||||||
type GroupTableData = GroupRepresentation & {
|
import { useSubGroups } from "./SubGroupsContext";
|
||||||
membersLength?: number;
|
import { GroupTable } from "./GroupTable";
|
||||||
};
|
import { getId, getLastId } from "./groupIdUtils";
|
||||||
|
import { Members } from "./Members";
|
||||||
|
import { GroupAttributes } from "./GroupAttributes";
|
||||||
|
|
||||||
type SubGroupsProps = {
|
import "./GroupsSection.css";
|
||||||
subGroups: GroupRepresentation[];
|
|
||||||
setSubGroups: (group: GroupRepresentation[]) => void;
|
|
||||||
clear: () => void;
|
|
||||||
remove: (group: GroupRepresentation) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const SubGroupContext = createContext<SubGroupsProps>({
|
|
||||||
subGroups: [],
|
|
||||||
setSubGroups: () => {},
|
|
||||||
clear: () => {},
|
|
||||||
remove: () => {},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const SubGroups = ({ children }: { children: ReactNode }) => {
|
|
||||||
const [subGroups, setSubGroups] = useState<GroupRepresentation[]>([]);
|
|
||||||
|
|
||||||
const clear = () => setSubGroups([]);
|
|
||||||
const remove = (group: GroupRepresentation) =>
|
|
||||||
setSubGroups(
|
|
||||||
subGroups.slice(
|
|
||||||
0,
|
|
||||||
subGroups.findIndex((g) => g.id === group.id)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<SubGroupContext.Provider
|
|
||||||
value={{ subGroups, setSubGroups, clear, remove }}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</SubGroupContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useSubGroups = () => useContext(SubGroupContext);
|
|
||||||
|
|
||||||
const getId = (pathname: string) => {
|
|
||||||
const pathParts = pathname.substr(1).split("/");
|
|
||||||
return pathParts.length > 1 ? pathParts.splice(2) : undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getLastId = (pathname: string) => {
|
|
||||||
const pathParts = getId(pathname);
|
|
||||||
return pathParts ? pathParts[pathParts.length - 1] : undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const GroupsSection = () => {
|
export const GroupsSection = () => {
|
||||||
const { t } = useTranslation("groups");
|
const { t } = useTranslation("groups");
|
||||||
|
const [activeTab, setActiveTab] = useState(0);
|
||||||
|
|
||||||
const adminClient = useAdminClient();
|
const adminClient = useAdminClient();
|
||||||
const [isKebabOpen, setIsKebabOpen] = useState(false);
|
|
||||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
|
||||||
const [selectedRows, setSelectedRows] = useState<GroupRepresentation[]>([]);
|
|
||||||
const { subGroups, setSubGroups } = useSubGroups();
|
const { subGroups, setSubGroups } = useSubGroups();
|
||||||
const { addAlert } = useAlerts();
|
const { addAlert } = useAlerts();
|
||||||
const { realm } = useRealm();
|
const { realm } = useRealm();
|
||||||
|
const errorHandler = useErrorHandler();
|
||||||
|
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
|
|
||||||
const location = useLocation();
|
|
||||||
const id = getLastId(location.pathname);
|
const id = getLastId(location.pathname);
|
||||||
|
|
||||||
const [key, setKey] = useState("");
|
|
||||||
const refresh = () => setKey(`${new Date().getTime()}`);
|
|
||||||
|
|
||||||
const getMembers = async (id: string) => {
|
|
||||||
const response = await adminClient.groups.listMembers({ id });
|
|
||||||
return response ? response.length : 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
const loader = async () => {
|
|
||||||
let groupsData;
|
|
||||||
if (!id) {
|
|
||||||
groupsData = await adminClient.groups.find();
|
|
||||||
} else {
|
|
||||||
const ids = getId(location.pathname);
|
|
||||||
const isNavigationStateInValid = ids && ids.length !== subGroups.length;
|
|
||||||
if (isNavigationStateInValid) {
|
|
||||||
const groups = [];
|
|
||||||
for (const i of ids!) {
|
|
||||||
const group = await adminClient.groups.findOne({ id: i });
|
|
||||||
if (group) groups.push(group);
|
|
||||||
}
|
|
||||||
setSubGroups(groups);
|
|
||||||
groupsData = groups.pop()?.subGroups!;
|
|
||||||
} else {
|
|
||||||
const group = await adminClient.groups.findOne({ id });
|
|
||||||
if (group) {
|
|
||||||
setSubGroups([...subGroups, group]);
|
|
||||||
groupsData = group.subGroups!;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (groupsData) {
|
|
||||||
const memberPromises = groupsData.map((group) => getMembers(group.id!));
|
|
||||||
const memberData = await Promise.all(memberPromises);
|
|
||||||
return groupsData.map((group: GroupTableData, i) => {
|
|
||||||
group.membersLength = memberData[i];
|
|
||||||
return group;
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
history.push(`/${realm}/groups`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return [];
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
refresh();
|
|
||||||
}, [id]);
|
|
||||||
|
|
||||||
const handleModalToggle = () => {
|
|
||||||
setIsCreateModalOpen(!isCreateModalOpen);
|
|
||||||
};
|
|
||||||
|
|
||||||
const deleteGroup = async (group: GroupRepresentation) => {
|
const deleteGroup = async (group: GroupRepresentation) => {
|
||||||
try {
|
try {
|
||||||
await adminClient.groups.del({
|
await adminClient.groups.del({
|
||||||
|
@ -160,30 +51,37 @@ export const GroupsSection = () => {
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const multiDelete = async () => {
|
useEffect(
|
||||||
if (selectedRows!.length !== 0) {
|
() =>
|
||||||
const chainedPromises = selectedRows!.map((group) => deleteGroup(group));
|
asyncStateFetch(
|
||||||
|
async () => {
|
||||||
await Promise.all(chainedPromises);
|
const ids = getId(location.pathname);
|
||||||
addAlert(t("groupsDeleted"), AlertVariant.success);
|
const isNavigationStateInValid =
|
||||||
setSelectedRows([]);
|
ids && ids.length !== subGroups.length + 1;
|
||||||
refresh();
|
if (isNavigationStateInValid) {
|
||||||
}
|
const groups: GroupRepresentation[] = [];
|
||||||
};
|
for (const i of ids!) {
|
||||||
|
const group = await adminClient.groups.findOne({ id: i });
|
||||||
const GroupNameCell = (group: GroupTableData) => (
|
if (group) groups.push(group);
|
||||||
<>
|
}
|
||||||
<Link key={group.id} to={`${location.pathname}/${group.id}`}>
|
return groups;
|
||||||
{group.name}
|
} else {
|
||||||
</Link>
|
if (id) {
|
||||||
</>
|
const group = await adminClient.groups.findOne({ id: id });
|
||||||
);
|
if (group) {
|
||||||
|
return [...subGroups, group];
|
||||||
const GroupMemberCell = (group: GroupTableData) => (
|
} else {
|
||||||
<div className="keycloak-admin--groups__member-count">
|
return subGroups;
|
||||||
<UsersIcon key={`user-icon-${group.id}`} />
|
}
|
||||||
{group.membersLength}
|
} else {
|
||||||
</div>
|
return subGroups;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(groups: GroupRepresentation[]) => setSubGroups(groups),
|
||||||
|
errorHandler
|
||||||
|
),
|
||||||
|
[id]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -216,93 +114,37 @@ export const GroupsSection = () => {
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<PageSection variant={PageSectionVariants.light}>
|
<PageSection variant={PageSectionVariants.light}>
|
||||||
<KeycloakDataTable
|
{subGroups.length > 0 && (
|
||||||
key={key}
|
<Tabs
|
||||||
onSelect={(rows) => setSelectedRows([...rows])}
|
activeKey={activeTab}
|
||||||
canSelectAll={false}
|
isSecondary
|
||||||
loader={loader}
|
onSelect={(_, key) => setActiveTab(key as number)}
|
||||||
ariaLabelKey="groups:groups"
|
isBox
|
||||||
searchPlaceholderKey="groups:searchForGroups"
|
>
|
||||||
toolbarItem={
|
<Tab
|
||||||
<>
|
data-testid="groups"
|
||||||
<ToolbarItem>
|
eventKey={0}
|
||||||
<Button
|
title={<TabTitleText>{t("childGroups")}</TabTitleText>}
|
||||||
data-testid="openCreateGroupModal"
|
>
|
||||||
variant="primary"
|
<GroupTable />
|
||||||
onClick={handleModalToggle}
|
</Tab>
|
||||||
>
|
<Tab
|
||||||
{t("createGroup")}
|
data-testid="members"
|
||||||
</Button>
|
eventKey={1}
|
||||||
</ToolbarItem>
|
title={<TabTitleText>{t("members")}</TabTitleText>}
|
||||||
<ToolbarItem>
|
>
|
||||||
<Dropdown
|
<Members />
|
||||||
toggle={
|
</Tab>
|
||||||
<KebabToggle
|
<Tab
|
||||||
onToggle={() => setIsKebabOpen(!isKebabOpen)}
|
data-testid="attributes"
|
||||||
/>
|
eventKey={2}
|
||||||
}
|
title={<TabTitleText>{t("attributes")}</TabTitleText>}
|
||||||
isOpen={isKebabOpen}
|
>
|
||||||
isPlain
|
<GroupAttributes />
|
||||||
dropdownItems={[
|
</Tab>
|
||||||
<DropdownItem
|
</Tabs>
|
||||||
key="action"
|
|
||||||
component="button"
|
|
||||||
onClick={() => {
|
|
||||||
multiDelete();
|
|
||||||
setIsKebabOpen(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("common:delete")}
|
|
||||||
</DropdownItem>,
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</ToolbarItem>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
actions={[
|
|
||||||
{
|
|
||||||
title: t("moveTo"),
|
|
||||||
onRowClick: () => console.log("TO DO: Add move to functionality"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t("common:delete"),
|
|
||||||
onRowClick: async (group: GroupRepresentation) => {
|
|
||||||
return deleteGroup(group);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
columns={[
|
|
||||||
{
|
|
||||||
name: "name",
|
|
||||||
displayKey: "groups:groupName",
|
|
||||||
cellRenderer: GroupNameCell,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "members",
|
|
||||||
displayKey: "groups:members",
|
|
||||||
cellRenderer: GroupMemberCell,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
emptyState={
|
|
||||||
<ListEmptyState
|
|
||||||
hasIcon={true}
|
|
||||||
message={t(`noGroupsInThis${id ? "SubGroup" : "Realm"}`)}
|
|
||||||
instructions={t(
|
|
||||||
`noGroupsInThis${id ? "SubGroup" : "Realm"}Instructions`
|
|
||||||
)}
|
|
||||||
primaryActionText={t("createGroup")}
|
|
||||||
onPrimaryAction={() => handleModalToggle()}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{isCreateModalOpen && (
|
|
||||||
<GroupsCreateModal
|
|
||||||
id={id}
|
|
||||||
handleModalToggle={handleModalToggle}
|
|
||||||
refresh={refresh}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
|
{subGroups.length === 0 && <GroupTable />}
|
||||||
</PageSection>
|
</PageSection>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
133
src/groups/Members.tsx
Normal file
133
src/groups/Members.tsx
Normal file
|
@ -0,0 +1,133 @@
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import _ from "lodash";
|
||||||
|
import { Button, Checkbox, ToolbarItem } from "@patternfly/react-core";
|
||||||
|
|
||||||
|
import GroupRepresentation from "keycloak-admin/lib/defs/groupRepresentation";
|
||||||
|
import UserRepresentation from "keycloak-admin/lib/defs/userRepresentation";
|
||||||
|
import { KeycloakDataTable } from "../components/table-toolbar/KeycloakDataTable";
|
||||||
|
import { useAdminClient } from "../context/auth/AdminClient";
|
||||||
|
import { emptyFormatter } from "../util";
|
||||||
|
|
||||||
|
import { getLastId } from "./groupIdUtils";
|
||||||
|
import { useSubGroups } from "./SubGroupsContext";
|
||||||
|
|
||||||
|
type MembersOf = UserRepresentation & {
|
||||||
|
membership: GroupRepresentation[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Members = () => {
|
||||||
|
const { t } = useTranslation("groups");
|
||||||
|
const adminClient = useAdminClient();
|
||||||
|
const id = getLastId(location.pathname);
|
||||||
|
const [includeSubGroup, setIncludeSubGroup] = useState(false);
|
||||||
|
const { currentGroup, subGroups } = useSubGroups();
|
||||||
|
|
||||||
|
const [key, setKey] = useState(0);
|
||||||
|
const refresh = () => setKey(new Date().getTime());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refresh();
|
||||||
|
}, [id, subGroups, includeSubGroup]);
|
||||||
|
|
||||||
|
const getMembership = async (id: string) =>
|
||||||
|
await adminClient.users.listGroups({ id: id! });
|
||||||
|
|
||||||
|
const getSubGroups = (groups: GroupRepresentation[]) => {
|
||||||
|
let subGroups: GroupRepresentation[] = [];
|
||||||
|
for (const group of groups!) {
|
||||||
|
subGroups.push(group);
|
||||||
|
const subs = getSubGroups(group.subGroups!);
|
||||||
|
subGroups = subGroups.concat(subs);
|
||||||
|
}
|
||||||
|
return subGroups;
|
||||||
|
};
|
||||||
|
|
||||||
|
const loader = async (first?: number, max?: number) => {
|
||||||
|
let members = await adminClient.groups.listMembers({
|
||||||
|
id: id!,
|
||||||
|
first,
|
||||||
|
max,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (includeSubGroup) {
|
||||||
|
const subGroups = getSubGroups(currentGroup().subGroups!);
|
||||||
|
for (const group of subGroups) {
|
||||||
|
members = members.concat(
|
||||||
|
await adminClient.groups.listMembers({ id: group.id! })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
members = _.uniqBy(members, (member) => member.username);
|
||||||
|
}
|
||||||
|
|
||||||
|
const memberOfPromises = await Promise.all(
|
||||||
|
members.map((member) => getMembership(member.id!))
|
||||||
|
);
|
||||||
|
return members.map((member: UserRepresentation, i) => {
|
||||||
|
return { ...member, membership: memberOfPromises[i] };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const MemberOfRenderer = (member: MembersOf) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{member.membership.map((group) => (
|
||||||
|
<>{group.path} </>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<KeycloakDataTable
|
||||||
|
key={key}
|
||||||
|
loader={loader}
|
||||||
|
ariaLabelKey="groups:members"
|
||||||
|
isPaginated
|
||||||
|
toolbarItem={
|
||||||
|
<>
|
||||||
|
<ToolbarItem>
|
||||||
|
<Button data-testid="addMember" variant="primary">
|
||||||
|
{t("addMember")}
|
||||||
|
</Button>
|
||||||
|
</ToolbarItem>
|
||||||
|
<ToolbarItem>
|
||||||
|
<Checkbox
|
||||||
|
data-testid="includeSubGroupsCheck"
|
||||||
|
label={t("includeSubGroups")}
|
||||||
|
id="kc-include-sub-groups"
|
||||||
|
isChecked={includeSubGroup}
|
||||||
|
onChange={() => setIncludeSubGroup(!includeSubGroup)}
|
||||||
|
/>
|
||||||
|
</ToolbarItem>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
name: "username",
|
||||||
|
displayKey: "common:name",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "email",
|
||||||
|
displayKey: "groups:email",
|
||||||
|
cellFormatters: [emptyFormatter()],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "firstName",
|
||||||
|
displayKey: "groups:firstName",
|
||||||
|
cellFormatters: [emptyFormatter()],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "lastName",
|
||||||
|
displayKey: "groups:lastName",
|
||||||
|
cellFormatters: [emptyFormatter()],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "membership",
|
||||||
|
displayKey: "groups:membership",
|
||||||
|
cellRenderer: MemberOfRenderer,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
43
src/groups/SubGroupsContext.tsx
Normal file
43
src/groups/SubGroupsContext.tsx
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
import React, { createContext, ReactNode, useContext, useState } from "react";
|
||||||
|
import GroupRepresentation from "keycloak-admin/lib/defs/groupRepresentation";
|
||||||
|
|
||||||
|
type SubGroupsProps = {
|
||||||
|
subGroups: GroupRepresentation[];
|
||||||
|
setSubGroups: (group: GroupRepresentation[]) => void;
|
||||||
|
clear: () => void;
|
||||||
|
remove: (group: GroupRepresentation) => void;
|
||||||
|
currentGroup: () => GroupRepresentation;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SubGroupContext = createContext<SubGroupsProps>({
|
||||||
|
subGroups: [],
|
||||||
|
setSubGroups: () => {},
|
||||||
|
clear: () => {},
|
||||||
|
remove: () => {},
|
||||||
|
currentGroup: () => {
|
||||||
|
return {};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const SubGroups = ({ children }: { children: ReactNode }) => {
|
||||||
|
const [subGroups, setSubGroups] = useState<GroupRepresentation[]>([]);
|
||||||
|
|
||||||
|
const clear = () => setSubGroups([]);
|
||||||
|
const remove = (group: GroupRepresentation) =>
|
||||||
|
setSubGroups(
|
||||||
|
subGroups.slice(
|
||||||
|
0,
|
||||||
|
subGroups.findIndex((g) => g.id === group.id)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const currentGroup = () => subGroups[subGroups.length - 1];
|
||||||
|
return (
|
||||||
|
<SubGroupContext.Provider
|
||||||
|
value={{ subGroups, setSubGroups, clear, remove, currentGroup }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SubGroupContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useSubGroups = () => useContext(SubGroupContext);
|
9
src/groups/groupIdUtils.ts
Normal file
9
src/groups/groupIdUtils.ts
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
export const getId = (pathname: string) => {
|
||||||
|
const pathParts = pathname.substr(1).split("/");
|
||||||
|
return pathParts.length > 1 ? pathParts.splice(2) : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getLastId = (pathname: string) => {
|
||||||
|
const pathParts = getId(pathname);
|
||||||
|
return pathParts ? pathParts[pathParts.length - 1] : undefined;
|
||||||
|
};
|
|
@ -1,6 +1,7 @@
|
||||||
{
|
{
|
||||||
"groups": {
|
"groups": {
|
||||||
"groups": "Groups",
|
"groups": "Groups",
|
||||||
|
"childGroups": "Child groups",
|
||||||
"createGroup": "Create group",
|
"createGroup": "Create group",
|
||||||
"groupName": "Group name",
|
"groupName": "Group name",
|
||||||
"searchForGroups": "Search for groups",
|
"searchForGroups": "Search for groups",
|
||||||
|
@ -10,6 +11,9 @@
|
||||||
"deleteGroup": "Delete group",
|
"deleteGroup": "Delete group",
|
||||||
"search": "Search",
|
"search": "Search",
|
||||||
"members": "Members",
|
"members": "Members",
|
||||||
|
"searchMembers": "Search members",
|
||||||
|
"addMember": "Add member",
|
||||||
|
"includeSubGroups": "Include sub-group users",
|
||||||
"path": "Path",
|
"path": "Path",
|
||||||
"moveTo": "Move to",
|
"moveTo": "Move to",
|
||||||
"tableOfGroups": "Table of groups",
|
"tableOfGroups": "Table of groups",
|
||||||
|
@ -18,6 +22,10 @@
|
||||||
"couldNotCreateGroup": "Could not create group {{error}}",
|
"couldNotCreateGroup": "Could not create group {{error}}",
|
||||||
"createAGroup": "Create a group",
|
"createAGroup": "Create a group",
|
||||||
"create": "Create",
|
"create": "Create",
|
||||||
|
"email": "Email",
|
||||||
|
"lastName": "Last name",
|
||||||
|
"firstName": "First name",
|
||||||
|
"membership": "Membership",
|
||||||
"noSearchResults": "No search results",
|
"noSearchResults": "No search results",
|
||||||
"noSearchResultsInstructions": "Click on the search bar above to search for groups",
|
"noSearchResultsInstructions": "Click on the search bar above to search for groups",
|
||||||
"noGroupsInThisRealm": "No groups in this realm",
|
"noGroupsInThisRealm": "No groups in this realm",
|
||||||
|
@ -26,6 +34,9 @@
|
||||||
"noGroupsInThisSubGroupInstructions": "You haven't created any groups in this sub group.",
|
"noGroupsInThisSubGroupInstructions": "You haven't created any groups in this sub group.",
|
||||||
"groupDelete": "Group deleted",
|
"groupDelete": "Group deleted",
|
||||||
"groupsDeleted": "Groups deleted",
|
"groupsDeleted": "Groups deleted",
|
||||||
"groupDeleteError": "Error deleting group {error}"
|
"groupDeleteError": "Error deleting group {error}",
|
||||||
|
"attributes": "Attributes",
|
||||||
|
"groupUpdated": "Group updated",
|
||||||
|
"groupUpdateError": "Error updating group {error}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,13 +9,14 @@ import {
|
||||||
ModalVariant,
|
ModalVariant,
|
||||||
} from "@patternfly/react-core";
|
} from "@patternfly/react-core";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useForm } from "react-hook-form";
|
import { asyncStateFetch, 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 { CaretDownIcon, FilterIcon } from "@patternfly/react-icons";
|
import { CaretDownIcon, FilterIcon } from "@patternfly/react-icons";
|
||||||
import { AliasRendererComponent } from "./AliasRendererComponent";
|
import { AliasRendererComponent } from "./AliasRendererComponent";
|
||||||
|
import _ from "lodash";
|
||||||
|
import { useErrorHandler } from "react-error-boundary";
|
||||||
|
|
||||||
export type AssociatedRolesModalProps = {
|
export type AssociatedRolesModalProps = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
@ -24,27 +25,12 @@ export type AssociatedRolesModalProps = {
|
||||||
existingCompositeRoles: RoleRepresentation[];
|
existingCompositeRoles: RoleRepresentation[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const attributesToArray = (attributes: { [key: string]: string }): any => {
|
|
||||||
if (!attributes || Object.keys(attributes).length === 0) {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
key: "",
|
|
||||||
value: "",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
return Object.keys(attributes).map((key) => ({
|
|
||||||
key: key,
|
|
||||||
value: attributes[key],
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
export const AssociatedRolesModal = (props: AssociatedRolesModalProps) => {
|
export const AssociatedRolesModal = (props: AssociatedRolesModalProps) => {
|
||||||
const { t } = useTranslation("roles");
|
const { t } = useTranslation("roles");
|
||||||
const form = useForm<RoleRepresentation>({ mode: "onChange" });
|
|
||||||
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 errorHandler = useErrorHandler();
|
||||||
|
|
||||||
const [isFilterDropdownOpen, setIsFilterDropdownOpen] = useState(false);
|
const [isFilterDropdownOpen, setIsFilterDropdownOpen] = useState(false);
|
||||||
const [filterType, setFilterType] = useState("roles");
|
const [filterType, setFilterType] = useState("roles");
|
||||||
|
@ -54,18 +40,7 @@ export const AssociatedRolesModal = (props: AssociatedRolesModalProps) => {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
|
|
||||||
const alphabetize = (rolesList: RoleRepresentation[]) => {
|
const alphabetize = (rolesList: RoleRepresentation[]) => {
|
||||||
return rolesList.sort((r1, r2) => {
|
return _.sortBy(rolesList, (role) => role.name?.toUpperCase());
|
||||||
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 () => {
|
||||||
|
@ -127,26 +102,17 @@ export const AssociatedRolesModal = (props: AssociatedRolesModalProps) => {
|
||||||
}, [filterType]);
|
}, [filterType]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
if (id) {
|
||||||
if (id) {
|
return asyncStateFetch(
|
||||||
const fetchedRole = await adminClient.roles.findOneById({ id });
|
() => adminClient.roles.findOneById({ id }),
|
||||||
setName(fetchedRole.name!);
|
(fetchedRole) => setName(fetchedRole.name!),
|
||||||
setupForm(fetchedRole);
|
errorHandler
|
||||||
} else {
|
);
|
||||||
setName(t("createRole"));
|
} else {
|
||||||
}
|
setName(t("createRole"));
|
||||||
})();
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const setupForm = (role: RoleRepresentation) =>
|
|
||||||
Object.entries(role).map((entry) => {
|
|
||||||
if (entry[0] === "attributes") {
|
|
||||||
form.setValue(entry[0], attributesToArray(entry[1]));
|
|
||||||
} else {
|
|
||||||
form.setValue(entry[0], entry[1]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const onFilterDropdownToggle = () => {
|
const onFilterDropdownToggle = () => {
|
||||||
setIsFilterDropdownOpen(!isFilterDropdownOpen);
|
setIsFilterDropdownOpen(!isFilterDropdownOpen);
|
||||||
};
|
};
|
||||||
|
@ -224,7 +190,6 @@ export const AssociatedRolesModal = (props: AssociatedRolesModalProps) => {
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
canSelectAll
|
canSelectAll
|
||||||
isPaginated
|
|
||||||
onSelect={(rows) => {
|
onSelect={(rows) => {
|
||||||
setSelectedRows([...rows]);
|
setSelectedRows([...rows]);
|
||||||
}}
|
}}
|
||||||
|
@ -245,7 +210,6 @@ export const AssociatedRolesModal = (props: AssociatedRolesModalProps) => {
|
||||||
message={t("noRolesInThisRealm")}
|
message={t("noRolesInThisRealm")}
|
||||||
instructions={t("noRolesInThisRealmInstructions")}
|
instructions={t("noRolesInThisRealmInstructions")}
|
||||||
primaryActionText={t("createRole")}
|
primaryActionText={t("createRole")}
|
||||||
// onPrimaryAction={goToCreate}
|
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -7,6 +7,7 @@ import {
|
||||||
ButtonVariant,
|
ButtonVariant,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
PageSection,
|
PageSection,
|
||||||
|
ToolbarItem,
|
||||||
} from "@patternfly/react-core";
|
} from "@patternfly/react-core";
|
||||||
import RoleRepresentation from "keycloak-admin/lib/defs/roleRepresentation";
|
import RoleRepresentation from "keycloak-admin/lib/defs/roleRepresentation";
|
||||||
import { ListEmptyState } from "../components/list-empty-state/ListEmptyState";
|
import { ListEmptyState } from "../components/list-empty-state/ListEmptyState";
|
||||||
|
@ -193,31 +194,36 @@ export const AssociatedRolesTab = ({
|
||||||
}}
|
}}
|
||||||
toolbarItem={
|
toolbarItem={
|
||||||
<>
|
<>
|
||||||
<Checkbox
|
<ToolbarItem>
|
||||||
label="Hide inherited roles"
|
<Checkbox
|
||||||
key="associated-roles-check"
|
label="Hide inherited roles"
|
||||||
id="kc-hide-inherited-roles-checkbox"
|
key="associated-roles-check"
|
||||||
onChange={() => setIsInheritedHidden(!isInheritedHidden)}
|
id="kc-hide-inherited-roles-checkbox"
|
||||||
isChecked={isInheritedHidden}
|
onChange={() => setIsInheritedHidden(!isInheritedHidden)}
|
||||||
/>
|
isChecked={isInheritedHidden}
|
||||||
<Button
|
/>
|
||||||
className="kc-add-role-button"
|
</ToolbarItem>
|
||||||
key="add-role-button"
|
<ToolbarItem>
|
||||||
onClick={() => toggleModal()}
|
<Button
|
||||||
data-testid="add-role-button"
|
key="add-role-button"
|
||||||
>
|
onClick={() => toggleModal()}
|
||||||
{t("addRole")}
|
data-testid="add-role-button"
|
||||||
</Button>
|
>
|
||||||
<Button
|
{t("addRole")}
|
||||||
variant="link"
|
</Button>
|
||||||
isDisabled={selectedRows.length === 0}
|
</ToolbarItem>
|
||||||
key="remove-role-button"
|
<ToolbarItem>
|
||||||
onClick={() => {
|
<Button
|
||||||
toggleDeleteAssociatedRolesDialog();
|
variant="link"
|
||||||
}}
|
isDisabled={selectedRows.length === 0}
|
||||||
>
|
key="remove-role-button"
|
||||||
{t("removeRoles")}
|
onClick={() => {
|
||||||
</Button>
|
toggleDeleteAssociatedRolesDialog();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("removeRoles")}
|
||||||
|
</Button>
|
||||||
|
</ToolbarItem>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
actions={[
|
actions={[
|
||||||
|
|
|
@ -15,7 +15,12 @@ import { useAlerts } from "../components/alert/Alerts";
|
||||||
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 Composites from "keycloak-admin/lib/defs/roleRepresentation";
|
import Composites from "keycloak-admin/lib/defs/roleRepresentation";
|
||||||
import { KeyValueType, RoleAttributes } from "./RoleAttributes";
|
import {
|
||||||
|
KeyValueType,
|
||||||
|
AttributesForm,
|
||||||
|
attributesToArray,
|
||||||
|
arrayToAttributes,
|
||||||
|
} from "../components/attribute-form/AttributeForm";
|
||||||
import { ViewHeader } from "../components/view-header/ViewHeader";
|
import { ViewHeader } from "../components/view-header/ViewHeader";
|
||||||
import { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog";
|
import { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog";
|
||||||
import { RealmRoleForm } from "./RealmRoleForm";
|
import { RealmRoleForm } from "./RealmRoleForm";
|
||||||
|
@ -25,26 +30,6 @@ import { KeycloakTabs } from "../components/keycloak-tabs/KeycloakTabs";
|
||||||
import { AssociatedRolesTab } from "./AssociatedRolesTab";
|
import { AssociatedRolesTab } from "./AssociatedRolesTab";
|
||||||
import { UsersInRoleTab } from "./UsersInRoleTab";
|
import { UsersInRoleTab } from "./UsersInRoleTab";
|
||||||
|
|
||||||
const arrayToAttributes = (attributeArray: KeyValueType[]) => {
|
|
||||||
const initValue: { [index: string]: string[] } = {};
|
|
||||||
return attributeArray.reduce((acc, attribute) => {
|
|
||||||
acc[attribute.key] = [attribute.value];
|
|
||||||
return acc;
|
|
||||||
}, initValue);
|
|
||||||
};
|
|
||||||
|
|
||||||
const attributesToArray = (attributes?: {
|
|
||||||
[key: string]: string[];
|
|
||||||
}): KeyValueType[] => {
|
|
||||||
if (!attributes || Object.keys(attributes).length === 0) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return Object.keys(attributes).map((key) => ({
|
|
||||||
key: key,
|
|
||||||
value: attributes[key][0],
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
export type RoleFormType = Omit<RoleRepresentation, "attributes"> & {
|
export type RoleFormType = Omit<RoleRepresentation, "attributes"> & {
|
||||||
attributes: KeyValueType[];
|
attributes: KeyValueType[];
|
||||||
};
|
};
|
||||||
|
@ -329,7 +314,7 @@ export const RealmRoleTabs = () => {
|
||||||
eventKey="attributes"
|
eventKey="attributes"
|
||||||
title={<TabTitleText>{t("attributes")}</TabTitleText>}
|
title={<TabTitleText>{t("attributes")}</TabTitleText>}
|
||||||
>
|
>
|
||||||
<RoleAttributes
|
<AttributesForm
|
||||||
form={form}
|
form={form}
|
||||||
save={save}
|
save={save}
|
||||||
array={{ fields, append, remove }}
|
array={{ fields, append, remove }}
|
||||||
|
|
|
@ -1,30 +1,4 @@
|
||||||
.kc-role-attributes__table {
|
|
||||||
/* even though the table is borderless, make the border under the th transparent */
|
|
||||||
--pf-c-table--border-width--base: 0;
|
|
||||||
--pf-c-table--m-compact--cell--first-last-child--PaddingLeft: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kc-role-attributes__plus-icon {
|
|
||||||
/* shift the button left to adjust for table cell padding */
|
|
||||||
margin-left: calc(var(--pf-global--spacer--md) * -1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pf-c-button.kc-role-attributes__minus-icon {
|
|
||||||
/* shift the button left to adjust for table cell padding */
|
|
||||||
margin-left: calc(var(--pf-global--spacer--md) * -1);
|
|
||||||
color: var(--pf-c-button--m-plain--Color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.kc-add-role-button {
|
|
||||||
margin-left: var(--pf-global--spacer--lg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.kc-role-attributes__action-group {
|
|
||||||
/* subtract the padding at the bottom of the table from the action group margin */
|
|
||||||
--pf-c-form__group--m-action--MarginTop: calc(
|
|
||||||
var(--pf-global--spacer--2xl) - var(--pf-global--spacer--sm)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
.kc-who-will-appear-button {
|
.kc-who-will-appear-button {
|
||||||
padding-left: 0px;
|
padding-left: 0px;
|
||||||
|
|
Loading…
Reference in a new issue