initial move group dialog (#455)

* initial move group dialog

* added test

* fixed search and added breadcrumb

* filter current group

* added empty state

* add cancel button to modal

* fixed merge error
This commit is contained in:
Erik Jan de Wit 2021-03-29 13:37:47 +02:00 committed by GitHub
parent 6ea4f88b5b
commit f10661444d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 369 additions and 18 deletions

View file

@ -1,6 +1,7 @@
import ListingPage from "../support/pages/admin_console/ListingPage"; import ListingPage from "../support/pages/admin_console/ListingPage";
import GroupModal from "../support/pages/admin_console/manage/groups/GroupModal"; import GroupModal from "../support/pages/admin_console/manage/groups/GroupModal";
import GroupDetailPage from "../support/pages/admin_console/manage/groups/GroupDetailPage"; import GroupDetailPage from "../support/pages/admin_console/manage/groups/GroupDetailPage";
import MoveGroupModal from "../support/pages/admin_console/manage/groups/MoveGroupModal";
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";
@ -16,6 +17,8 @@ describe("Group test", () => {
const listingPage = new ListingPage(); const listingPage = new ListingPage();
const viewHeaderPage = new ViewHeaderPage(); const viewHeaderPage = new ViewHeaderPage();
const groupModal = new GroupModal(); const groupModal = new GroupModal();
const searchGroupPage = new SearchGroupPage();
const moveGroupModal = new MoveGroupModal();
let groupName = "group"; let groupName = "group";
@ -61,12 +64,36 @@ describe("Group test", () => {
listingPage.deleteItem(newName); listingPage.deleteItem(newName);
}); });
const searchGroupPage = new SearchGroupPage();
it("Group search", () => { it("Group search", () => {
viewHeaderPage.clickAction("searchGroup"); viewHeaderPage.clickAction("searchGroup");
searchGroupPage.searchGroup("group").clickSearchButton(); searchGroupPage.searchGroup("group").clickSearchButton();
searchGroupPage.checkTerm("group"); searchGroupPage.checkTerm("group");
}); });
it("Should move group", () => {
const targetGroupName = "target";
groupModal
.open("empty-primary-action")
.fillGroupForm(groupName)
.clickCreate();
groupModal.open().fillGroupForm(targetGroupName).clickCreate();
listingPage.clickRowDetails(groupName).clickDetailMenu("Move to");
moveGroupModal
.clickRow(targetGroupName)
.checkTitle(`Move ${groupName} to ${targetGroupName}`);
moveGroupModal.clickMove();
masthead.checkNotificationMessage("Group moved");
listingPage
.itemExist(groupName, false)
.goToItemDetails(targetGroupName)
.itemExist(targetGroupName);
sidebarPage.goToGroups();
listingPage.deleteItem(targetGroupName);
});
}); });
describe("Group details", () => { describe("Group details", () => {

View file

@ -47,6 +47,20 @@ export default class ListingPage {
return this; return this;
} }
clickRowDetails(itemName: string) {
cy.get(this.itemsRows)
.contains(itemName)
.parentsUntil("tbody")
.find(this.itemRowDrpDwn)
.click();
return this;
}
clickDetailMenu(name: string) {
cy.get(this.itemsRows).contains(name).click();
return this;
}
itemExist(itemName: string, exist = true) { itemExist(itemName: string, exist = true) {
cy.get(this.itemsRows) cy.get(this.itemsRows)
.contains(itemName) .contains(itemName)
@ -62,23 +76,15 @@ export default class ListingPage {
} }
deleteItem(itemName: string) { deleteItem(itemName: string) {
cy.get(this.itemsRows) this.clickRowDetails(itemName);
.contains(itemName) this.clickDetailMenu("Delete");
.parentsUntil("tbody")
.find(this.itemRowDrpDwn)
.click();
cy.get(this.itemsRows).contains("Delete").click();
return this; return this;
} }
exportItem(itemName: string) { exportItem(itemName: string) {
cy.get(this.itemsRows) this.clickRowDetails(itemName);
.contains(itemName) this.clickDetailMenu("Export");
.parentsUntil("tbody")
.find(this.itemRowDrpDwn)
.click();
cy.get(this.itemsRows).contains("Export").click();
return this; return this;
} }

View file

@ -0,0 +1,19 @@
export default class MoveGroupModal {
private moveButton = "moveGroup";
private title = ".pf-c-modal-box__title";
clickRow(groupName: string) {
cy.getId(groupName).click();
return this;
}
checkTitle(title: string) {
cy.get(this.title).should("have.text", title);
return this;
}
clickMove() {
cy.getId(this.moveButton).click();
return this;
}
}

50
nginx.conf Normal file
View file

@ -0,0 +1,50 @@
server {
listen 80;
listen [::]:80;
server_name localhost;
#charset koi8-r;
#access_log /var/log/nginx/host.access.log main;
location / {
root /usr/share/nginx/html;
index index.html index.htm;
}
location /auth/ {
proxy_pass http://localhost:8180/auth/;
}
#error_page 404 /404.html;
# redirect server error pages to the static page /50x.html
#
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
# proxy the PHP scripts to Apache listening on 127.0.0.1:80
#
#location ~ \.php$ {
# proxy_pass http://127.0.0.1;
#}
# pass the PHP scripts to FastCGI server listening on 127.0.0.1:9000
#
#location ~ \.php$ {
# root html;
# fastcgi_pass 127.0.0.1:9000;
# fastcgi_index index.php;
# fastcgi_param SCRIPT_FILENAME /scripts$fastcgi_script_name;
# include fastcgi_params;
#}
# deny access to .htaccess files, if Apache's document root
# concurs with nginx's one
#
#location ~ /\.ht {
# deny all;
#}
}

View file

@ -57,7 +57,7 @@ export const ClientScopesSection = () => {
} catch (error) { } catch (error) {
addAlert( addAlert(
t("deleteError", { t("deleteError", {
error: error.response.data?.errorMessage || error, error: error.response?.data?.errorMessage || error,
}), }),
AlertVariant.danger AlertVariant.danger
); );

View file

@ -21,6 +21,7 @@ import { KeycloakDataTable } from "../components/table-toolbar/KeycloakDataTable
import { ListEmptyState } from "../components/list-empty-state/ListEmptyState"; import { ListEmptyState } from "../components/list-empty-state/ListEmptyState";
import { GroupsModal } from "./GroupsModal"; import { GroupsModal } from "./GroupsModal";
import { getLastId } from "./groupIdUtils"; import { getLastId } from "./groupIdUtils";
import { MoveGroupDialog } from "./MoveGroupDialog";
type GroupTableData = GroupRepresentation & { type GroupTableData = GroupRepresentation & {
membersLength?: number; membersLength?: number;
@ -35,6 +36,7 @@ export const GroupTable = () => {
const [isKebabOpen, setIsKebabOpen] = useState(false); const [isKebabOpen, setIsKebabOpen] = useState(false);
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [selectedRows, setSelectedRows] = useState<GroupRepresentation[]>([]); const [selectedRows, setSelectedRows] = useState<GroupRepresentation[]>([]);
const [move, setMove] = useState<GroupTableData>();
const { subGroups } = useSubGroups(); const { subGroups } = useSubGroups();
@ -161,7 +163,10 @@ export const GroupTable = () => {
actions={[ actions={[
{ {
title: t("moveTo"), title: t("moveTo"),
onRowClick: () => console.log("TO DO: Add move to functionality"), onRowClick: async (group) => {
setMove(group);
return false;
},
}, },
{ {
title: t("common:delete"), title: t("common:delete"),
@ -201,6 +206,34 @@ export const GroupTable = () => {
refresh={refresh} refresh={refresh}
/> />
)} )}
{move && (
<MoveGroupDialog
group={move}
onClose={() => setMove(undefined)}
onMove={async (id) => {
delete move.membersLength;
try {
try {
await adminClient.groups.setOrCreateChild({ id }, move);
} catch (error) {
if (error.response) {
throw error;
}
}
setMove(undefined);
refresh();
addAlert(t("moveGroupSuccess"), AlertVariant.success);
} catch (error) {
addAlert(
t("moveGroupError", {
error: error.response?.data?.errorMessage || error,
}),
AlertVariant.danger
);
}
}}
/>
)}
</> </>
); );
}; };

View file

@ -0,0 +1,210 @@
import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useErrorHandler } from "react-error-boundary";
import {
Breadcrumb,
BreadcrumbItem,
Button,
ButtonVariant,
DataList,
DataListAction,
DataListCell,
DataListItem,
DataListItemCells,
DataListItemRow,
InputGroup,
Modal,
ModalVariant,
TextInput,
Toolbar,
ToolbarContent,
ToolbarItem,
} from "@patternfly/react-core";
import { AngleRightIcon, SearchIcon } from "@patternfly/react-icons";
import GroupRepresentation from "keycloak-admin/lib/defs/groupRepresentation";
import { asyncStateFetch, useAdminClient } from "../context/auth/AdminClient";
import { ListEmptyState } from "../components/list-empty-state/ListEmptyState";
type MoveGroupDialogProps = {
group: GroupRepresentation;
onClose: () => void;
onMove: (groupId: string) => void;
};
export const MoveGroupDialog = ({
group,
onClose,
onMove,
}: MoveGroupDialogProps) => {
const { t } = useTranslation("groups");
const adminClient = useAdminClient();
const errorHandler = useErrorHandler();
const [navigation, setNavigation] = useState<GroupRepresentation[]>([]);
const [groups, setGroups] = useState<GroupRepresentation[]>([]);
const [filtered, setFiltered] = useState<GroupRepresentation[]>();
const [filter, setFilter] = useState("");
const [id, setId] = useState<string>();
const currentGroup = () => navigation[navigation.length - 1];
useEffect(
() =>
asyncStateFetch(
async () => {
if (id) {
const group = await adminClient.groups.findOne({ id });
return { group, groups: group.subGroups! };
} else {
return { groups: await adminClient.groups.find() };
}
},
({ group: selectedGroup, groups }) => {
if (selectedGroup) setNavigation([...navigation, selectedGroup]);
setGroups(groups.filter((g) => g.id !== group.id));
},
errorHandler
),
[id]
);
return (
<Modal
variant={ModalVariant.large}
title={
currentGroup()
? t("moveToGroup", {
group1: group.name,
group2: currentGroup().name,
})
: t("moveTo")
}
isOpen={true}
onClose={onClose}
actions={[
<Button
data-testid="moveGroup"
key="confirm"
variant="primary"
form="group-form"
onClick={() => onMove(currentGroup().id!)}
>
{t("moveHere")}
</Button>,
<Button
data-testid="moveCancel"
key="cancel"
variant="secondary"
onClick={onClose}
>
{t("common:cancel")}
</Button>,
]}
>
<Breadcrumb>
<BreadcrumbItem key="home">
<Button
variant="link"
onClick={() => {
setId(undefined);
setNavigation([]);
}}
>
{t("groups")}
</Button>
</BreadcrumbItem>
{navigation.map((group, i) => (
<BreadcrumbItem key={i}>
{navigation.length - 1 !== i && (
<Button
variant="link"
onClick={() => {
setId(group.id);
setNavigation([...navigation].slice(0, i));
}}
>
{group.name}
</Button>
)}
{navigation.length - 1 === i && <>{group.name}</>}
</BreadcrumbItem>
))}
</Breadcrumb>
<Toolbar>
<ToolbarContent>
<ToolbarItem>
<InputGroup>
<TextInput
type="search"
aria-label={t("common:search")}
placeholder={t("searchForGroups")}
onChange={(value) => {
if (value === "") {
setFiltered(undefined);
}
setFilter(value);
}}
/>
<Button
variant={ButtonVariant.control}
aria-label={t("common:search")}
onClick={() =>
setFiltered(
groups.filter((group) =>
group.name?.toLowerCase().includes(filter.toLowerCase())
)
)
}
>
<SearchIcon />
</Button>
</InputGroup>
</ToolbarItem>
</ToolbarContent>
</Toolbar>
<DataList
onSelectDataListItem={(value) => setId(value)}
aria-label={t("groups")}
isCompact
>
{(filtered || groups).map((group) => (
<DataListItem
aria-labelledby={group.name}
key={group.id}
id={group.id}
>
<DataListItemRow data-testid={group.name}>
<DataListItemCells
dataListCells={[
<DataListCell key={`name-${group.id}`}>
<>{group.name}</>
</DataListCell>,
]}
/>
<DataListAction
aria-labelledby={`select-${group.name}`}
id={`select-${group.name}`}
aria-label={t("groupName")}
isPlainButtonAction
>
<Button isDisabled variant="link">
<AngleRightIcon />
</Button>
</DataListAction>
</DataListItemRow>
</DataListItem>
))}
{(filtered || groups).length === 0 && (
<ListEmptyState
hasIcon={false}
message={t("moveGroupEmpty")}
instructions={t("moveGroupEmptyInstructions")}
/>
)}
</DataList>
</Modal>
);
};

View file

@ -23,6 +23,12 @@
"includeSubGroups": "Include sub-group users", "includeSubGroups": "Include sub-group users",
"path": "Path", "path": "Path",
"moveTo": "Move to", "moveTo": "Move to",
"moveToGroup": "Move {{group1}} to {{group2}}",
"moveHere": "Move here",
"moveGroupEmpty": "No sub groups",
"moveGroupEmptyInstructions": "There are no sub groups, select 'Move here' to move the selected group as a subgroup of this group",
"moveGroupSuccess": "Group moved",
"moveGroupError": "Could not move group {{error}}",
"tableOfGroups": "Table of groups", "tableOfGroups": "Table of groups",
"groupsDescription": "Description goes here", "groupsDescription": "Description goes here",
"groupCreated": "Group created", "groupCreated": "Group created",

View file

@ -163,7 +163,7 @@ export const RealmRoleTabs = () => {
} catch (error) { } catch (error) {
addAlert( addAlert(
t((id ? "roleSave" : "roleCreate") + "Error", { t((id ? "roleSave" : "roleCreate") + "Error", {
error: error.response.data?.errorMessage || error, error: error.response?.data?.errorMessage || error,
}), }),
AlertVariant.danger AlertVariant.danger
); );

View file

@ -63,7 +63,7 @@ export const NewRealmForm = () => {
} catch (error) { } catch (error) {
addAlert( addAlert(
t("saveRealmError", { t("saveRealmError", {
error: error.response.data?.errorMessage || error, error: error.response?.data?.errorMessage || error,
}), }),
AlertVariant.danger AlertVariant.danger
); );

View file

@ -51,7 +51,7 @@ export const UsersTabs = () => {
} catch (error) { } catch (error) {
addAlert( addAlert(
t("users:userCreateError", { t("users:userCreateError", {
error: error.response.data?.errorMessage || error, error: error.response?.data?.errorMessage || error,
}), }),
AlertVariant.danger AlertVariant.danger
); );