Added rename group and adding members to a group (#448)

* users can now rename groups

* add members to a group

* added cypress test

* remove rename and delete when no group is selected

* added test

* keep selected rows form other pages

* fixed empty first page and cancel button
This commit is contained in:
Erik Jan de Wit 2021-03-24 15:07:49 +01:00 committed by GitHub
parent 236e89dc63
commit 50920b3df2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 531 additions and 190 deletions

View file

@ -83,8 +83,7 @@ describe("Clients test", function () {
it("Initial access token", () => {
const initialAccessTokenTab = new InitialAccessTokenTab();
listingPage.goToInitialAccessTokenTab();
initialAccessTokenTab.shouldBeEmpty();
initialAccessTokenTab.goToInitialAccessTokenTab().shouldBeEmpty();
initialAccessTokenTab.createNewToken(1, 1).save();
modalUtils.checkModalTitle("Initial access token details").closeModal();

View file

@ -1,5 +1,5 @@
import ListingPage from "../support/pages/admin_console/ListingPage";
import CreateGroupModal from "../support/pages/admin_console/manage/groups/CreateGroupModal";
import GroupModal from "../support/pages/admin_console/manage/groups/GroupModal";
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";
@ -15,7 +15,7 @@ describe("Group test", () => {
const sidebarPage = new SidebarPage();
const listingPage = new ListingPage();
const viewHeaderPage = new ViewHeaderPage();
const createGroupModal = new CreateGroupModal();
const groupModal = new GroupModal();
let groupName = "group";
@ -29,7 +29,7 @@ describe("Group test", () => {
it("Group CRUD test", () => {
groupName += "_" + (Math.random() + 1).toString(36).substring(7);
createGroupModal
groupModal
.open("empty-primary-action")
.fillGroupForm(groupName)
.clickCreate();
@ -44,6 +44,23 @@ describe("Group test", () => {
masthead.checkNotificationMessage("Group deleted");
});
it("Should rename group", () => {
groupModal
.open("empty-primary-action")
.fillGroupForm(groupName)
.clickCreate();
listingPage.goToItemDetails(groupName);
viewHeaderPage.clickAction("renameGroupAction");
const newName = "Renamed group";
groupModal.fillGroupForm(newName).clickRename();
masthead.checkNotificationMessage("Group updated");
sidebarPage.goToGroups();
listingPage.searchItem(newName, false).itemExist(newName);
listingPage.deleteItem(newName);
});
const searchGroupPage = new SearchGroupPage();
it("Group search", () => {
viewHeaderPage.clickAction("searchGroup");
@ -63,6 +80,7 @@ describe("Group test", () => {
const username = "user" + i;
client.createUserInGroup(username, createdGroups[i % 3].id);
}
client.createUser({ username: "new", enabled: true });
});
beforeEach(() => {
@ -80,7 +98,7 @@ describe("Group test", () => {
detailPage.checkListSubGroup([groups[1]]);
const added = "addedGroup";
createGroupModal.open().fillGroupForm(added).clickCreate();
groupModal.open().fillGroupForm(added).clickCreate();
detailPage.checkListSubGroup([added, groups[1]]);
});
@ -93,6 +111,18 @@ describe("Group test", () => {
.checkListMembers(["user0", "user3", "user1", "user4", "user2"]);
});
it("Should add members", () => {
listingPage.goToItemDetails(groups[0]);
detailPage
.clickMembersTab()
.clickAddMembers()
.checkSelectableMembers(["user1", "user4"]);
detailPage.selectUsers(["new"]).clickAdd();
masthead.checkNotificationMessage("1 user added to the group");
detailPage.checkListMembers(["new", "user0", "user3"]);
});
it("Attributes CRUD test", () => {
listingPage.goToItemDetails(groups[0]);
detailPage

View file

@ -7,7 +7,6 @@ export default class ListingPage {
searchBtn: string;
createBtn: string;
importBtn: string;
initialAccessTokenTab = "initialAccessToken";
constructor() {
this.searchInput = '.pf-c-toolbar__item [type="search"]';
@ -35,11 +34,6 @@ export default class ListingPage {
return this;
}
goToInitialAccessTokenTab() {
cy.getId(this.initialAccessTokenTab).click();
return this;
}
searchItem(searchValue: string, wait = true) {
if (wait) {
const searchUrl = `/auth/admin/realms/master/*${searchValue}*`;

View file

@ -1,10 +1,17 @@
export default class InitialAccessTokenTab {
private initialAccessTokenTab = "initialAccessToken";
private emptyAction = "empty-primary-action";
private expirationInput = "expiration";
private countInput = "count";
private saveBtn = "save";
goToInitialAccessTokenTab() {
cy.getId(this.initialAccessTokenTab).click();
return this;
}
shouldBeEmpty() {
cy.getId(this.emptyAction).should("exist");
return this;

View file

@ -6,6 +6,9 @@ export default class GroupDetailPage {
private attributesTab = "attributes";
private memberNameColumn = 'tbody > tr > [data-label="Name"]';
private includeSubGroupsCheck = "includeSubGroupsCheck";
private addMembers = "addMember";
private addMember = "add";
private memberUsernameColumn = 'tbody > tr > [data-label="Username"]';
private keyInput = '[name="attributes[0].key"]';
private valueInput = '[name="attributes[0].value"]';
@ -37,6 +40,31 @@ export default class GroupDetailPage {
return this;
}
checkSelectableMembers(members: string[]) {
cy.get(this.memberUsernameColumn).should((member) => {
for (const user of members) {
expect(member).to.contain(user);
}
});
return this;
}
selectUsers(users: string[]) {
for (const user of users) {
cy.get(this.memberUsernameColumn)
.contains(user)
.parent()
.find("input")
.click();
}
return this;
}
clickAdd() {
cy.getId(this.addMember).click();
return this;
}
clickIncludeSubGroups() {
cy.getId(this.includeSubGroupsCheck).click();
return this;
@ -47,6 +75,11 @@ export default class GroupDetailPage {
return this;
}
clickAddMembers() {
cy.getId(this.addMembers).click();
return this;
}
fillAttribute(key: string, value: string) {
cy.get(this.keyInput).type(key).get(this.valueInput).type(value);
return this;

View file

@ -1,7 +1,8 @@
export default class CreateGroupModal {
export default class GroupModal {
private openButton = "openCreateGroupModal";
private nameInput = "groupNameInput";
private createButton = "createGroup";
private renameButton = "renameGroup";
open(name?: string) {
cy.getId(name || this.openButton).click();
@ -18,4 +19,9 @@ export default class CreateGroupModal {
cy.getId(this.createButton).click();
return this;
}
clickRename() {
cy.getId(this.renameButton).click();
return this;
}
}

View file

@ -1,4 +1,5 @@
import KeycloakAdminClient from "keycloak-admin";
import UserRepresentation from "keycloak-admin/lib/defs/userRepresentation";
export default class AdminClient {
private client: KeycloakAdminClient;
@ -57,9 +58,14 @@ export default class AdminClient {
}
}
async createUser(user: UserRepresentation) {
await this.login();
return await this.client.users.create(user);
}
async createUserInGroup(username: string, groupId: string) {
await this.login();
const user = await this.client.users.create({ username, enabled: true });
const user = await this.createUser({ username, enabled: true });
await this.client.users.addToGroup({ id: user.id!, groupId });
}
}

View file

@ -133,6 +133,7 @@ export function KeycloakDataTable<T>({
emptyState,
}: DataListProps<T>) {
const { t } = useTranslation();
const [selected, setSelected] = useState<T[]>([]);
const [rows, setRows] = useState<Row<T>[]>();
const [filteredData, setFilteredData] = useState<Row<T>[]>();
const [loading, setLoading] = useState(false);
@ -154,7 +155,9 @@ export function KeycloakDataTable<T>({
const result = data!.map((value) => {
return {
data: value,
selected: false,
selected: !!selected.find(
(v) => (v as any).id === (value as any).id
),
cells: columns.map((col) => {
if (col.cellRenderer) {
return col.cellRenderer(value);
@ -233,7 +236,17 @@ export function KeycloakDataTable<T>({
rows![rowIndex].selected = isSelected;
setRows([...rows!]);
}
onSelect!(rows!.filter((row) => row.selected).map((row) => row.data));
const difference = _.differenceBy(
selected,
rows!.map((row) => row.data),
"id"
);
const selectedRows = [
...difference,
...rows!.filter((row) => row.selected).map((row) => row.data),
];
setSelected(selectedRows);
onSelect!(selectedRows);
};
return (

View file

@ -19,7 +19,7 @@ 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 { GroupsModal } from "./GroupsModal";
import { getLastId } from "./groupIdUtils";
type GroupTableData = GroupRepresentation & {
@ -195,7 +195,7 @@ export const GroupTable = () => {
}
/>
{isCreateModalOpen && (
<GroupsCreateModal
<GroupsModal
id={id}
handleModalToggle={handleModalToggle}
refresh={refresh}

View file

@ -1,97 +0,0 @@
import React from "react";
import {
AlertVariant,
Button,
Form,
FormGroup,
Modal,
ModalVariant,
TextInput,
ValidatedOptions,
} from "@patternfly/react-core";
import { useTranslation } from "react-i18next";
import { useForm } from "react-hook-form";
import GroupRepresentation from "keycloak-admin/lib/defs/groupRepresentation";
import { useAdminClient } from "../context/auth/AdminClient";
import { useAlerts } from "../components/alert/Alerts";
type GroupsCreateModalProps = {
id?: string;
handleModalToggle: () => void;
refresh: () => void;
};
export const GroupsCreateModal = ({
id,
handleModalToggle,
refresh,
}: GroupsCreateModalProps) => {
const { t } = useTranslation("groups");
const adminClient = useAdminClient();
const { addAlert } = useAlerts();
const { register, errors, handleSubmit } = useForm();
const submitForm = async (group: GroupRepresentation) => {
try {
if (!id) {
await adminClient.groups.create({ name: group.name });
} else {
await adminClient.groups.setOrCreateChild({ id }, { name: group.name });
}
refresh();
handleModalToggle();
addAlert(t("groupCreated"), AlertVariant.success);
} catch (error) {
addAlert(t("couldNotCreateGroup", { error }), AlertVariant.danger);
}
};
return (
<>
<Modal
variant={ModalVariant.small}
title={t("createAGroup")}
isOpen={true}
onClose={handleModalToggle}
actions={[
<Button
data-testid="createGroup"
key="confirm"
variant="primary"
type="submit"
form="group-form"
>
{t("create")}
</Button>,
]}
>
<Form id="group-form" isHorizontal onSubmit={handleSubmit(submitForm)}>
<FormGroup
name="create-modal-group"
label={t("common:name")}
fieldId="group-id"
helperTextInvalid={t("common:required")}
validated={
errors.name ? ValidatedOptions.error : ValidatedOptions.default
}
isRequired
>
<TextInput
data-testid="groupNameInput"
ref={register({ required: true })}
autoFocus
type="text"
id="create-group-name"
name="name"
validated={
errors.name ? ValidatedOptions.error : ValidatedOptions.default
}
/>
</FormGroup>
</Form>
</Modal>
</>
);
};

104
src/groups/GroupsModal.tsx Normal file
View file

@ -0,0 +1,104 @@
import React from "react";
import {
AlertVariant,
Button,
Form,
FormGroup,
Modal,
ModalVariant,
TextInput,
ValidatedOptions,
} from "@patternfly/react-core";
import { useTranslation } from "react-i18next";
import { useForm } from "react-hook-form";
import GroupRepresentation from "keycloak-admin/lib/defs/groupRepresentation";
import { useAdminClient } from "../context/auth/AdminClient";
import { useAlerts } from "../components/alert/Alerts";
type GroupsModalProps = {
id?: string;
rename?: string;
handleModalToggle: () => void;
refresh: (group?: GroupRepresentation) => void;
};
export const GroupsModal = ({
id,
rename,
handleModalToggle,
refresh,
}: GroupsModalProps) => {
const { t } = useTranslation("groups");
const adminClient = useAdminClient();
const { addAlert } = useAlerts();
const { register, errors, handleSubmit } = useForm({
defaultValues: { name: rename },
});
const submitForm = async (group: GroupRepresentation) => {
try {
if (!id) {
await adminClient.groups.create(group);
} else if (rename) {
await adminClient.groups.update({ id }, group);
} else {
await adminClient.groups.setOrCreateChild({ id }, group);
}
refresh(rename ? group : undefined);
handleModalToggle();
addAlert(
t(rename ? "groupUpdated" : "groupCreated"),
AlertVariant.success
);
} catch (error) {
addAlert(t("couldNotCreateGroup", { error }), AlertVariant.danger);
}
};
return (
<Modal
variant={ModalVariant.small}
title={t(rename ? "renameAGroup" : "createAGroup")}
isOpen={true}
onClose={handleModalToggle}
actions={[
<Button
data-testid={`${rename ? "rename" : "create"}Group`}
key="confirm"
variant="primary"
type="submit"
form="group-form"
>
{t(rename ? "rename" : "create")}
</Button>,
]}
>
<Form id="group-form" isHorizontal onSubmit={handleSubmit(submitForm)}>
<FormGroup
name="create-modal-group"
label={t("common:name")}
fieldId="group-id"
helperTextInvalid={t("common:required")}
validated={
errors.name ? ValidatedOptions.error : ValidatedOptions.default
}
isRequired
>
<TextInput
data-testid="groupNameInput"
ref={register({ required: true })}
autoFocus
type="text"
id="create-group-name"
name="name"
validated={
errors.name ? ValidatedOptions.error : ValidatedOptions.default
}
/>
</FormGroup>
</Form>
</Modal>
);
};

View file

@ -23,6 +23,7 @@ import { GroupTable } from "./GroupTable";
import { getId, getLastId } from "./groupIdUtils";
import { Members } from "./Members";
import { GroupAttributes } from "./GroupAttributes";
import { GroupsModal } from "./GroupsModal";
import "./GroupsSection.css";
@ -31,11 +32,13 @@ export const GroupsSection = () => {
const [activeTab, setActiveTab] = useState(0);
const adminClient = useAdminClient();
const { subGroups, setSubGroups } = useSubGroups();
const { subGroups, setSubGroups, currentGroup } = useSubGroups();
const { addAlert } = useAlerts();
const { realm } = useRealm();
const errorHandler = useErrorHandler();
const [rename, setRename] = useState<string>();
const history = useHistory();
const location = useLocation();
const id = getLastId(location.pathname);
@ -85,34 +88,60 @@ export const GroupsSection = () => {
[id]
);
const SearchDropdown = (
<DropdownItem
data-testid="searchGroup"
key="searchGroup"
onClick={() => history.push(`/${realm}/groups/search`)}
>
{t("searchGroup")}
</DropdownItem>
);
return (
<>
{rename && (
<GroupsModal
id={id}
rename={rename}
refresh={(group) =>
setSubGroups([...subGroups.slice(0, subGroups.length - 1), group!])
}
handleModalToggle={() => setRename(undefined)}
/>
)}
<ViewHeader
titleKey="groups:groups"
subKey="groups:groupsDescription"
dropdownItems={[
<DropdownItem
data-testid="searchGroup"
key="searchGroup"
onClick={() => history.push(`/${realm}/groups/search`)}
>
{t("searchGroup")}
</DropdownItem>,
<DropdownItem
data-testid="renameGroup"
key="renameGroup"
onClick={() => addAlert("Not implemented")}
>
{t("renameGroup")}
</DropdownItem>,
<DropdownItem
data-testid="deleteGroup"
key="deleteGroup"
onClick={() => deleteGroup({ id })}
>
{t("deleteGroup")}
</DropdownItem>,
]}
dropdownItems={
id
? [
SearchDropdown,
<DropdownItem
data-testid="renameGroupAction"
key="renameGroup"
onClick={() => setRename(currentGroup().name)}
>
{t("renameGroup")}
</DropdownItem>,
<DropdownItem
data-testid="deleteGroup"
key="deleteGroup"
onClick={() => {
deleteGroup({ id });
history.push(
location.pathname.substr(
0,
location.pathname.lastIndexOf("/")
)
);
}}
>
{t("deleteGroup")}
</DropdownItem>,
]
: [SearchDropdown]
}
/>
<PageSection variant={PageSectionVariants.light}>
{subGroups.length > 0 && (

View file

@ -2,16 +2,27 @@ import React, { useEffect, useState } from "react";
import { useLocation } from "react-router-dom";
import { useTranslation } from "react-i18next";
import _ from "lodash";
import { Button, Checkbox, ToolbarItem } from "@patternfly/react-core";
import {
AlertVariant,
Button,
Checkbox,
Dropdown,
DropdownItem,
KebabToggle,
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 { useAlerts } from "../components/alert/Alerts";
import { emptyFormatter } from "../util";
import { getLastId } from "./groupIdUtils";
import { useSubGroups } from "./SubGroupsContext";
import { MemberModal } from "./MembersModal";
import { ListEmptyState } from "../components/list-empty-state/ListEmptyState";
type MembersOf = UserRepresentation & {
membership: GroupRepresentation[];
@ -20,10 +31,14 @@ type MembersOf = UserRepresentation & {
export const Members = () => {
const { t } = useTranslation("groups");
const adminClient = useAdminClient();
const { addAlert } = useAlerts();
const location = useLocation();
const id = getLastId(location.pathname);
const [includeSubGroup, setIncludeSubGroup] = useState(false);
const { currentGroup, subGroups } = useSubGroups();
const [addMembers, setAddMembers] = useState(false);
const [isKebabOpen, setIsKebabOpen] = useState(false);
const [selectedRows, setSelectedRows] = useState<UserRepresentation[]>([]);
const [key, setKey] = useState(0);
const refresh = () => setKey(new Date().getTime());
@ -81,55 +96,118 @@ export const Members = () => {
};
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,
},
]}
/>
<>
{addMembers && (
<MemberModal
groupId={id!}
onClose={() => {
setAddMembers(false);
refresh();
}}
/>
)}
<KeycloakDataTable
key={key}
loader={loader}
ariaLabelKey="groups:members"
isPaginated
canSelectAll
onSelect={(rows) => setSelectedRows([...rows])}
toolbarItem={
<>
<ToolbarItem>
<Button
data-testid="addMember"
variant="primary"
onClick={() => setAddMembers(true)}
>
{t("addMember")}
</Button>
</ToolbarItem>
<ToolbarItem>
<Checkbox
data-testid="includeSubGroupsCheck"
label={t("includeSubGroups")}
id="kc-include-sub-groups"
isChecked={includeSubGroup}
onChange={() => setIncludeSubGroup(!includeSubGroup)}
/>
</ToolbarItem>
<ToolbarItem>
<Dropdown
toggle={
<KebabToggle onToggle={() => setIsKebabOpen(!isKebabOpen)} />
}
isOpen={isKebabOpen}
isPlain
dropdownItems={[
<DropdownItem
key="action"
component="button"
onClick={async () => {
try {
await Promise.all(
selectedRows.map((user) =>
adminClient.users.delFromGroup({
id: user.id!,
groupId: id!,
})
)
);
setIsKebabOpen(false);
addAlert(
t("usersLeft", { count: selectedRows.length }),
AlertVariant.success
);
} catch (error) {
addAlert(t("usersLeftError"), AlertVariant.danger);
}
refresh();
}}
>
{t("leave")}
</DropdownItem>,
]}
/>
</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,
},
]}
emptyState={
<ListEmptyState
message={t("users:noUsersFound")}
instructions={t("users:emptyInstructions")}
primaryActionText={t("addMember")}
onPrimaryAction={() => setAddMembers(true)}
/>
}
/>
</>
);
};

130
src/groups/MembersModal.tsx Normal file
View file

@ -0,0 +1,130 @@
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
import {
AlertVariant,
Button,
Modal,
ModalVariant,
} from "@patternfly/react-core";
import UserRepresentation from "keycloak-admin/lib/defs/userRepresentation";
import { useAdminClient } from "../context/auth/AdminClient";
import { useRealm } from "../context/realm-context/RealmContext";
import { useAlerts } from "../components/alert/Alerts";
import { KeycloakDataTable } from "../components/table-toolbar/KeycloakDataTable";
import { ListEmptyState } from "../components/list-empty-state/ListEmptyState";
import { emptyFormatter } from "../util";
import _ from "lodash";
type MemberModalProps = {
groupId: string;
onClose: () => void;
};
export const MemberModal = ({ groupId, onClose }: MemberModalProps) => {
const { t } = useTranslation("groups");
const adminClient = useAdminClient();
const { addAlert } = useAlerts();
const [selectedRows, setSelectedRows] = useState<UserRepresentation[]>([]);
const history = useHistory();
const { realm } = useRealm();
const goToCreate = () => history.push(`/${realm}/users/add-user`);
const loader = async (first?: number, max?: number, search?: string) => {
const members = await adminClient.groups.listMembers({ id: groupId });
const params: { [name: string]: string | number } = {
first: first!,
max: max! + members.length,
search: search || "",
};
try {
const users = await adminClient.users.find({ ...params });
return _.xorBy(users, members, "id").slice(0, max);
} catch (error) {
addAlert(t("noUsersFoundError", { error }), AlertVariant.danger);
return [];
}
};
return (
<Modal
variant={ModalVariant.large}
title={t("addMember")}
isOpen={true}
onClose={onClose}
actions={[
<Button
data-testid="add"
key="confirm"
variant="primary"
onClick={async () => {
try {
await Promise.all(
selectedRows.map((user) =>
adminClient.users.addToGroup({ id: user.id!, groupId })
)
);
onClose();
addAlert(
t("usersAdded", { count: selectedRows.length }),
AlertVariant.success
);
} catch (error) {
addAlert(t("usersAddedError"), AlertVariant.danger);
}
}}
>
{t("common:add")}
</Button>,
<Button
data-testid="cancel"
key="cancel"
variant="secondary"
onClick={onClose}
>
{t("common:cancel")}
</Button>,
]}
>
<KeycloakDataTable
loader={loader}
isPaginated
ariaLabelKey="users:title"
searchPlaceholderKey="users:searchForUser"
canSelectAll
onSelect={(rows) => setSelectedRows([...rows])}
emptyState={
<ListEmptyState
message={t("users:noUsersFound")}
instructions={t("users:emptyInstructions")}
primaryActionText={t("users:createNewUser")}
onPrimaryAction={goToCreate}
/>
}
columns={[
{
name: "username",
displayKey: "users:username",
},
{
name: "email",
displayKey: "users:email",
},
{
name: "lastName",
displayKey: "users:lastName",
cellFormatters: [emptyFormatter()],
},
{
name: "firstName",
displayKey: "users:firstName",
cellFormatters: [emptyFormatter()],
},
]}
/>
</Modal>
);
};

View file

@ -9,6 +9,13 @@
"searchGroup": "Search group",
"renameGroup": "Rename group",
"deleteGroup": "Delete group",
"leave": "Leave group",
"usersLeft": "{{count}} user left the group",
"usersLeft_plural": "{{count}} users left the group",
"usersLeftError": "Could not remove users from the group",
"usersAdded": "{{count}} user added to the group",
"usersAdded_plural": "{{count}} users added to the group",
"usersAddedError": "Could not add users to the group",
"search": "Search",
"members": "Members",
"searchMembers": "Search members",
@ -21,7 +28,9 @@
"groupCreated": "Group created",
"couldNotCreateGroup": "Could not create group {{error}}",
"createAGroup": "Create a group",
"renameAGroup": "Rename group",
"create": "Create",
"rename": "Rename",
"email": "Email",
"lastName": "Last name",
"firstName": "First name",