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]

Reply via email to