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 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 Masthead from "../support/pages/admin_console/Masthead";
|
||||
import SidebarPage from "../support/pages/admin_console/SidebarPage";
|
||||
import LoginPage from "../support/pages/LoginPage";
|
||||
import ViewHeaderPage from "../support/pages/ViewHeaderPage";
|
||||
import AdminClient from "../support/util/AdminClient";
|
||||
|
||||
describe("Group test", () => {
|
||||
const loginPage = new LoginPage();
|
||||
|
@ -39,8 +41,6 @@ describe("Group test", () => {
|
|||
// Delete
|
||||
listingPage.deleteItem(groupName);
|
||||
masthead.checkNotificationMessage("Group deleted");
|
||||
|
||||
listingPage.itemExist(groupName, false);
|
||||
});
|
||||
|
||||
const searchGroupPage = new SearchGroupPage();
|
||||
|
@ -50,4 +50,56 @@ describe("Group test", () => {
|
|||
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];
|
||||
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 { PageBreadCrumbs } from "./components/bread-crumb/PageBreadCrumbs";
|
||||
import { ForbiddenSection } from "./ForbiddenSection";
|
||||
import { SubGroups } from "./groups/GroupsSection";
|
||||
import { SubGroups } from "./groups/SubGroupsContext";
|
||||
import { useRealm } from "./context/realm-context/RealmContext";
|
||||
import { useAdminClient, asyncStateFetch } from "./context/auth/AdminClient";
|
||||
import { ErrorRenderer } from "./components/error/ErrorRenderer";
|
||||
|
|
|
@ -12,16 +12,19 @@ import {
|
|||
} from "@patternfly/react-table";
|
||||
import { MinusCircleIcon, PlusCircleIcon } from "@patternfly/react-icons";
|
||||
|
||||
import { FormAccess } from "../components/form-access/FormAccess";
|
||||
import { RoleFormType } from "./RealmRoleTabs";
|
||||
import { FormAccess } from "../form-access/FormAccess";
|
||||
|
||||
import "./RealmRolesSection.css";
|
||||
import "./attribute-form.css";
|
||||
|
||||
export type KeyValueType = { key: string; value: string };
|
||||
|
||||
type RoleAttributesProps = {
|
||||
form: UseFormMethods<RoleFormType>;
|
||||
save: (role: RoleFormType) => void;
|
||||
export type AttributeForm = {
|
||||
attributes: KeyValueType[];
|
||||
};
|
||||
|
||||
export type AttributesFormProps = {
|
||||
form: UseFormMethods<AttributeForm>;
|
||||
save: (model: AttributeForm) => void;
|
||||
reset: () => void;
|
||||
array: {
|
||||
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 },
|
||||
save,
|
||||
array: { fields, append, remove },
|
||||
reset,
|
||||
}: RoleAttributesProps) => {
|
||||
}: AttributesFormProps) => {
|
||||
const { t } = useTranslation("roles");
|
||||
|
||||
const columns = ["Key", "Value"];
|
||||
|
@ -48,7 +71,7 @@ export const RoleAttributes = ({
|
|||
<>
|
||||
<FormAccess role="manage-realm" onSubmit={handleSubmit(save)}>
|
||||
<TableComposable
|
||||
className="kc-role-attributes__table"
|
||||
className="kc-attributes__table"
|
||||
aria-label="Role attribute keys and values"
|
||||
variant="compact"
|
||||
borders={false}
|
||||
|
@ -93,7 +116,6 @@ export const RoleAttributes = ({
|
|||
ref={register()}
|
||||
aria-label="value-input"
|
||||
defaultValue={attribute.value}
|
||||
validated={errors.description ? "error" : "default"}
|
||||
/>
|
||||
</Td>
|
||||
{rowIndex !== fields.length - 1 && fields.length - 1 !== 0 && (
|
||||
|
@ -106,7 +128,7 @@ export const RoleAttributes = ({
|
|||
id={`minus-button-${rowIndex}`}
|
||||
aria-label={`remove ${attribute.key} with value ${attribute.value} `}
|
||||
variant="link"
|
||||
className="kc-role-attributes__minus-icon"
|
||||
className="kc-attributes__minus-icon"
|
||||
onClick={() => remove(rowIndex)}
|
||||
>
|
||||
<MinusCircleIcon />
|
||||
|
@ -120,7 +142,7 @@ export const RoleAttributes = ({
|
|||
id={`minus-button-${rowIndex}`}
|
||||
aria-label={`remove ${attribute.key} with value ${attribute.value} `}
|
||||
variant="link"
|
||||
className="kc-role-attributes__minus-icon"
|
||||
className="kc-attributes__minus-icon"
|
||||
onClick={() => remove(rowIndex)}
|
||||
>
|
||||
<MinusCircleIcon />
|
||||
|
@ -130,7 +152,7 @@ export const RoleAttributes = ({
|
|||
aria-label={t("roles:addAttributeText")}
|
||||
id="plus-icon"
|
||||
variant="link"
|
||||
className="kc-role-attributes__plus-icon"
|
||||
className="kc-attributes__plus-icon"
|
||||
onClick={() => append({ key: "", value: "" })}
|
||||
icon={<PlusCircleIcon />}
|
||||
isDisabled={!formState.isValid}
|
||||
|
@ -141,7 +163,7 @@ export const RoleAttributes = ({
|
|||
))}
|
||||
</Tbody>
|
||||
</TableComposable>
|
||||
<ActionGroup className="kc-role-attributes__action-group">
|
||||
<ActionGroup className="kc-attributes__action-group">
|
||||
<Button variant="primary" type="submit" isDisabled={!watchFirstKey}>
|
||||
{t("common:save")}
|
||||
</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 { Breadcrumb, BreadcrumbItem } from "@patternfly/react-core";
|
||||
|
||||
import { useSubGroups } from "../../groups/GroupsSection";
|
||||
import { useSubGroups } from "../../groups/SubGroupsContext";
|
||||
import { useRealm } from "../../context/realm-context/RealmContext";
|
||||
|
||||
export const GroupBreadCrumbs = () => {
|
||||
|
|
|
@ -3,7 +3,7 @@ import { mount } from "enzyme";
|
|||
import { MemoryRouter } from "react-router-dom";
|
||||
|
||||
import { GroupBreadCrumbs } from "../GroupBreadCrumbs";
|
||||
import { SubGroups, useSubGroups } from "../../../groups/GroupsSection";
|
||||
import { SubGroups, useSubGroups } from "../../../groups/SubGroupsContext";
|
||||
|
||||
const GroupCrumbs = () => {
|
||||
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, {
|
||||
createContext,
|
||||
ReactNode,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Link, useHistory, useLocation } from "react-router-dom";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useErrorHandler } from "react-error-boundary";
|
||||
import {
|
||||
Button,
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
KebabToggle,
|
||||
PageSection,
|
||||
PageSectionVariants,
|
||||
ToolbarItem,
|
||||
AlertVariant,
|
||||
Tab,
|
||||
TabTitleText,
|
||||
Tabs,
|
||||
} from "@patternfly/react-core";
|
||||
import { UsersIcon } from "@patternfly/react-icons";
|
||||
import GroupRepresentation from "keycloak-admin/lib/defs/groupRepresentation";
|
||||
|
||||
import { GroupsCreateModal } from "./GroupsCreateModal";
|
||||
import { ViewHeader } from "../components/view-header/ViewHeader";
|
||||
import { ListEmptyState } from "../components/list-empty-state/ListEmptyState";
|
||||
import { useAdminClient } from "../context/auth/AdminClient";
|
||||
import { asyncStateFetch, useAdminClient } from "../context/auth/AdminClient";
|
||||
import { useAlerts } from "../components/alert/Alerts";
|
||||
import { KeycloakDataTable } from "../components/table-toolbar/KeycloakDataTable";
|
||||
|
||||
import "./GroupsSection.css";
|
||||
import { useRealm } from "../context/realm-context/RealmContext";
|
||||
|
||||
type GroupTableData = GroupRepresentation & {
|
||||
membersLength?: number;
|
||||
};
|
||||
import { useSubGroups } from "./SubGroupsContext";
|
||||
import { GroupTable } from "./GroupTable";
|
||||
import { getId, getLastId } from "./groupIdUtils";
|
||||
import { Members } from "./Members";
|
||||
import { GroupAttributes } from "./GroupAttributes";
|
||||
|
||||
type SubGroupsProps = {
|
||||
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;
|
||||
};
|
||||
import "./GroupsSection.css";
|
||||
|
||||
export const GroupsSection = () => {
|
||||
const { t } = useTranslation("groups");
|
||||
const [activeTab, setActiveTab] = useState(0);
|
||||
|
||||
const adminClient = useAdminClient();
|
||||
const [isKebabOpen, setIsKebabOpen] = useState(false);
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
const [selectedRows, setSelectedRows] = useState<GroupRepresentation[]>([]);
|
||||
const { subGroups, setSubGroups } = useSubGroups();
|
||||
const { addAlert } = useAlerts();
|
||||
const { realm } = useRealm();
|
||||
const errorHandler = useErrorHandler();
|
||||
|
||||
const history = useHistory();
|
||||
|
||||
const location = useLocation();
|
||||
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) => {
|
||||
try {
|
||||
await adminClient.groups.del({
|
||||
|
@ -160,30 +51,37 @@ export const GroupsSection = () => {
|
|||
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();
|
||||
useEffect(
|
||||
() =>
|
||||
asyncStateFetch(
|
||||
async () => {
|
||||
const ids = getId(location.pathname);
|
||||
const isNavigationStateInValid =
|
||||
ids && ids.length !== subGroups.length + 1;
|
||||
if (isNavigationStateInValid) {
|
||||
const groups: GroupRepresentation[] = [];
|
||||
for (const i of ids!) {
|
||||
const group = await adminClient.groups.findOne({ id: i });
|
||||
if (group) groups.push(group);
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
return groups;
|
||||
} else {
|
||||
if (id) {
|
||||
const group = await adminClient.groups.findOne({ id: id });
|
||||
if (group) {
|
||||
return [...subGroups, group];
|
||||
} else {
|
||||
return subGroups;
|
||||
}
|
||||
} else {
|
||||
return subGroups;
|
||||
}
|
||||
}
|
||||
},
|
||||
(groups: GroupRepresentation[]) => setSubGroups(groups),
|
||||
errorHandler
|
||||
),
|
||||
[id]
|
||||
);
|
||||
|
||||
return (
|
||||
|
@ -216,93 +114,37 @@ export const GroupsSection = () => {
|
|||
]}
|
||||
/>
|
||||
<PageSection variant={PageSectionVariants.light}>
|
||||
<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}
|
||||
{subGroups.length > 0 && (
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
isSecondary
|
||||
onSelect={(_, key) => setActiveTab(key as number)}
|
||||
isBox
|
||||
>
|
||||
{t("createGroup")}
|
||||
</Button>
|
||||
</ToolbarItem>
|
||||
<ToolbarItem>
|
||||
<Dropdown
|
||||
toggle={
|
||||
<KebabToggle
|
||||
onToggle={() => setIsKebabOpen(!isKebabOpen)}
|
||||
/>
|
||||
}
|
||||
isOpen={isKebabOpen}
|
||||
isPlain
|
||||
dropdownItems={[
|
||||
<DropdownItem
|
||||
key="action"
|
||||
component="button"
|
||||
onClick={() => {
|
||||
multiDelete();
|
||||
setIsKebabOpen(false);
|
||||
}}
|
||||
<Tab
|
||||
data-testid="groups"
|
||||
eventKey={0}
|
||||
title={<TabTitleText>{t("childGroups")}</TabTitleText>}
|
||||
>
|
||||
{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}
|
||||
/>
|
||||
<GroupTable />
|
||||
</Tab>
|
||||
<Tab
|
||||
data-testid="members"
|
||||
eventKey={1}
|
||||
title={<TabTitleText>{t("members")}</TabTitleText>}
|
||||
>
|
||||
<Members />
|
||||
</Tab>
|
||||
<Tab
|
||||
data-testid="attributes"
|
||||
eventKey={2}
|
||||
title={<TabTitleText>{t("attributes")}</TabTitleText>}
|
||||
>
|
||||
<GroupAttributes />
|
||||
</Tab>
|
||||
</Tabs>
|
||||
)}
|
||||
{subGroups.length === 0 && <GroupTable />}
|
||||
</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",
|
||||
"childGroups": "Child groups",
|
||||
"createGroup": "Create group",
|
||||
"groupName": "Group name",
|
||||
"searchForGroups": "Search for groups",
|
||||
|
@ -10,6 +11,9 @@
|
|||
"deleteGroup": "Delete group",
|
||||
"search": "Search",
|
||||
"members": "Members",
|
||||
"searchMembers": "Search members",
|
||||
"addMember": "Add member",
|
||||
"includeSubGroups": "Include sub-group users",
|
||||
"path": "Path",
|
||||
"moveTo": "Move to",
|
||||
"tableOfGroups": "Table of groups",
|
||||
|
@ -18,6 +22,10 @@
|
|||
"couldNotCreateGroup": "Could not create group {{error}}",
|
||||
"createAGroup": "Create a group",
|
||||
"create": "Create",
|
||||
"email": "Email",
|
||||
"lastName": "Last name",
|
||||
"firstName": "First name",
|
||||
"membership": "Membership",
|
||||
"noSearchResults": "No search results",
|
||||
"noSearchResultsInstructions": "Click on the search bar above to search for groups",
|
||||
"noGroupsInThisRealm": "No groups in this realm",
|
||||
|
@ -26,6 +34,9 @@
|
|||
"noGroupsInThisSubGroupInstructions": "You haven't created any groups in this sub group.",
|
||||
"groupDelete": "Group 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,
|
||||
} from "@patternfly/react-core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useAdminClient } from "../context/auth/AdminClient";
|
||||
import { asyncStateFetch, useAdminClient } from "../context/auth/AdminClient";
|
||||
import RoleRepresentation from "keycloak-admin/lib/defs/roleRepresentation";
|
||||
import { KeycloakDataTable } from "../components/table-toolbar/KeycloakDataTable";
|
||||
import { ListEmptyState } from "../components/list-empty-state/ListEmptyState";
|
||||
import { CaretDownIcon, FilterIcon } from "@patternfly/react-icons";
|
||||
import { AliasRendererComponent } from "./AliasRendererComponent";
|
||||
import _ from "lodash";
|
||||
import { useErrorHandler } from "react-error-boundary";
|
||||
|
||||
export type AssociatedRolesModalProps = {
|
||||
open: boolean;
|
||||
|
@ -24,27 +25,12 @@ export type AssociatedRolesModalProps = {
|
|||
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) => {
|
||||
const { t } = useTranslation("roles");
|
||||
const form = useForm<RoleRepresentation>({ mode: "onChange" });
|
||||
const [name, setName] = useState("");
|
||||
const adminClient = useAdminClient();
|
||||
const [selectedRows, setSelectedRows] = useState<RoleRepresentation[]>([]);
|
||||
const errorHandler = useErrorHandler();
|
||||
|
||||
const [isFilterDropdownOpen, setIsFilterDropdownOpen] = useState(false);
|
||||
const [filterType, setFilterType] = useState("roles");
|
||||
|
@ -54,18 +40,7 @@ export const AssociatedRolesModal = (props: AssociatedRolesModalProps) => {
|
|||
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;
|
||||
});
|
||||
return _.sortBy(rolesList, (role) => role.name?.toUpperCase());
|
||||
};
|
||||
|
||||
const loader = async () => {
|
||||
|
@ -127,26 +102,17 @@ export const AssociatedRolesModal = (props: AssociatedRolesModalProps) => {
|
|||
}, [filterType]);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (id) {
|
||||
const fetchedRole = await adminClient.roles.findOneById({ id });
|
||||
setName(fetchedRole.name!);
|
||||
setupForm(fetchedRole);
|
||||
return asyncStateFetch(
|
||||
() => adminClient.roles.findOneById({ id }),
|
||||
(fetchedRole) => setName(fetchedRole.name!),
|
||||
errorHandler
|
||||
);
|
||||
} 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 = () => {
|
||||
setIsFilterDropdownOpen(!isFilterDropdownOpen);
|
||||
};
|
||||
|
@ -224,7 +190,6 @@ export const AssociatedRolesModal = (props: AssociatedRolesModalProps) => {
|
|||
/>
|
||||
}
|
||||
canSelectAll
|
||||
isPaginated
|
||||
onSelect={(rows) => {
|
||||
setSelectedRows([...rows]);
|
||||
}}
|
||||
|
@ -245,7 +210,6 @@ export const AssociatedRolesModal = (props: AssociatedRolesModalProps) => {
|
|||
message={t("noRolesInThisRealm")}
|
||||
instructions={t("noRolesInThisRealmInstructions")}
|
||||
primaryActionText={t("createRole")}
|
||||
// onPrimaryAction={goToCreate}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
|
|
@ -7,6 +7,7 @@ import {
|
|||
ButtonVariant,
|
||||
Checkbox,
|
||||
PageSection,
|
||||
ToolbarItem,
|
||||
} from "@patternfly/react-core";
|
||||
import RoleRepresentation from "keycloak-admin/lib/defs/roleRepresentation";
|
||||
import { ListEmptyState } from "../components/list-empty-state/ListEmptyState";
|
||||
|
@ -193,6 +194,7 @@ export const AssociatedRolesTab = ({
|
|||
}}
|
||||
toolbarItem={
|
||||
<>
|
||||
<ToolbarItem>
|
||||
<Checkbox
|
||||
label="Hide inherited roles"
|
||||
key="associated-roles-check"
|
||||
|
@ -200,14 +202,17 @@ export const AssociatedRolesTab = ({
|
|||
onChange={() => setIsInheritedHidden(!isInheritedHidden)}
|
||||
isChecked={isInheritedHidden}
|
||||
/>
|
||||
</ToolbarItem>
|
||||
<ToolbarItem>
|
||||
<Button
|
||||
className="kc-add-role-button"
|
||||
key="add-role-button"
|
||||
onClick={() => toggleModal()}
|
||||
data-testid="add-role-button"
|
||||
>
|
||||
{t("addRole")}
|
||||
</Button>
|
||||
</ToolbarItem>
|
||||
<ToolbarItem>
|
||||
<Button
|
||||
variant="link"
|
||||
isDisabled={selectedRows.length === 0}
|
||||
|
@ -218,6 +223,7 @@ export const AssociatedRolesTab = ({
|
|||
>
|
||||
{t("removeRoles")}
|
||||
</Button>
|
||||
</ToolbarItem>
|
||||
</>
|
||||
}
|
||||
actions={[
|
||||
|
|
|
@ -15,7 +15,12 @@ import { useAlerts } from "../components/alert/Alerts";
|
|||
import { useAdminClient } from "../context/auth/AdminClient";
|
||||
import RoleRepresentation 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 { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog";
|
||||
import { RealmRoleForm } from "./RealmRoleForm";
|
||||
|
@ -25,26 +30,6 @@ import { KeycloakTabs } from "../components/keycloak-tabs/KeycloakTabs";
|
|||
import { AssociatedRolesTab } from "./AssociatedRolesTab";
|
||||
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"> & {
|
||||
attributes: KeyValueType[];
|
||||
};
|
||||
|
@ -329,7 +314,7 @@ export const RealmRoleTabs = () => {
|
|||
eventKey="attributes"
|
||||
title={<TabTitleText>{t("attributes")}</TabTitleText>}
|
||||
>
|
||||
<RoleAttributes
|
||||
<AttributesForm
|
||||
form={form}
|
||||
save={save}
|
||||
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 {
|
||||
padding-left: 0px;
|
||||
|
|
Loading…
Reference in a new issue