This is an automated email from the ASF dual-hosted git repository.
jialiang pushed a commit to branch frontend-refactor
in repository https://gitbox.apache.org/repos/asf/ambari.git
The following commit(s) were added to refs/heads/frontend-refactor by this push:
new 2e07fbc147 AMBARI-26169: Ambari Admin React Implementation: Add Groups
Page (#3880)
2e07fbc147 is described below
commit 2e07fbc1478603ef4a73fb52be4d6f02e03b0bc3
Author: Himanshu Maurya <[email protected]>
AuthorDate: Tue Nov 19 06:32:04 2024 +0530
AMBARI-26169: Ambari Admin React Implementation: Add Groups Page (#3880)
---
.../ui/ambari-admin/src/__mocks__/mockUserNames.ts | 47 ++++
.../ui/ambari-admin/src/screens/Users/AddGroup.tsx | 277 +++++++++++++++++++++
.../ui/ambari-admin/src/screens/Users/index.tsx | 8 +
.../ui/ambari-admin/src/tests/AddGroup.test.tsx | 258 +++++++++++++++++++
4 files changed, 590 insertions(+)
diff --git
a/ambari-admin/src/main/resources/ui/ambari-admin/src/__mocks__/mockUserNames.ts
b/ambari-admin/src/main/resources/ui/ambari-admin/src/__mocks__/mockUserNames.ts
new file mode 100644
index 0000000000..352240e15b
--- /dev/null
+++
b/ambari-admin/src/main/resources/ui/ambari-admin/src/__mocks__/mockUserNames.ts
@@ -0,0 +1,47 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export const userNames = {
+ "href":
"http://host.example.com:8080/api/v1/users?Users/user_name.matches(.*.*)",
+ "items": [
+ {
+ "href": "http://host.example.com:8080/api/v1/users/admin",
+ "Users": {
+ "user_name": "admin"
+ }
+ },
+ {
+ "href": "http://host.example.com:8080/api/v1/users/cdscs",
+ "Users": {
+ "user_name": "cdscs"
+ }
+ },
+ {
+ "href": "http://host.example.com:8080/api/v1/users/dsasd",
+ "Users": {
+ "user_name": "dsasd"
+ }
+ },
+ {
+ "href": "http://host.example.com:8080/api/v1/users/dwfewrf",
+ "Users": {
+ "user_name": "dwfewrf"
+ }
+ },
+ ]
+}
\ No newline at end of file
diff --git
a/ambari-admin/src/main/resources/ui/ambari-admin/src/screens/Users/AddGroup.tsx
b/ambari-admin/src/main/resources/ui/ambari-admin/src/screens/Users/AddGroup.tsx
new file mode 100644
index 0000000000..9a8b8dbb62
--- /dev/null
+++
b/ambari-admin/src/main/resources/ui/ambari-admin/src/screens/Users/AddGroup.tsx
@@ -0,0 +1,277 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { Button, Form, Modal } from "react-bootstrap";
+import { faCircleXmark, faQuestionCircle } from
"@fortawesome/free-solid-svg-icons";
+import DefaultButton from "../../components/DefaultButton";
+import { useContext, useEffect, useState } from "react";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import RoleBasedAccessControl from "./RoleBasedAccessControl";
+import Select from "react-select";
+import { userAccessOptions, permissionLabelToName } from "./constants";
+import { PermissionLabel, SelectOptionType, UserNamesType } from "./types";
+import {
+ GroupDataType,
+ MembersDataType,
+ PrivilegesDataType,
+} from "../../api/types";
+import WarningModal from "./WarningModal";
+import toast from "react-hot-toast";
+import { get } from "lodash";
+import { DefaultAccess, PrincipalType } from "./enums";
+import UserGroupApi from "../../api/userGroupApi";
+import PrivilegeApi from "../../api/privilegeApi";
+import AppContent from "../../context/AppContext";
+import Spinner from "../../components/Spinner";
+import { Link } from "react-router-dom";
+
+type AddGroupProps = {
+ showAddGroupModal: boolean;
+ setShowAddGroupModal: (showAddUserModal: boolean) => void;
+ successCallback: () => void;
+};
+
+enum GroupNameCriteria {
+ REGEX = "^([a-zA-Z0-9._\\s]*)$",
+ MAX_LENGTH = 80,
+}
+
+export default function AddGroup({
+ showAddGroupModal,
+ setShowAddGroupModal,
+ successCallback,
+}: AddGroupProps) {
+ const [showRoleAccessModal, setShowRoleAccessModal] = useState(false);
+ const [groupName, setGroupName] = useState("");
+ const [showAddGroupCancelWarning, setShowAddGroupCancelWarning] =
+ useState(false);
+ const [groupAccess, setGroupAccess] = useState<PermissionLabel>("None");
+ const [loading, setLoading] = useState(false);
+ const [userNames, setUserNames] = useState<UserNamesType | null>(null);
+ const [groupMembers, setGroupMembers] = useState<string[]>([]);
+ const [validationError, setValidationError] = useState("");
+ const {
+ cluster: { cluster_name: clusterName },
+ } = useContext(AppContent);
+
+ useEffect(() => {
+ async function getUserNames() {
+ setLoading(true);
+ const data: any = await UserGroupApi.userNames();
+ setUserNames(data);
+ setLoading(false);
+ }
+ getUserNames();
+ }, []);
+
+ useEffect(() => {
+ if (!showAddGroupModal) {
+ resetValues();
+ setShowAddGroupModal(false);
+ }
+ }, [showAddGroupModal]);
+
+ const userOptions: SelectOptionType[] = get(userNames, "items", []).map(
+ (user: any) => ({
+ value: get(user, "Users.user_name"),
+ label: get(user, "Users.user_name"),
+ })
+ );
+
+ const validateGroupName = (groupNameValue: string) => {
+ const regex = new RegExp(GroupNameCriteria.REGEX);
+ if (!regex.test(groupNameValue)) {
+ setValidationError("Must not contain special characters!");
+ } else if (groupNameValue.length > GroupNameCriteria.MAX_LENGTH) {
+ setValidationError("Must not be longer than 80 characters!");
+ } else {
+ setValidationError("");
+ }
+ };
+
+ const handleSave = async (event: React.FormEvent<HTMLFormElement>) => {
+ event.preventDefault();
+ if (!groupName) {
+ setValidationError("Field required!");
+ }
+ if (groupName && !validationError) {
+ const groupData: GroupDataType[] = [
+ {
+ "Groups/group_name": groupName,
+ },
+ ];
+ await UserGroupApi.addGroup(groupData);
+ toast.success(
+ <div className="toast-message">
+ Created group{" "}
+ <Link to={`/groups/${groupName}/edit`} className="custom-link">
+ {groupName}
+ </Link>
+ </div>
+ );
+ if (groupMembers.length) {
+ const membersData: MembersDataType[] = groupMembers.map((member) => ({
+ "MemberInfo/group_name": groupName,
+ "MemberInfo/user_name": member,
+ }));
+ await UserGroupApi.updateMembers(groupName, membersData);
+ }
+ if (groupAccess !== DefaultAccess.NONE) {
+ const privilegesData: PrivilegesDataType[] = [
+ {
+ PrivilegeInfo: {
+ permission_name: permissionLabelToName[groupAccess],
+ principal_name: groupName,
+ principal_type: PrincipalType.GROUP,
+ },
+ },
+ ];
+ await PrivilegeApi.addClusterPrivileges(clusterName, privilegesData);
+ }
+ resetValues();
+ setShowAddGroupModal(false);
+ successCallback();
+ }
+ };
+
+ const resetValues = () => {
+ setGroupName("");
+ setGroupMembers([]);
+ setGroupAccess("None");
+ };
+
+ const handleCancel = () => {
+ if (groupName || groupMembers.length || groupAccess !== "None") {
+ setShowAddGroupCancelWarning(true);
+ } else {
+ setShowAddGroupModal(false);
+ }
+ };
+
+ const handleWarningSave = (event: any) => {
+ setShowAddGroupCancelWarning(false);
+ handleSave(event);
+ };
+
+ const handleWarningDiscard = () => {
+ setShowAddGroupCancelWarning(false);
+ setShowAddGroupModal(false);
+ };
+
+ return (
+ <Modal
+ show={showAddGroupModal}
+ onHide={handleCancel}
+ size="lg"
+ className="custom-modal-container"
+ data-testid="add-group-modal"
+ >
+ <Modal.Header closeButton>
+ <Modal.Title className="ms-2">Add Groups</Modal.Title>
+ </Modal.Header>
+ {loading ? (
+ <Spinner />
+ ) : (
+ <Form onSubmit={handleSave}>
+ <Modal.Body>
+ <Form.Group className="mb-4">
+ <Form.Label>Group name *</Form.Label>
+ <Form.Control
+ type="text"
+ value={groupName}
+ placeholder="Group name"
+ className={
+ validationError
+ ? "custom-form-control border-danger"
+ : "custom-form-control"
+ }
+ onChange={(e) => {
+ setGroupName(e.target.value);
+ validateGroupName(e.target.value);
+ }}
+ data-testid="group-name-input"
+ />
+ {validationError ? (
+ <div className="text-danger mt-1">
+ <FontAwesomeIcon icon={faCircleXmark} /> {validationError}
+ </div>
+ ) : null}
+ </Form.Group>
+ <Form.Group className="mb-4">
+ <Form.Label>Add users to this group</Form.Label>
+ <Select<SelectOptionType, true>
+ isMulti
+ name="users"
+ options={userOptions}
+ className="basic-multi-select"
+ placeholder="Add User"
+ value={groupMembers.map((user) => ({
+ value: user,
+ label: user,
+ }))}
+ onChange={(e) => setGroupMembers(e.map((user) => user.value))}
+ aria-label="select-user"
+ ></Select>
+ </Form.Group>
+ <Form.Group className="mb-4">
+ <Form.Label>
+ Group Access *{" "}
+ <FontAwesomeIcon
+ icon={faQuestionCircle}
+ onClick={() => setShowRoleAccessModal(true)}
+ data-testid="group-access-help-icon"
+ />
+ <RoleBasedAccessControl
+ isOpen={showRoleAccessModal}
+ onClose={() => setShowRoleAccessModal(false)}
+ />
+ </Form.Label>
+ <Form.Select
+ aria-label="Select"
+ className="w-50 custom-form-control"
+ onChange={(e) =>
+ setGroupAccess(e.target.value as PermissionLabel)
+ }
+ data-testid="group-access-dropdown"
+ >
+ {userAccessOptions.map((accessOption, idx) => {
+ return (
+ <option key={idx} value={accessOption}>
+ {accessOption}
+ </option>
+ );
+ })}
+ </Form.Select>
+ </Form.Group>
+ </Modal.Body>
+ <Modal.Footer>
+ <DefaultButton onClick={handleCancel}
data-testid="add-group-cancel-btn">Cancel</DefaultButton>
+ <WarningModal
+ isOpen={showAddGroupCancelWarning}
+ onClose={() => setShowAddGroupCancelWarning(false)}
+ handleWarningDiscard={handleWarningDiscard}
+ handleWarningSave={handleWarningSave}
+ />
+ <Button type="submit" variant="success"
data-testid="add-group-save-btn">
+ SAVE
+ </Button>
+ </Modal.Footer>
+ </Form>
+ )}
+ </Modal>
+ );
+}
diff --git
a/ambari-admin/src/main/resources/ui/ambari-admin/src/screens/Users/index.tsx
b/ambari-admin/src/main/resources/ui/ambari-admin/src/screens/Users/index.tsx
index 19d4bb30dd..57c535642a 100644
---
a/ambari-admin/src/main/resources/ui/ambari-admin/src/screens/Users/index.tsx
+++
b/ambari-admin/src/main/resources/ui/ambari-admin/src/screens/Users/index.tsx
@@ -52,6 +52,7 @@ import {
parseJSONData,
} from "../../api/Utility";
import AddUser from "./AddUser";
+import AddGroup from "./AddGroup";
export default function Users() {
const [currentLoggedInUser, setCurrentLoggedInUser] = useState("");
@@ -404,6 +405,13 @@ export default function Users() {
buttonVariant="danger"
/>
) : null}
+ {showAddGroupModal ? (
+ <AddGroup
+ showAddGroupModal={showAddGroupModal}
+ setShowAddGroupModal={setShowAddGroupModal}
+ successCallback={() => getGroupsList()}
+ />
+ ) : null}
{showDeleteGroupModal ? (
<ConfirmationModal
isOpen={showDeleteGroupModal}
diff --git
a/ambari-admin/src/main/resources/ui/ambari-admin/src/tests/AddGroup.test.tsx
b/ambari-admin/src/main/resources/ui/ambari-admin/src/tests/AddGroup.test.tsx
new file mode 100644
index 0000000000..35412be459
--- /dev/null
+++
b/ambari-admin/src/main/resources/ui/ambari-admin/src/tests/AddGroup.test.tsx
@@ -0,0 +1,258 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { render, screen, fireEvent, waitFor } from "@testing-library/react";
+import AddGroup from "../screens/Users/AddGroup";
+import { describe, it, beforeEach, expect, vi } from "vitest";
+import "@testing-library/jest-dom/vitest";
+import UserGroupApi from "../api/userGroupApi";
+import { Router } from "react-router";
+import AppContent from "../context/AppContext";
+import { createMemoryHistory } from "history";
+import toast from "react-hot-toast";
+import { userNames } from "../__mocks__/mockUserNames";
+import { GroupDataType } from "../api/types";
+import { get } from "lodash";
+import { rbacData } from "../__mocks__/mockRbacData";
+
+describe("AddGroup component", () => {
+ const mockClusterName = "testCluster";
+ const mockContext = {
+ cluster: { cluster_name: mockClusterName },
+ rbacData: {},
+ setRbacData: () => vi.fn(),
+ permissionLabelList: [],
+ setPermissionLabelList: vi.fn(),
+ };
+ const mockProps = {
+ showAddGroupModal: true,
+ setShowAddGroupModal: vi.fn(),
+ successCallback: vi.fn(),
+ };
+
+ let mockToastSuccessMessage = "";
+ let mockToastErrorMessage = "";
+
+ toast.success = (message) => {
+ mockToastSuccessMessage = message as string;
+ return "";
+ };
+
+ toast.error = (message) => {
+ mockToastErrorMessage = message as string;
+ return "";
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockToastSuccessMessage = "";
+ mockToastErrorMessage = "";
+ UserGroupApi.userNames = async () => userNames;
+ UserGroupApi.getPermissions = async () => rbacData;
+ });
+
+ const renderComponent = (props = mockProps) => {
+ render(
+ <AppContent.Provider value={mockContext}>
+ <Router history={createMemoryHistory()}>
+ <AddGroup {...props} />
+ </Router>
+ </AppContent.Provider>
+ );
+ };
+
+ it("renders AddGroup modal without crashing", () => {
+ renderComponent();
+ expect(screen.getByTestId("add-group-modal")).toBeInTheDocument();
+ });
+
+ it("renders spinner while fetching user names", () => {
+ renderComponent();
+ expect(screen.getByTestId("admin-spinner")).toBeInTheDocument();
+ });
+
+ it("should display required errors when input fields are empty", async () =>
{
+ renderComponent();
+
+ await waitFor(() => {
+ screen.getByLabelText("select-user");
+ });
+
+ const saveButton = screen.getByTestId("add-group-save-btn");
+ fireEvent.click(saveButton);
+
+ await waitFor(() => {
+ expect(screen.getByText("Field required!")).toBeInTheDocument();
+ });
+ });
+
+ it("renders group name input and validates input", async () => {
+ renderComponent();
+
+ await waitFor(() => {
+ screen.getByLabelText("select-user");
+ });
+
+ const saveButton = screen.getByTestId("add-group-save-btn");
+ fireEvent.click(saveButton);
+
+ const groupNameInput = screen.getByTestId("group-name-input");
+ fireEvent.change(groupNameInput, {
+ target: { value: "invalid&groupname" },
+ });
+
+ await waitFor(() => {
+ expect(
+ screen.getByText("Must not contain special characters!")
+ ).toBeInTheDocument();
+ });
+
+ fireEvent.change(groupNameInput, {
+ target: {
+ value:
+
"012345678901234567890123456789012345678901234567890123456789012345678901234567891",
+ },
+ });
+
+ await waitFor(() => {
+ expect(
+ screen.getByText("Must not be longer than 80 characters!")
+ ).toBeInTheDocument();
+ });
+
+ fireEvent.change(groupNameInput, {
+ target: { value: "validgroupname" },
+ });
+
+ await waitFor(() => {
+ expect(
+ screen.queryByText("Must not be longer than 80 characters!")
+ ).not.toBeInTheDocument();
+ });
+ });
+
+ it("renders the multi-select for user addition to the group", async () => {
+ renderComponent();
+
+ await waitFor(() => {
+ expect(screen.getByLabelText("select-user")).toBeInTheDocument();
+ });
+
+ const userSelect = screen.getByLabelText("select-user");
+
+ fireEvent.mouseDown(userSelect);
+ fireEvent.click(screen.getByText("cdscs"));
+
+ expect(screen.getByText("cdscs")).toBeInTheDocument();
+ });
+
+ it("renders group access dropdown and selects an option", async () => {
+ renderComponent();
+
+ await waitFor(() => {
+ screen.getByLabelText("select-user");
+ });
+
+ const groupAccessSelect = screen.getByTestId(
+ "group-access-dropdown"
+ ) as HTMLSelectElement;
+ fireEvent.change(groupAccessSelect, { target: { value: "Cluster User" } });
+
+ expect(groupAccessSelect.value).toBe("Cluster User");
+ });
+
+ it("calls handleSave on form submit with valid data", async () => {
+ UserGroupApi.addGroup = async (groupData: GroupDataType[]) => {
+ toast.success(
+ `Created group ${get(groupData[0], "Groups/group_name", "")}`
+ );
+ return { status: 200 };
+ };
+
+ renderComponent();
+
+ await waitFor(() => {
+ screen.getByLabelText("select-user");
+ });
+
+ const groupNameInput = screen.getByTestId("group-name-input");
+ fireEvent.change(groupNameInput, { target: { value: "validgroupname" } });
+
+ const saveButton = screen.getByTestId("add-group-save-btn");
+ fireEvent.click(saveButton);
+
+ await waitFor(() => {
+ expect(mockToastSuccessMessage).toBe("Created group validgroupname");
+ });
+ });
+
+ it("calls handleSave on form submit but API got failed", async () => {
+ UserGroupApi.addGroup = async (groupData: GroupDataType[]) => {
+ toast.error(
+ `Error while adding group ${get(groupData[0], "Groups/group_name",
"")}`
+ );
+ return { status: 400 };
+ };
+
+ renderComponent();
+
+ await waitFor(() => {
+ screen.getByLabelText("select-user");
+ });
+
+ const groupNameInput = screen.getByTestId("group-name-input");
+ fireEvent.change(groupNameInput, { target: { value: "validgroupname" } });
+
+ const saveButton = screen.getByTestId("add-group-save-btn");
+ fireEvent.click(saveButton);
+
+ await waitFor(() => {
+ expect(mockToastErrorMessage).toBe(
+ "Error while adding group validgroupname"
+ );
+ });
+ });
+
+ it("calls handleCancel and shows warning modal if form is dirty", async ()
=> {
+ renderComponent();
+
+ await waitFor(() => {
+ screen.getByLabelText("select-user");
+ });
+
+ const groupNameInput = screen.getByTestId("group-name-input");
+ fireEvent.change(groupNameInput, { target: { value: "validgroupname" } });
+
+ const cancelButton = screen.getByTestId("add-group-cancel-btn");
+ fireEvent.click(cancelButton);
+
+ expect(screen.getByTestId("warning-modal")).toBeInTheDocument();
+ });
+
+ it("renders RoleBasedAccessControl modal", async () => {
+ renderComponent();
+
+ await waitFor(() => {
+ screen.getByLabelText("select-user");
+ });
+
+ const roleAccessIcon = screen.getByTestId("group-access-help-icon");
+ fireEvent.click(roleAccessIcon);
+
+ expect(screen.getByText("Role Based Access Control")).toBeInTheDocument();
+ });
+});
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]