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 d5389b61da AMBARI-26180: Ambari Admin React Implementation: Register 
Remote Cluster #3889
d5389b61da is described below

commit d5389b61dafda74d9060604cf391d639acc698a4
Author: Sandeep  Kumar <[email protected]>
AuthorDate: Fri Nov 22 07:25:04 2024 +0530

    AMBARI-26180: Ambari Admin React Implementation: Register Remote Cluster 
#3889
---
 .../RemoteClusters/RegisterRemoteCluster.tsx       | 246 +++++++++++++++++++++
 .../src/tests/RegisterRemoteCluster.test.tsx       | 206 +++++++++++++++++
 2 files changed, 452 insertions(+)

diff --git 
a/ambari-admin/src/main/resources/ui/ambari-admin/src/screens/ClusterManagement/RemoteClusters/RegisterRemoteCluster.tsx
 
b/ambari-admin/src/main/resources/ui/ambari-admin/src/screens/ClusterManagement/RemoteClusters/RegisterRemoteCluster.tsx
new file mode 100644
index 0000000000..52243a36cb
--- /dev/null
+++ 
b/ambari-admin/src/main/resources/ui/ambari-admin/src/screens/ClusterManagement/RemoteClusters/RegisterRemoteCluster.tsx
@@ -0,0 +1,246 @@
+/**
+ * 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 { Form, Button } from "react-bootstrap";
+import { useContext, useEffect, useState } from "react";
+import { Link } from "react-router-dom";
+import { useHistory } from "react-router-dom";
+import toast from "react-hot-toast";
+import DefaultButton from "../../../components/DefaultButton";
+import RemoteClusterApi from "../../../api/remoteCluster";
+import AppContent from "../../../context/AppContext";
+
+export default function RegisterRemoteCluster() {
+  const [cluster, setCluster] = useState({
+    name: "",
+    url: "",
+    username: "",
+    password: "",
+  });
+  const [validated, setValidated] = useState(false);
+  const [errorMessageClusterName, setErrorMessageClusterName] = useState("");
+  const [errorMessageUrl, setErrorMessageUrl] = useState("");
+  const history = useHistory();
+
+  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 { setSelectedOption } = useContext(AppContent);
+
+  useEffect(() => {setSelectedOption("Remote Clusters")}, []);
+
+  const handleInputChange = (e: any) => {
+    const { name, value } = e.target;
+    setCluster({ ...cluster, [name]: value });
+    validateInputs(name, value);
+  };
+
+  const handleSubmit = async (e: any) => {
+    e.preventDefault();
+    setValidated(true);
+
+    validateInputs("name", cluster.name);
+    validateInputs("url", cluster.url);
+
+    if (
+      !e.currentTarget.checkValidity() ||
+      errorMessageUrl ||
+      errorMessageClusterName
+    ) {
+      return;
+    }
+
+    if (e.currentTarget.checkValidity()) {
+      try {
+        await RemoteClusterApi.addRemoteCluster(cluster);
+        toast.success(
+          <div className="toast-message">
+            Cluster "{cluster.name}" registered successfully!
+          </div>
+        );
+        history.push(`/remoteClusters/${cluster.name}/edit`);
+      } catch (error) {
+        if (error instanceof Error)
+          toast.error(
+            <div className="toast-message">
+              Error while adding remote Cluster: {error.message}
+            </div>
+          );
+        else toast.error("Error while adding remote cluster");
+      }
+    }
+  };
+
+  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>Register</h4>
+        </div>
+      </div>
+
+      <Form
+        onSubmit={handleSubmit}
+        className="mt-4"
+        noValidate
+        validated={validated}
+      >
+        <Form.Group
+          controlId="formClusterName"
+          className="row align-items-center"
+        >
+          <Form.Label className="col-sm-2 text-center">
+            Cluster Name*
+          </Form.Label>
+          <div className="col-sm-10">
+            <Form.Control
+              className="rounded-1"
+              isInvalid={!!errorMessageClusterName}
+              type="text"
+              placeholder="Ambari Cluster Name"
+              name="name"
+              value={cluster.name}
+              pattern="^[A-Za-z0-9]{1,80}$"
+              maxLength={80}
+              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}
+              type="text"
+              
placeholder="http://ambari.server:8080/api/v1/clusters/clusterName";
+              name="url"
+              value={cluster.url}
+              
pattern="^https?:\/\/[a-zA-Z0-9]+\.example\.com(:[0-9]{4})?(\/.*)?$"
+              maxLength={80}
+              onChange={handleInputChange}
+              required
+            />
+            <Form.Control.Feedback type="invalid">
+              {errorMessageUrl}
+            </Form.Control.Feedback>
+          </div>
+        </Form.Group>
+
+        <Form.Group
+          controlId="formClusterUser"
+          className="mt-3 row align-items-center"
+        >
+          <Form.Label className="col-sm-2 text-center">
+            Cluster User*
+          </Form.Label>
+          <div className="col-sm-10">
+            <Form.Control
+              type="text"
+              placeholder="Cluster User"
+              name="username"
+              value={cluster.username}
+              onChange={handleInputChange}
+              required
+            />
+            <Form.Control.Feedback type="invalid">
+              This field is required.
+            </Form.Control.Feedback>
+          </div>
+        </Form.Group>
+
+        <Form.Group
+          controlId="formPassword"
+          className="mt-3 row align-items-center"
+        >
+          <Form.Label className="col-sm-2 text-center">Password*</Form.Label>
+          <div className="col-sm-10">
+            <Form.Control
+              type="password"
+              placeholder="Password"
+              name="password"
+              value={cluster.password}
+              onChange={handleInputChange}
+              required
+            />
+            <Form.Control.Feedback type="invalid">
+              This field is required.
+            </Form.Control.Feedback>
+          </div>
+        </Form.Group>
+
+        <div className="d-flex justify-content-end mt-4">
+          <Link className="text-decoration-none" to={`/remoteClusters`}>
+            <DefaultButton
+              variant="primary"
+              className="rounded-1 text-uppercase"
+              size="sm"
+            >
+              Cancel
+            </DefaultButton>
+          </Link>
+          <Button
+            variant="success"
+            className="ms-2 rounded-1 text-uppercase"
+            size="sm"
+            type="submit"
+          >
+            Save
+          </Button>
+        </div>
+      </Form>
+    </>
+  );
+}
diff --git 
a/ambari-admin/src/main/resources/ui/ambari-admin/src/tests/RegisterRemoteCluster.test.tsx
 
b/ambari-admin/src/main/resources/ui/ambari-admin/src/tests/RegisterRemoteCluster.test.tsx
new file mode 100644
index 0000000000..2761864039
--- /dev/null
+++ 
b/ambari-admin/src/main/resources/ui/ambari-admin/src/tests/RegisterRemoteCluster.test.tsx
@@ -0,0 +1,206 @@
+/**
+ * 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 } from "vitest";
+import { render, screen, waitFor, fireEvent } from "@testing-library/react";
+import { createMemoryHistory } from "history";
+import { Router } from "react-router-dom";
+import "@testing-library/jest-dom/vitest";
+import RemoteClusterApi from "../api/remoteCluster";
+import RegisterRemoteCluster from 
"../screens/ClusterManagement/RemoteClusters/RegisterRemoteCluster";
+import AppContent from "../context/AppContext";
+import toast from "react-hot-toast";
+
+const mockClusterName = "TestCluster1";
+const mockContext = {
+  cluster: { cluster_name: mockClusterName },
+  setSelectedOption: () => "RemoteCluster",
+};
+
+const renderRegisterRemoteCluster = () => {
+  render(
+    <AppContent.Provider value={mockContext}>
+      <Router history={createMemoryHistory()}>
+        <RegisterRemoteCluster />
+      </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("RegisterRemoteCluster component", () => {
+  it("renders without creshing", () => {
+    renderRegisterRemoteCluster();
+  });
+
+  it("render form for registering remote Cluster", () => {
+    renderRegisterRemoteCluster();
+    expect(getClusterName()).toBeInTheDocument();
+    expect(getClusterUrl()).toBeInTheDocument();
+    expect(getClusterUserName()).toBeInTheDocument();
+    expect(getPassword()).toBeInTheDocument();
+  });
+
+  it("should redirect to /remoteCluster on clicking cancel button", async () 
=> {
+    const history = createMemoryHistory();
+    render(
+      <AppContent.Provider value={mockContext}>
+        <Router history={history}>
+          <RegisterRemoteCluster />
+        </Router>
+      </AppContent.Provider>
+    );
+    expect(getClusterName()).toBeInTheDocument();
+    expect(getClusterUrl()).toBeInTheDocument();
+    expect(getClusterUserName()).toBeInTheDocument();
+    expect(getPassword()).toBeInTheDocument();
+
+    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("should display required errors when input fields are empty", async () => 
{
+    renderRegisterRemoteCluster();
+    fireEvent.click(getSaveButton());
+
+    await waitFor(async () => {
+      const errorMessages = await screen.findAllByText(/is required/i);
+      expect(errorMessages).toHaveLength(4);
+    });
+  });
+
+  it("should display error when cluster name includes special characters or 
spaces", async () => {
+    renderRegisterRemoteCluster();
+    const clusterNameInput = getClusterName();
+    fireEvent.change(clusterNameInput, {target: {value: "invalid name@"}});
+    fireEvent.keyDown(clusterNameInput, { key: "Tab", code: "Tab" });
+    fireEvent.keyUp(clusterNameInput, { key: "Tab", code: "Tab" });
+
+    await waitFor(() => {
+      expect(
+        screen.getByText("Must not contain any special characters or spaces.")
+      ).toBeInTheDocument();
+    });
+  });
+
+  it("should display error when url is invalid", async () => {
+    renderRegisterRemoteCluster();
+    const clusterUrlInput = getClusterUrl();
+    fireEvent.change(clusterUrlInput, {target: {value: "invalid_url"}});
+    fireEvent.keyDown(clusterUrlInput, { key: "Tab", code: "Tab" });
+    fireEvent.keyUp(clusterUrlInput, { key: "Tab", code: "Tab" });
+
+    await waitFor(() => {
+      expect(screen.getByText("Must be a valid URL.")).toBeInTheDocument();
+    });
+  });
+
+  it("should submit form when all values are valid", async () => {
+    RemoteClusterApi.addRemoteCluster = async () => {
+      toast.success(`Cluster "${mockClusterName}" registered successfully`);
+      return { status: 200 };
+    };
+
+    renderRegisterRemoteCluster();
+    fireEvent.change(getClusterName(), { target: { value: "TestCluster1" } });
+    fireEvent.change(getClusterUrl(), {
+      target: {
+        value: 
"http://clusterHost.example.com:8080/api/v1/clusters/clusterName";,
+      },
+    });
+    fireEvent.change(getClusterUserName(), { target: { value: "admin" } });
+    fireEvent.change(getPassword(), { target: { value: "admin" } });
+    fireEvent.click(getSaveButton());
+
+    await waitFor(() => {
+      expect(mockToastSuccessMessage).not.toBeUndefined;
+      expect(mockToastSuccessMessage).toBe(
+        `Cluster "TestCluster1" registered successfully`
+      );
+    });
+  });
+
+  it("should display error when API call fails", async () => {
+    RemoteClusterApi.addRemoteCluster = async () => {
+      toast.error("Error while adding remote cluster");
+      return { status: 400 };
+    };
+
+    renderRegisterRemoteCluster();
+    fireEvent.change(getClusterName(), { target: { value: "TestCluster1" } });
+    fireEvent.change(getClusterUrl(), {
+      target: {
+        value: 
"http://clusterHost.example.com:8080/api/v1/clusters/clusterName";,
+      },
+    });
+    fireEvent.change(getClusterUserName(), { target: { value: "admin" } });
+    fireEvent.change(getPassword(), { target: { value: "admin" } });
+    fireEvent.click(getSaveButton());
+
+    await waitFor(() => {
+      expect(mockToastErrorMessage).not.toBeUndefined;
+      expect(mockToastErrorMessage).toBe("Error while adding remote cluster");
+    });
+  });
+});
+
+function getClusterName() {
+  return screen.getByRole("textbox", {
+    name: /cluster name/i,
+  });
+}
+
+function getClusterUrl() {
+  return screen.getByRole("textbox", {
+    name: /ambari cluster/i,
+  });
+}
+
+function getClusterUserName() {
+  return screen.getByRole("textbox", {
+    name: /cluster user/i,
+  });
+}
+
+function getPassword() {
+  return screen.getByLabelText(/password/i);
+}
+
+function getSaveButton() {
+  return screen.getByRole("button", {
+    name: /save/i,
+  });
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to