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 96e15b5f99 AMBARI-26179: Ambari Admin React Implementation:Edit Remote
Cluster #3888
96e15b5f99 is described below
commit 96e15b5f99fc3c236d81eea69e5b4399518d814e
Author: Sandeep Kumar <[email protected]>
AuthorDate: Fri Nov 22 07:24:22 2024 +0530
AMBARI-26179: Ambari Admin React Implementation:Edit Remote Cluster #3888
---
.../RemoteClusters/EditRemoteCluster.tsx | 327 +++++++++++++++++
.../src/tests/EditRemoteCluster.test.tsx | 386 +++++++++++++++++++++
2 files changed, 713 insertions(+)
diff --git
a/ambari-admin/src/main/resources/ui/ambari-admin/src/screens/ClusterManagement/RemoteClusters/EditRemoteCluster.tsx
b/ambari-admin/src/main/resources/ui/ambari-admin/src/screens/ClusterManagement/RemoteClusters/EditRemoteCluster.tsx
new file mode 100644
index 0000000000..d93524a2c6
--- /dev/null
+++
b/ambari-admin/src/main/resources/ui/ambari-admin/src/screens/ClusterManagement/RemoteClusters/EditRemoteCluster.tsx
@@ -0,0 +1,327 @@
+/**
+ * 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 { useState, useEffect, useMemo, useContext } from "react";
+import { Link, useParams, useHistory } from "react-router-dom";
+import { Button, Form, Modal } from "react-bootstrap";
+import toast from "react-hot-toast";
+import Spinner from "../../../components/Spinner";
+import RemoteClusterApi from "../../../api/remoteCluster";
+import DefaultButton from "../../../components/DefaultButton";
+import AppContent from "../../../context/AppContext";
+
+export default function EditRemoteCluster() {
+ const { clusterName } = useParams() as any;
+ const [cluster, setCluster] = useState({
+ name: "",
+ url: "",
+ username: "",
+ password: "",
+ });
+ const [clusterCopy, setClusterCopy] = useState({ ...cluster });
+ const [showCredentialModal, setShowCredentialModal] = useState(false);
+ const [errorMessageClusterName, setErrorMessageClusterName] = useState("");
+ const [errorMessageUrl, setErrorMessageUrl] = useState("");
+ const [loading, setLoading] = useState(false);
+ const history = useHistory();
+
+ const { setSelectedOption } = useContext(AppContent);
+
+ // Fetch cluster information when component mounts
+ useEffect(() => {
+ setSelectedOption("Remote Clusters");
+ const fetchClusterInfo = async () => {
+ setLoading(true)
+ try {
+ const response = await RemoteClusterApi.getRemoteClusterByName(
+ clusterName
+ );
+ setCluster(response.ClusterInfo);
+ setClusterCopy(response.ClusterInfo);
+ } catch (error) {
+ if (error instanceof Error)
+ toast("Error while fetching cluster information: " + error.message);
+ else toast("Error fetching cluster information");
+ }
+ setLoading(false);
+ };
+ fetchClusterInfo();
+ }, []);
+
+ const handleUpdate = async (event: any) => {
+ event.preventDefault();
+ validateInputs("name", clusterCopy.name);
+ validateInputs("url", clusterCopy.url);
+
+ if (
+ !event.currentTarget.checkValidity() ||
+ errorMessageClusterName ||
+ errorMessageUrl
+ ) {
+ return;
+ }
+
+ try {
+ await RemoteClusterApi.updateRemoteCluster(clusterName, clusterCopy);
+ setCluster(clusterCopy);
+ toast.success(
+ <div className="toast-message">
+ Cluster "{clusterName}" updated successfully!
+ </div>
+ );
+ setShowCredentialModal(false);
+ setErrorMessageClusterName("");
+ setErrorMessageUrl("");
+ } catch (error) {
+ if (error instanceof Error)
+ toast.error(
+ <div className="toast-message">
+ Error while updating cluster: {error.message}
+ </div>
+ );
+ else toast.error("Error while updating cluster");
+ }
+ };
+
+ const validateInputs = (name: string, value: string) => {
+ let urlPattern = new RegExp(
+ "^https?://[a-zA-Z0-9]+.example.com(:[0-9]{4})?(/.*)?$"
+ );
+ let clusterNamePattern = new RegExp("^[A-Za-z0-9]{1,80}$");
+
+ if (name === "url") {
+ if (value === "") {
+ setErrorMessageUrl("This field is required.");
+ } else if (!urlPattern.test(value)) {
+ setErrorMessageUrl("Must be a valid URL.");
+ } else {
+ setErrorMessageUrl("");
+ }
+ }
+
+ if (name === "name") {
+ if (value === "") {
+ setErrorMessageClusterName("This field is required.");
+ } else if (!clusterNamePattern.test(value)) {
+ setErrorMessageClusterName(
+ "Must not contain any special characters or spaces."
+ );
+ } else {
+ setErrorMessageClusterName("");
+ }
+ }
+ };
+
+ const handleInputChange = (e: any) => {
+ const { name, value } = e.target;
+ setClusterCopy({ ...clusterCopy, [name]: value });
+ validateInputs(name, value);
+ };
+
+ const isModified = useMemo(() => {
+ return JSON.stringify(cluster) !== JSON.stringify(clusterCopy);
+ }, [cluster, clusterCopy]);
+
+ if (loading) {
+ return <Spinner />;
+ }
+ return (
+ <>
+ <div className="d-flex justify-content-between align-items-center
border-bottom pb-3">
+ <div className="d-flex ">
+ <Link to={`/remoteClusters`} className="fs-lg text-decoration-none">
+ <h4 className="custom-link">Remote Clusters </h4>
+ </Link>
+ <h4 className="mx-2">/</h4>
+ <h4>{cluster.name}</h4>
+ </div>
+ <Button
+ variant="danger"
+ className="px-3 rounded-1"
+ size="sm"
+ >
+ DEREGISTER CLUSTER
+ </Button>
+ </div>
+ <div>
+ <Form onSubmit={handleUpdate} className="mt-4">
+ <Form.Group
+ className="row align-items-center"
+ controlId="formClusterName"
+ >
+ <Form.Label className="col-sm-2 text-center">
+ Cluster Name*
+ </Form.Label>
+ <div className="col-sm-10">
+ <Form.Control
+ className="rounded-1"
+ type="text"
+ isInvalid={!!errorMessageClusterName}
+ name="name"
+ pattern="^[A-Za-z0-9]{1,80}$"
+ maxLength={80}
+ value={clusterCopy.name}
+ onChange={handleInputChange}
+ required
+ />
+ <Form.Control.Feedback type="invalid">
+ {errorMessageClusterName}
+ </Form.Control.Feedback>
+ </div>
+ </Form.Group>
+
+ <Form.Group
+ controlId="formAmbariClusterUrl"
+ className="mt-3 row align-items-center"
+ >
+ <Form.Label className="col-sm-2 text-center">
+ Ambari Cluster URL*
+ </Form.Label>
+ <div className="col-sm-10">
+ <Form.Control
+ isInvalid={!!errorMessageUrl}
+ className="col-sm-10 rounded-1"
+ type="text"
+ name="url"
+ value={clusterCopy.url}
+ onChange={handleInputChange}
+
pattern="^https?:\/\/[a-zA-Z0-9]+\.example\.com(:[0-9]{4})?(\/.*)?$"
+ maxLength={80}
+ required
+ />
+ <Form.Control.Feedback type="invalid">
+ {errorMessageUrl}
+ </Form.Control.Feedback>
+ </div>
+ </Form.Group>
+
+ <Form.Group
+ controlId="updateCredentials"
+ className="mt-3 row align-items-center"
+ >
+ <Form.Label className="col-sm-2"></Form.Label>
+ <div className="col-sm-2" data-testid="updateCredentialButton">
+ <DefaultButton
+ onClick={() => setShowCredentialModal(true)}
+ variant="outline-secondary"
+ >
+ Update Credentials
+ </DefaultButton>
+ </div>
+ </Form.Group>
+
+ <br />
+ <div className="d-flex justify-content-end mt-1">
+ <div>
+ <DefaultButton
+ variant="outline-secondary"
+ className="text-uppercase"
+ onClick={() => history.push("/remoteClusters")}
+ >
+ Cancel
+ </DefaultButton>
+ </div>
+ <Button
+ className="text-uppercase mx-2"
+ type="submit"
+ variant="outline-success"
+ size="sm"
+ disabled={!isModified}
+ >
+ Save
+ </Button>
+ </div>
+ </Form>
+ </div>
+
+ <Modal
+ show={showCredentialModal}
+ onHide={() => setShowCredentialModal(false)}
+ >
+ <Form onSubmit={handleUpdate}>
+ <Modal.Header closeButton>
+ <Modal.Title>Update Credentials</Modal.Title>
+ </Modal.Header>
+ <Modal.Body>
+ <Form.Group
+ controlId="formClusterUser"
+ className="row align-items-center"
+ >
+ <Form.Label className="col-sm-4 text-center">
+ Cluster User*
+ </Form.Label>
+ <div className="col-sm-8">
+ <Form.Control
+ type="text"
+ name="username"
+ value={clusterCopy.username}
+ maxLength={80}
+ onChange={handleInputChange}
+ required
+ />
+ </div>
+ </Form.Group>
+ <Form.Group
+ controlId="formClusterPassword"
+ className="mt-2 row align-items-center"
+ >
+ <Form.Label className="col-sm-4 text-center">
+ Password*
+ </Form.Label>
+ <div className="col-sm-8">
+ <Form.Control
+ type="password"
+ name="password"
+ value={clusterCopy.password}
+ maxLength={80}
+ onChange={handleInputChange}
+ data-testid="password-input"
+ required
+ />
+ </div>
+ </Form.Group>
+ </Modal.Body>
+ <Modal.Footer className="justify-content-end">
+ <DefaultButton
+ size="sm"
+ onClick={() => {
+ setShowCredentialModal(false);
+ setClusterCopy((prevState) => ({
+ ...prevState,
+ username: cluster.username,
+ password: cluster.password,
+ }));
+ }}
+ className="outline secondary"
+ >
+ Cancel
+ </DefaultButton>
+ <Button
+ className="rounded-1 text-uppercase"
+ type="submit"
+ variant="success"
+ size="sm"
+ disabled={!isModified}
+ >
+ Update
+ </Button>
+ </Modal.Footer>
+ </Form>
+ </Modal>
+ </>
+ );
+}
diff --git
a/ambari-admin/src/main/resources/ui/ambari-admin/src/tests/EditRemoteCluster.test.tsx
b/ambari-admin/src/main/resources/ui/ambari-admin/src/tests/EditRemoteCluster.test.tsx
new file mode 100644
index 0000000000..27d285c1cb
--- /dev/null
+++
b/ambari-admin/src/main/resources/ui/ambari-admin/src/tests/EditRemoteCluster.test.tsx
@@ -0,0 +1,386 @@
+/**
+ * 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 { describe, it, expect, beforeEach } from "vitest";
+import {
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+ within,
+} from "@testing-library/react";
+import { createMemoryHistory } from "history";
+import { Router } from "react-router-dom";
+import { mockClusterDataForEdit } from "../__mocks__/mockRemoteCluster";
+import "@testing-library/jest-dom/vitest";
+import RemoteClusterApi from "../api/remoteCluster";
+import EditRemoteCluster from
"../screens/ClusterManagement/RemoteClusters/EditRemoteCluster";
+import AppContent from "../context/AppContext";
+import toast from "react-hot-toast";
+
+const mockClusterName = "TestCluster1";
+const mockContext = {
+ cluster: { cluster_name: mockClusterName },
+ setSelectedOption: () => "RemoteCluster",
+};
+
+const renderEditRemoteCluster = () => {
+ render(
+ <AppContent.Provider value={mockContext}>
+ <Router history={createMemoryHistory()}>
+ <EditRemoteCluster />
+ </Router>
+ </AppContent.Provider>
+ );
+};
+
+let mockToastSuccessMessage: string;
+let mockToastErrorMessage: string;
+
+toast.success = (message) => {
+ mockToastSuccessMessage = message as string;
+ return "";
+};
+
+toast.error = (message) => {
+ mockToastErrorMessage = message as string;
+ return "";
+};
+
+describe("EditRemoteCluster component", () => {
+ beforeEach(() => {
+ mockToastErrorMessage = "";
+ mockToastSuccessMessage = "";
+ });
+
+ it("renders without crashing", () => {
+ renderEditRemoteCluster();
+ });
+
+ it("shows loading spinner when data is being fetched.", async () => {
+ RemoteClusterApi.getRemoteClusterByName = async () => [];
+ renderEditRemoteCluster();
+
+ const spinner = screen.getByTestId("admin-spinner");
+ expect(spinner).toBeInTheDocument();
+ });
+
+ it("should display current details of remote cluster", async () => {
+ RemoteClusterApi.getRemoteClusterByName = async () =>
+ mockClusterDataForEdit;
+ renderEditRemoteCluster();
+
+ await waitFor(() => {});
+ expect(getClusterNameInput()).toHaveValue(
+ mockClusterDataForEdit.ClusterInfo.name
+ );
+ expect(getclusterUrlInput()).toHaveValue(
+ mockClusterDataForEdit.ClusterInfo.url
+ );
+ });
+
+ it("should redirect to /remoteCluster on clicking cancel button", async ()
=> {
+ RemoteClusterApi.getRemoteClusterByName = async () =>
+ mockClusterDataForEdit;
+ const history = createMemoryHistory();
+ render(
+ <AppContent.Provider value={mockContext}>
+ <Router history={history}>
+ <EditRemoteCluster />
+ </Router>
+ </AppContent.Provider>
+ );
+
+ await waitFor(() => {});
+ const cancelButton = screen.getByRole("button", { name: /cancel/i });
+ expect(cancelButton).toBeInTheDocument();
+
+ fireEvent.click(cancelButton);
+ await waitFor(() => {});
+
+ await waitFor(() => {
+ expect(history.location.pathname).toBe("/remoteClusters");
+ });
+ });
+
+ it("loads data and displays update credential modal with username, password
input fields, and buttons", async () => {
+ RemoteClusterApi.getRemoteClusterByName = async () =>
+ mockClusterDataForEdit;
+ renderEditRemoteCluster();
+
+ await waitFor(() => {
+ expect(getClusterNameInput()).toHaveValue(
+ mockClusterDataForEdit.ClusterInfo.name
+ );
+ expect(getclusterUrlInput()).toHaveValue(
+ mockClusterDataForEdit.ClusterInfo.url
+ );
+ });
+
+ const updateCredentialButton = screen
+ .getByTestId("updateCredentialButton")
+ .querySelector("button");
+ if (updateCredentialButton) {
+ fireEvent.click(updateCredentialButton);
+ } else {
+ throw new Error("Update credential button not found");
+ }
+
+ // Wait for the modal to be rendered
+ await waitFor(() => {
+ const dialogs = screen.getAllByRole("dialog");
+ expect(dialogs.length).toBe(1);
+
+ const updateCredentialsDialog = dialogs[0];
+ expect(
+ within(updateCredentialsDialog).getByText(/Update Credentials/i)
+ ).toBeInTheDocument();
+ });
+
+ // Check if the user and password fields are rendered
+ const updateCredentialsDialog = screen.getAllByRole("dialog")[0];
+ expect(
+ within(updateCredentialsDialog).getByLabelText(/Cluster user/i)
+ ).toBeInTheDocument();
+ expect(
+ within(updateCredentialsDialog).getByTestId("password-input")
+ ).toBeInTheDocument();
+
+ // Check if the cancel and update buttons are rendered
+ expect(
+ within(updateCredentialsDialog).getByRole("button", { name: /Cancel/i })
+ ).toBeInTheDocument();
+ expect(
+ within(updateCredentialsDialog).getByRole("button", { name: /Update/i })
+ ).toBeInTheDocument();
+ });
+
+ it("should update cluster name after submitting", async () => {
+ RemoteClusterApi.getRemoteClusterByName = async () =>
+ mockClusterDataForEdit;
+ RemoteClusterApi.updateRemoteCluster = async () => {
+ toast.success(`Cluster "UpdatedClusterName" updated successfully`);
+ return { status: 200 };
+ };
+ renderEditRemoteCluster();
+
+ await waitFor(() => {
+ expect(
+ screen.getByDisplayValue(mockClusterDataForEdit.ClusterInfo.name)
+ ).toBeInTheDocument();
+ });
+ fireEvent.change(
+ screen.getByDisplayValue(mockClusterDataForEdit.ClusterInfo.name),
+ { target: { value: "UpdatedClusterName" } }
+ );
+ fireEvent.click(getSaveButton());
+ await waitFor(() => {
+ expect(mockToastSuccessMessage).toBe(
+ 'Cluster "UpdatedClusterName" updated successfully'
+ );
+ });
+ });
+
+ it("should display error when API call fails for clustername", async () => {
+ RemoteClusterApi.getRemoteClusterByName = async () =>
+ mockClusterDataForEdit;
+ RemoteClusterApi.updateRemoteCluster = async () => {
+ toast.error("Error while updating remote cluster");
+ return { status: 400 };
+ };
+ renderEditRemoteCluster();
+
+ await waitFor(() => {
+ expect(
+ screen.getByDisplayValue(mockClusterDataForEdit.ClusterInfo.name)
+ ).toBeInTheDocument();
+ });
+ fireEvent.change(
+ screen.getByDisplayValue(mockClusterDataForEdit.ClusterInfo.name),
+ { target: { value: "UpdatedClusterName" } }
+ );
+ fireEvent.click(getSaveButton());
+
+ await waitFor(() => {
+ expect(mockToastErrorMessage).toBe("Error while updating remote
cluster");
+ });
+ });
+
+ it("should show modal and populate username when clicking update credentials
button", async () => {
+ RemoteClusterApi.getRemoteClusterByName = async () =>
+ mockClusterDataForEdit;
+ renderEditRemoteCluster();
+
+ await waitFor(() => {
+ expect(getClusterNameInput()).toHaveValue(
+ mockClusterDataForEdit.ClusterInfo.name
+ );
+ });
+ const updateCredentialButton = screen
+ .getByTestId("updateCredentialButton")
+ .querySelector("button");
+ if (updateCredentialButton) {
+ fireEvent.click(updateCredentialButton);
+ } else {
+ throw new Error("Update credential button not found");
+ }
+
+ // Wait for the modal to be rendered
+ await waitFor(() => {
+ const dialogs = screen.getAllByRole("dialog");
+ expect(dialogs.length).toBe(1);
+
+ const updateCredentialsDialog = dialogs[0];
+ expect(
+ within(updateCredentialsDialog).getByText(/Update Credentials/i)
+ ).toBeInTheDocument();
+ });
+
+ // Check if the username field is populated
+ const updateCredentialsDialog = screen.getAllByRole("dialog")[0];
+ expect(
+ within(updateCredentialsDialog).getByLabelText(/Cluster user/i)
+ ).toHaveValue(mockClusterDataForEdit.ClusterInfo.username);
+ });
+
+ it("should display success message when updating credentials successfully",
async () => {
+ RemoteClusterApi.getRemoteClusterByName = async () =>
+ mockClusterDataForEdit;
+ RemoteClusterApi.updateRemoteCluster = async () => {
+ toast.success(
+ `Credentials for Cluster "${mockClusterName}" updated successfully.`
+ );
+ return { status: 200 };
+ };
+ renderEditRemoteCluster();
+
+ await waitFor(() => {
+ expect(getClusterNameInput()).toHaveValue(
+ mockClusterDataForEdit.ClusterInfo.name
+ );
+ });
+
+ const updateCredentialButton = screen
+ .getByTestId("updateCredentialButton")
+ .querySelector("button");
+ if (updateCredentialButton) {
+ fireEvent.click(updateCredentialButton);
+ } else {
+ throw new Error("Update credential button not found");
+ }
+
+ // Wait for the modal to be rendered
+ await waitFor(() => {
+ const dialogs = screen.getAllByRole("dialog");
+ expect(dialogs.length).toBe(1);
+
+ const updateCredentialsDialog = dialogs[0];
+ expect(
+ within(updateCredentialsDialog).getByText(/Update Credentials/i)
+ ).toBeInTheDocument();
+ });
+
+ // Update username and password
+ const updateCredentialsDialog = screen.getAllByRole("dialog")[0];
+ fireEvent.change(
+ within(updateCredentialsDialog).getByLabelText(/Cluster user/i),
+ { target: { value: "newuser" } }
+ );
+ fireEvent.change(
+ within(updateCredentialsDialog).getByTestId("password-input"),
+ { target: { value: "newPassword" } }
+ );
+ fireEvent.click(
+ within(updateCredentialsDialog).getByRole("button", { name: /Update/i })
+ );
+
+ await waitFor(() => {
+ expect(mockToastSuccessMessage).toBe(
+ `Credentials for Cluster "${mockClusterName}" updated successfully.`
+ );
+ });
+ });
+
+ it("should display error message when updating credentials fails", async ()
=> {
+ RemoteClusterApi.getRemoteClusterByName = async () =>
+ mockClusterDataForEdit;
+ RemoteClusterApi.updateRemoteCluster = async () => {
+ toast.error("Error while updating credentials");
+ return { status: 400 };
+ };
+
+ renderEditRemoteCluster();
+
+ await waitFor(() => {
+ expect(getClusterNameInput()).toHaveValue(
+ mockClusterDataForEdit.ClusterInfo.name
+ );
+ });
+
+ const updateCredentialButton = screen
+ .getByTestId("updateCredentialButton")
+ .querySelector("button");
+ if (updateCredentialButton) {
+ fireEvent.click(updateCredentialButton);
+ } else {
+ throw new Error("Update credential button not found");
+ }
+
+ // Wait for the modal to be rendered
+ await waitFor(() => {
+ const dialogs = screen.getAllByRole("dialog");
+ expect(dialogs.length).toBe(1);
+
+ const updateCredentialsDialog = dialogs[0];
+ expect(
+ within(updateCredentialsDialog).getByText(/Update Credentials/i)
+ ).toBeInTheDocument();
+ });
+
+ // Update username and password
+ const updateCredentialsDialog = screen.getAllByRole("dialog")[0];
+ fireEvent.change(
+ within(updateCredentialsDialog).getByLabelText(/Cluster User/i),
+ { target: { value: "newUser" } }
+ );
+ fireEvent.change(
+ within(updateCredentialsDialog).getByTestId("password-input"),
+ { target: { value: "newPassword" } }
+ );
+ fireEvent.click(
+ within(updateCredentialsDialog).getByRole("button", { name: /Update/i })
+ );
+
+ await waitFor(() => {
+ expect(mockToastErrorMessage).toBe("Error while updating credentials");
+ });
+ });
+});
+
+function getClusterNameInput() {
+ return screen.getByLabelText(/Cluster Name/i);
+}
+
+function getclusterUrlInput() {
+ return screen.getByLabelText(/Ambari Cluster URL/i);
+}
+
+function getSaveButton() {
+ return screen.getByRole("button", {
+ name: /save/i,
+ });
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]