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 a087805d8b AMBARI-26171: Ambari Admin React Implementation:
Create/Clone Views Instances Page (#3892)
a087805d8b is described below
commit a087805d8b110d4dcb8b4792f5776a83053dd5c6
Author: Himanshu Maurya <[email protected]>
AuthorDate: Mon Nov 25 06:20:42 2024 +0530
AMBARI-26171: Ambari Admin React Implementation: Create/Clone Views
Instances Page (#3892)
---
.../ui/ambari-admin/src/api/clusterApi.ts | 98 +++
.../resources/ui/ambari-admin/src/api/viewApi.ts | 44 ++
.../src/screens/Views/CreateInstance.tsx | 824 +++++++++++++++++++++
.../ui/ambari-admin/src/screens/Views/types.ts | 226 ++++++
.../src/screens/Views/viewConstants.ts | 24 +
5 files changed, 1216 insertions(+)
diff --git
a/ambari-admin/src/main/resources/ui/ambari-admin/src/api/clusterApi.ts
b/ambari-admin/src/main/resources/ui/ambari-admin/src/api/clusterApi.ts
new file mode 100644
index 0000000000..12ca2ba01f
--- /dev/null
+++ b/ambari-admin/src/main/resources/ui/ambari-admin/src/api/clusterApi.ts
@@ -0,0 +1,98 @@
+/**
+ * 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 { adminApi } from "./configs/axiosConfig";
+
+const ClusterApi = {
+ // Cluster APIs
+
+ clusterInfo: async function (fields: string) {
+ const url = `/clusters?fields=${fields}`;
+ const response = await adminApi.request({
+ url: url,
+ method: "GET",
+ });
+ return response.data;
+ },
+ hostClustersInfo: async function () {
+ const url = `/clusters?fields=Clusters/provisioning_state`;
+ const response = await adminApi.request({
+ url: url,
+ method: "GET",
+ });
+ return response.data;
+ },
+ adminAboutInfo: async function (fields: string) {
+ const url = `services/AMBARI/components/AMBARI_SERVER?fields=${fields}`;
+ const response = await adminApi.request({
+ url: url,
+ method: "GET",
+ });
+ return response.data;
+ },
+ blueprintInfo: async function (
+ clusterName: string,
+ format: string = "blueprint"
+ ) {
+ const url = `/clusters/${clusterName}?format=${format}`;
+ const response = await adminApi.request({
+ url: url,
+ method: "GET",
+ });
+ return response.data;
+ },
+ updateClusterName: async function (clusterName:
string,updatedClusterName:string) {
+ const url = `/clusters/${clusterName}`;
+ const response = await adminApi.request({
+ url: url,
+ method: "PUT",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ data: { Clusters: { cluster_name: updatedClusterName } },
+ });
+ return response.data;
+ },
+ remoteClusterInfo: async function (fields: string) {
+ const url = `/remoteclusters?fields=${fields}`;
+ const response = await adminApi.request({
+ url: url,
+ method: "GET",
+ });
+ return response.data;
+ },
+ noopPolling: async function () {
+ const timestamp = new Date().getTime();
+ const url =
`/services/AMBARI/components/AMBARI_SERVER?fields=RootServiceComponents/properties/user.inactivity.timeout.default&_=${timestamp}`;
+ const response = await adminApi.request({
+ url: url,
+ method: "GET",
+ });
+ return response;
+ },
+ getUserTimeout: async function () {
+ const timestamp = new Date().getTime();
+ const url = `services/AMBARI/components/AMBARI_SERVER?_=${timestamp}`;
+ const response = await adminApi.request({
+ url: url,
+ method: "GET",
+ });
+ return response;
+ }
+};
+
+export default ClusterApi;
diff --git a/ambari-admin/src/main/resources/ui/ambari-admin/src/api/viewApi.ts
b/ambari-admin/src/main/resources/ui/ambari-admin/src/api/viewApi.ts
new file mode 100644
index 0000000000..6d2c9fe3d8
--- /dev/null
+++ b/ambari-admin/src/main/resources/ui/ambari-admin/src/api/viewApi.ts
@@ -0,0 +1,44 @@
+/**
+ * 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 { adminApi } from "./configs/axiosConfig";
+
+const ViewApi = {
+
+ viewsListAPI: async function () {
+ const url =
`/views?fields=versions/ViewVersionInfo/version,versions/instances/ViewInstanceInfo,versions/*&versions/ViewVersionInfo/system=false`;
+ const response = await adminApi.request({
+ url: url,
+ method: "GET",
+ });
+ return response.data;
+ },
+
+ addView: async function (view: string, version: string, instanceName:
string, viewData: any) {
+ const url =
`/views/${view}/versions/${version}/instances/${instanceName}`;
+ const response = await adminApi.request({
+ url: url,
+ method: "POST",
+ data: viewData
+ });
+ return response.data;
+ }
+
+}
+
+export default ViewApi;
+
diff --git
a/ambari-admin/src/main/resources/ui/ambari-admin/src/screens/Views/CreateInstance.tsx
b/ambari-admin/src/main/resources/ui/ambari-admin/src/screens/Views/CreateInstance.tsx
new file mode 100644
index 0000000000..5189da4851
--- /dev/null
+++
b/ambari-admin/src/main/resources/ui/ambari-admin/src/screens/Views/CreateInstance.tsx
@@ -0,0 +1,824 @@
+/**
+ * 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, Nav, Tab } from "react-bootstrap";
+import DefaultButton from "../../components/DefaultButton";
+import { useEffect, useState } from "react";
+import {
+ ClusterInfoType,
+ ClusterInfoItemType,
+ ClusterType,
+ ConfigType,
+ MappingType,
+ ParametersType,
+ ViewDetailsItemType,
+ ViewDetailsType,
+ RemoteClusterInfoItemType,
+ RemoteClusterInfoType,
+ ValidationErrorType,
+} from "./types";
+import { clusterType } from "./viewConstants";
+import { cloneDeep, get, set } from "lodash";
+import toast from "react-hot-toast";
+import WarningModal from "../Users/WarningModal";
+import ClusterApi from "../../api/clusterApi.ts";
+import ViewApi from "../../api/viewApi.ts";
+import Spinner from "../../components/Spinner";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { faCircleXmark } from "@fortawesome/free-solid-svg-icons";
+import { useHistory } from "react-router";
+
+type CreateInstanceProps = {
+ isOpen: boolean;
+ onClose: (showAddUserModal: boolean) => void;
+ viewDetails: ViewDetailsType;
+ viewInstanceInfoToBeCloned?: any;
+ successCallback: () => void;
+};
+
+export default function CreateInstance({
+ isOpen,
+ onClose,
+ viewDetails,
+ viewInstanceInfoToBeCloned = {},
+ successCallback,
+}: CreateInstanceProps) {
+ const [showCancelWarning, setShowCancelWarning] = useState(false);
+ const [clusterInfo, setClusterInfo] = useState<ClusterInfoType>(
+ {} as ClusterInfoType
+ );
+ const [remoteClusterInfo, setRemoteClusterInfo] =
+ useState<RemoteClusterInfoType>({} as RemoteClusterInfoType);
+ const [loading, setLoading] = useState(false);
+ const [viewOptions, setViewOptions] = useState({});
+ const [view, setView] = useState<string>("");
+ const [version, setVersion] = useState<string>("");
+ const [instanceName, setInstanceName] = useState<string>("");
+ const [displayName, setDisplayName] = useState<string>("");
+ const [description, setDescription] = useState<string>("");
+ const [isVisible, setIsVisible] = useState<boolean>(true);
+ const [clusterHandle, setClusterHandle] = useState<string>("");
+ const [nonClusterConfigs, setNonClusterConfigs] =
useState<MappingType[]>([]);
+ const [clusterConfigs, setClusterConfigs] = useState<MappingType[]>([]);
+ const [configs, setConfigs] = useState<ConfigType>({});
+ const [validationError, setValidationError] = useState<ValidationErrorType>({
+ instanceName: "",
+ displayName: "",
+ description: "",
+ });
+ const [isFormSubmitted, setIsFormSubmitted] = useState(false);
+ const [existingInstanceList, setExistingInstanceList] =
useState<string[]>([]);
+ const history = useHistory();
+
+ const [activeKey, setActiveKey] = useState<ClusterType>(
+ Object.keys(clusterType)[0] as ClusterType
+ );
+
+ const isThisCloneInstance =
+ Object.keys(viewInstanceInfoToBeCloned).length > 0;
+
+ useEffect(() => {
+ async function getClusterInfo() {
+ setLoading(true);
+ const response = await ClusterApi.clusterInfo("Clusters/cluster_id");
+ setClusterInfo(response);
+ setLoading(false);
+ setClusterHandle(get(response, "items[0].Clusters.cluster_id",
"").toString());
+ }
+ async function getRemoteClusterInfo() {
+ setLoading(true);
+ const response = await ClusterApi.remoteClusterInfo(
+ "ClusterInfo/services,ClusterInfo/cluster_id"
+ );
+ setRemoteClusterInfo(response);
+ setLoading(false);
+ }
+ if (isOpen) {
+ getClusterInfo();
+ getRemoteClusterInfo();
+ }
+ if (isOpen && isThisCloneInstance) {
+ setClusterHandle(
+ get(viewInstanceInfoToBeCloned, "cluster_handle") === null
+ ? ""
+ : get(viewInstanceInfoToBeCloned, "cluster_handle")
+ );
+ setView(get(viewInstanceInfoToBeCloned, "view_name"));
+ setVersion(get(viewInstanceInfoToBeCloned, "version"));
+ setInstanceName(
+ get(viewInstanceInfoToBeCloned, "instance_name") + "_Copy"
+ );
+ setDisplayName(get(viewInstanceInfoToBeCloned, "label") + "_Copy");
+ setDescription(get(viewInstanceInfoToBeCloned, "description"));
+ setIsVisible(get(viewInstanceInfoToBeCloned, "visible"));
+ setConfigs(get(viewInstanceInfoToBeCloned, "properties"));
+ setActiveKey(
+ Object.entries(clusterType).find(
+ ([, value]) =>
+ value === get(viewInstanceInfoToBeCloned, "cluster_type")
+ )?.[0] as ClusterType
+ );
+ }
+ if (!isOpen) {
+ resetValues();
+ }
+ }, [isOpen]);
+
+ useEffect(() => {
+ setViewOptions(
+ get(viewDetails, "items", []).reduce((acc: any, item: any) => {
+ return {
+ ...acc,
+ [get(item, "ViewInfo.view_name")]: get(item, "versions").map(
+ (version: any) => get(version, "ViewVersionInfo.version")
+ ),
+ };
+ }, {})
+ );
+
+ const instanceNamesList: string[] = (viewDetails?.items ?? []).flatMap(
+ (item: { versions: any }) =>
+ (item?.versions ?? []).flatMap((version: { instances: any }) =>
+ (version?.instances ?? [])
+ .map((instance: any) => {
+ return get(instance, "ViewInstanceInfo.instance_name");
+ })
+ .filter(Boolean)
+ )
+ );
+
+ setExistingInstanceList(instanceNamesList);
+
+ }, [viewDetails]);
+
+ useEffect(() => {
+ if (!isThisCloneInstance) {
+ setView(get(Object.keys(viewOptions), "[0]", ""));
+ }
+ }, [viewOptions]);
+
+ useEffect(() => {
+ if (!isThisCloneInstance) {
+ setVersion(get(viewOptions, `[${view}][0]`, ""));
+ setActiveKey(Object.keys(clusterType)[0] as ClusterType);
+ }
+ }, [view]);
+
+ useEffect(() => {
+ let defaultConfigs = {};
+ setNonClusterConfigs(getConfigs(true, defaultConfigs));
+ setClusterConfigs(getConfigs(false, defaultConfigs));
+ setConfigs(defaultConfigs);
+ }, [version, view]);
+
+ useEffect(() => {
+ const updatedConfigs = clusterConfigs.map((config) => ({
+ ...config,
+ error: "",
+ }));
+ setClusterConfigs(updatedConfigs);
+ if (activeKey === "local") {
+ setClusterHandle(
+ get(clusterInfo, "items[0].Clusters.cluster_id", "").toString()
+ );
+ } else if (activeKey === "remote") {
+ setClusterHandle(
+ get(remoteClusterInfo, "items[0].ClusterInfo.cluster_id",
"").toString()
+ );
+ } else {
+ setClusterHandle("");
+ }
+ }, [activeKey]);
+
+ const validateInstanceName = (instanceNameValue: string) => {
+ const regex = /^\s*\w*\s*$/;
+ if (instanceNameValue === "") {
+ setValidationError({
+ ...validationError,
+ instanceName: "Field required!",
+ });
+ } else if (!regex.test(instanceNameValue)) {
+ setValidationError({
+ ...validationError,
+ instanceName: "Must not contain special characters!",
+ });
+ } else if (existingInstanceList.includes(instanceNameValue)) {
+ setValidationError({
+ ...validationError,
+ instanceName: "Instance with this name already exists.",
+ });
+ } else {
+ setValidationError({
+ ...validationError,
+ instanceName: "",
+ });
+ }
+ };
+
+ const getConfigs = (condition: boolean, defaultConfigs: any) => {
+ let currentConfigs = get(viewDetails, "items", []).flatMap(
+ (item: ViewDetailsItemType) => {
+ if (get(item, "ViewInfo.view_name") === view) {
+ return get(item, "versions").flatMap((viewVersion: any) => {
+ if (get(viewVersion, "ViewVersionInfo.version") === version) {
+ return get(viewVersion, "ViewVersionInfo.parameters")
+ .map((parameter: ParametersType) => {
+ if (
+ condition
+ ? get(parameter, "clusterConfig") === null
+ : get(parameter, "clusterConfig") !== null
+ ) {
+ const defaultVal = get(parameter, "defaultValue", null);
+ if (defaultVal !== null) {
+ let key = get(parameter, "name");
+ defaultConfigs[key] = defaultVal;
+ }
+ return {
+ label: get(parameter, "label"),
+ value: get(parameter, "name"),
+ placeholder: get(parameter, "placeholder"),
+ defaultValue: get(parameter, "defaultValue"),
+ isRequired: get(parameter, "required"),
+ masked: get(parameter, "masked"),
+ error: "",
+ };
+ }
+ })
+ .filter(Boolean);
+ } else {
+ return [];
+ }
+ });
+ } else {
+ return [];
+ }
+ }
+ );
+ setConfigs(defaultConfigs);
+ return currentConfigs;
+ };
+
+ const resetValues = () => {
+ setView(get(Object.keys(viewOptions), "[0]", ""));
+ setVersion(get(viewOptions, `[${view}][0]`, ""));
+ setInstanceName("");
+ setDisplayName("");
+ setDescription("");
+ setClusterHandle("");
+ setClusterConfigs([]);
+ setNonClusterConfigs([]);
+ setConfigs({});
+ setActiveKey(Object.keys(clusterType)[0] as ClusterType);
+ setIsVisible(true);
+ setValidationError({
+ instanceName: "",
+ displayName: "",
+ description: "",
+ });
+ setIsFormSubmitted(false);
+ };
+
+ const isFormValid = () => {
+ let currentValidationError = cloneDeep(validationError);
+ let hasError = false;
+ for (let key in currentValidationError) {
+ if (
+ ((key === "instanceName" && !instanceName) ||
+ (key === "displayName" && !displayName) ||
+ (key === "description" && !description)) &&
+ currentValidationError[key] === ""
+ ) {
+ currentValidationError[key] = "Field required!";
+ }
+
+ if (currentValidationError[key] !== "") {
+ hasError = true;
+ }
+ }
+ setValidationError(currentValidationError);
+
+ if (nonClusterConfigs.length) {
+ nonClusterConfigs.forEach(
+ (nonClusterConfig: MappingType, idx: number) => {
+ if (nonClusterConfig.isRequired) {
+ if (
+ nonClusterConfig.value in configs &&
+ configs[nonClusterConfig.value] !== ""
+ ) {
+ set(nonClusterConfigs, `[${idx}]["error"]`, "");
+ } else {
+ set(nonClusterConfigs, `[${idx}]["error"]`, "Field required!");
+ hasError = true;
+ }
+ }
+ }
+ );
+ }
+
+ if (clusterConfigs.length && activeKey === "custom") {
+ clusterConfigs.forEach((clusterConfig: MappingType, idx: number) => {
+ if (clusterConfig.isRequired) {
+ if (
+ clusterConfig.value in configs &&
+ configs[clusterConfig.value] !== ""
+ ) {
+ set(clusterConfigs, `[${idx}]["error"]`, "");
+ } else {
+ set(clusterConfigs, `[${idx}]["error"]`, "Field required!");
+ hasError = true;
+ }
+ }
+ });
+ }
+
+ if (!clusterHandle && activeKey !== "custom") {
+ hasError = true;
+ }
+
+ return !hasError;
+ };
+
+ const handleSave = async (event: any) => {
+ event.preventDefault();
+ if (isFormValid()) {
+ const properties = Object.entries(configs).reduce(
+ (acc: { [key: string]: any }, [key, value]) => {
+ acc[key] = value === "" ? null : value;
+ return acc;
+ },
+ {}
+ );
+ const viewData = {
+ ViewInstanceInfo: {
+ cluster_handle: clusterHandle === "" ? null : Number(clusterHandle),
+ cluster_type: clusterType[activeKey],
+ description: description,
+ icon64_path: "",
+ icon_path: "",
+ instance_name: instanceName,
+ label: displayName,
+ properties: properties,
+ visible: isVisible,
+ },
+ };
+ await ViewApi.addView(view, version, instanceName, viewData);
+ toast.success(
+ <div className="toast-message">Created instance {instanceName}</div>
+ );
+ resetValues();
+ onClose(false);
+ successCallback();
+ history.push(
+ `views/${view}/versions/${version}/instances/${instanceName}/edit`
+ );
+ }
+ };
+
+ const isFormUpdated = () => {
+ if (isThisCloneInstance) {
+ return (
+ instanceName !==
+ get(viewInstanceInfoToBeCloned, "instance_name") + "_Copy" ||
+ displayName !== get(viewInstanceInfoToBeCloned, "label") + "_Copy" ||
+ description !== get(viewInstanceInfoToBeCloned, "description") ||
+ isVisible !== get(viewInstanceInfoToBeCloned, "visible") ||
+ clusterHandle !== get(viewInstanceInfoToBeCloned, "cluster_handle") ||
+ JSON.stringify(configs) !==
+ JSON.stringify(get(viewInstanceInfoToBeCloned, "properties"))
+ );
+ }
+ return (
+ view !== Object.keys(viewOptions)[0] ||
+ instanceName ||
+ displayName ||
+ description ||
+ clusterHandle !== get(clusterInfo, "items[0].Clusters.cluster_id",
"").toString()
+ );
+ };
+
+ const handleCancel = () => {
+ if (isFormUpdated()) {
+ setShowCancelWarning(true);
+ } else {
+ onClose(false);
+ }
+ };
+
+ const handleWarningSave = (event: any) => {
+ setShowCancelWarning(false);
+ handleSave(event);
+ };
+
+ const handleWarningDiscard = () => {
+ setShowCancelWarning(false);
+ onClose(false);
+ };
+
+ return (
+ <Modal data-testid="Create-instance"
+ show={isOpen}
+ onHide={handleCancel}
+ size="lg"
+ className="custom-modal-container make-scrollable"
+ >
+ <Modal.Header closeButton>
+ <Modal.Title>
+ {isThisCloneInstance ? (
+ <h3>Clone Instance</h3>
+ ) : (
+ <h3>Create Instance</h3>
+ )}
+ </Modal.Title>
+ </Modal.Header>
+ {loading ? (
+ <Spinner />
+ ) : (
+ <Form onSubmit={handleSave}>
+ <Modal.Body>
+ <Form.Group className="mb-4 d-flex ">
+ <Form.Group className="me-5 w-100">
+ <Form.Label>Select View *</Form.Label>
+ <Form.Select
+ aria-label="Select View"
+ value={view}
+ className="w-100 custom-form-control"
+ onChange={(e) => setView(e.target.value)}
+ disabled={isThisCloneInstance}
+ >
+ {Object.keys(viewOptions).map((viewOption, idx) => (
+ <option value={viewOption} key={idx}>
+ {viewOption}
+ </option>
+ ))}
+ </Form.Select>
+ </Form.Group>
+ <Form.Group className="w-100">
+ <Form.Label>Select Version *</Form.Label>
+ <Form.Select
+ aria-label="Select Version"
+ value={version}
+ className="w-100 custom-form-control"
+ onChange={(e) => setVersion(e.target.value)}
+ disabled={isThisCloneInstance}
+ >
+ {get(viewOptions, view, []).map(
+ (viewVersion: string, idx: number) => (
+ <option value={viewVersion} key={idx}>
+ {viewVersion}
+ </option>
+ )
+ )}
+ </Form.Select>
+ </Form.Group>
+ </Form.Group>
+ <Form.Group className="mb-4">
+ <h5>Details</h5>
+ <Form.Group className="mb-3">
+ <Form.Label>Instance Name *</Form.Label>
+ <Form.Control
+ data-testid="instance-name"
+ type="text"
+ value={instanceName}
+ onChange={(e) => {
+ setInstanceName(e.target.value);
+ validateInstanceName(e.target.value);
+ }}
+ className={
+ get(validationError, "instanceName") && isFormSubmitted
+ ? "border-danger"
+ : ""
+ }
+ />
+ {get(validationError, "instanceName") && isFormSubmitted ? (
+ <div className="text-danger mt-1">
+ <FontAwesomeIcon icon={faCircleXmark} />{" "}
+ {get(validationError, "instanceName")}
+ </div>
+ ) : null}
+ </Form.Group>
+ <Form.Group className="mb-3">
+ <Form.Label>Display Name *</Form.Label>
+ <Form.Control
+ data-testid="display-name"
+ type="text"
+ value={displayName}
+ onChange={(e) => {
+ setDisplayName(e.target.value);
+ if (e.target.value === "") {
+ setValidationError({
+ ...validationError,
+ displayName: "Field required!",
+ });
+ } else {
+ setValidationError({
+ ...validationError,
+ displayName: "",
+ });
+ }
+ }}
+ className={
+ get(validationError, "displayName") && isFormSubmitted
+ ? "border-danger"
+ : ""
+ }
+ />
+ {get(validationError, "displayName") && isFormSubmitted ? (
+ <div className="text-danger mt-1">
+ <FontAwesomeIcon icon={faCircleXmark} />{" "}
+ {get(validationError, "displayName")}
+ </div>
+ ) : null}
+ </Form.Group>
+ <Form.Group className="mb-3">
+ <Form.Label>Description *</Form.Label>
+ <Form.Control
+ data-testid="description"
+ type="text"
+ value={description}
+ onChange={(e) => {
+ setDescription(e.target.value);
+ if (e.target.value === "") {
+ setValidationError({
+ ...validationError,
+ description: "Field required!",
+ });
+ } else {
+ setValidationError({
+ ...validationError,
+ description: "",
+ });
+ }
+ }}
+ className={
+ get(validationError, "description") && isFormSubmitted
+ ? "border-danger"
+ : ""
+ }
+ />
+ {get(validationError, "description") && isFormSubmitted ? (
+ <div className="text-danger mt-1">
+ <FontAwesomeIcon icon={faCircleXmark} />{" "}
+ {get(validationError, "description")}
+ </div>
+ ) : null}
+ </Form.Group>
+ <Form.Group className="mb-3 d-flex">
+ <Form.Check
+ id="viewVisible"
+ type="checkbox"
+ className="custom-checkbox p-0"
+ checked={isVisible}
+ label="Visible"
+ onChange={() => setIsVisible(!isVisible)}
+ />
+ </Form.Group>
+ </Form.Group>
+ {nonClusterConfigs.length ? (
+ <Form.Group className="mb-4">
+ <h5>Settings</h5>
+ {nonClusterConfigs.map((setting: MappingType, idx: number) => (
+ <Form.Group className="mb-3" key={idx}>
+ <Form.Label>
+ {setting.label}
+ {setting.isRequired ? " *" : ""}
+ </Form.Label>
+ <Form.Control
+ type="text"
+ placeholder={setting.placeholder}
+ value={configs[setting.value]}
+ defaultValue={setting.defaultValue}
+ onChange={(e) => {
+ setConfigs({
+ ...configs,
+ [setting.value]: e.target.value,
+ });
+ if (setting.isRequired) {
+ const updatedNonClusterConfigs = [
+ ...nonClusterConfigs,
+ ];
+ if (e.target.value === "") {
+ set(
+ updatedNonClusterConfigs,
+ `[${idx}]["error"]`,
+ "Field required!"
+ );
+ } else {
+ set(
+ updatedNonClusterConfigs,
+ `[${idx}]["error"]`,
+ ""
+ );
+ }
+ setNonClusterConfigs(updatedNonClusterConfigs);
+ }
+ }}
+ className={
+ setting.isRequired &&
+ get(nonClusterConfigs, `[${idx}]["error"]`) &&
+ isFormSubmitted
+ ? "border-danger"
+ : ""
+ }
+ />
+ {get(nonClusterConfigs, `[${idx}]["error"]`) &&
+ isFormSubmitted ? (
+ <div className="text-danger mt-1">
+ <FontAwesomeIcon icon={faCircleXmark} />{" "}
+ {get(nonClusterConfigs, `[${idx}]["error"]`)}
+ </div>
+ ) : null}
+ </Form.Group>
+ ))}
+ </Form.Group>
+ ) : null}
+ <Form.Group className="mb-4">
+ <h5>Cluster Configuration</h5>
+ <Form.Group className="mb-3 d-flex flex-column">
+ <Form.Label>Cluster Type?</Form.Label>
+ <div>
+ <Tab.Container activeKey={activeKey}>
+ <Nav
+ variant="pills"
+ className="mb-2"
+ onSelect={(selectedKey) => {
+ if (selectedKey)
+ setActiveKey(selectedKey as ClusterType);
+ }}
+ >
+ <Nav.Item>
+ <Nav.Link eventKey="local" className="tab-button">
+ LOCAL
+ </Nav.Link>
+ </Nav.Item>
+ <Nav.Item>
+ <Nav.Link eventKey="remote" className="tab-button"
data-testid="/remote-toggle-button">
+ REMOTE
+ </Nav.Link>
+ </Nav.Item>
+ <Nav.Item>
+ {clusterConfigs.length ? (
+ <Nav.Link eventKey="custom" className="tab-button">
+ CUSTOM
+ </Nav.Link>
+ ) : null}
+ </Nav.Item>
+ </Nav>
+ <Form.Group className="mb-3">
+ <Form.Label>Cluster Name *</Form.Label>
+ <Tab.Content>
+ <Tab.Pane eventKey="local">
+ <Form.Select
+ value={clusterHandle}
+ className="w-50 custom-form-control mb-3"
+ onChange={(e) => setClusterHandle(e.target.value)}
+ >
+ {get(clusterInfo, "items", []).map(
+ (info: ClusterInfoItemType, idx: number) => (
+ <option
+ value={get(info, "Clusters.cluster_id")}
+ key={idx}
+ >
+ {get(info, "Clusters.cluster_name")}
+ </option>
+ )
+ )}
+ </Form.Select>
+ </Tab.Pane>
+ <Tab.Pane eventKey="remote">
+ <Form.Select
+ value={clusterHandle}
+ className="w-50 custom-form-control mb-3"
+ onChange={(e) => setClusterHandle(e.target.value)}
+ >
+ {get(remoteClusterInfo, "items", []).map(
+ (
+ info: RemoteClusterInfoItemType,
+ idx: number
+ ) => (
+ <option
+ value={get(info, "ClusterInfo.cluster_id")}
+ key={idx}
+ >
+ {get(info, "ClusterInfo.name")}
+ </option>
+ )
+ )}
+ </Form.Select>
+ </Tab.Pane>
+ <Tab.Pane eventKey="custom">
+ <Form.Select
+ value={clusterHandle}
+ className="w-50 custom-form-control"
+ onChange={() => setClusterHandle("")}
+ disabled
+ ></Form.Select>
+ </Tab.Pane>
+ {!clusterHandle &&
+ activeKey !== "custom" &&
+ isFormSubmitted ? (
+ <div className="text-danger mt-1 mb-3">
+ <FontAwesomeIcon icon={faCircleXmark} />
+ {" Field required!"}
+ </div>
+ ) : null}
+ {clusterConfigs?.length
+ ? clusterConfigs?.map(
+ (clusterConfig: MappingType, idx: number) => (
+ <Form.Group className="mb-3" key={idx}>
+ <Form.Label>
+ {clusterConfig.label}
+ {clusterConfig.isRequired ? " *" : ""}
+ </Form.Label>
+ <Form.Control
+ type="text"
+ disabled={activeKey !== "custom"}
+ placeholder={clusterConfig.placeholder}
+ defaultValue={clusterConfig.defaultValue}
+ value={configs[clusterConfig.value]}
+ onChange={(e) => {
+ setConfigs({
+ ...configs,
+ [clusterConfig.value]: e.target.value,
+ });
+ if (clusterConfig.isRequired) {
+ if (e.target.value === "") {
+ set(
+ clusterConfigs,
+ `[${idx}]["error"]`,
+ "Field required!"
+ );
+ } else {
+ set(
+ clusterConfigs,
+ `[${idx}]["error"]`,
+ ""
+ );
+ }
+ }
+ }}
+ className={
+ clusterConfig.isRequired &&
+ get(
+ clusterConfigs,
+ `[${idx}]["error"]`
+ ) &&
+ isFormSubmitted
+ ? "border-danger"
+ : ""
+ }
+ />
+ {get(clusterConfigs, `[${idx}]["error"]`) &&
+ isFormSubmitted ? (
+ <div className="text-danger mt-1">
+ <FontAwesomeIcon icon={faCircleXmark}
/>{" "}
+ {get(clusterConfigs,
`[${idx}]["error"]`)}
+ </div>
+ ) : null}
+ </Form.Group>
+ )
+ )
+ : null}
+ </Tab.Content>
+ </Form.Group>
+ </Tab.Container>
+ </div>
+ </Form.Group>
+ </Form.Group>
+ </Modal.Body>
+ <Modal.Footer>
+ <DefaultButton onClick={handleCancel}>Cancel</DefaultButton>
+ <WarningModal
+ isOpen={showCancelWarning}
+ onClose={() => setShowCancelWarning(false)}
+ handleWarningDiscard={handleWarningDiscard}
+ handleWarningSave={handleWarningSave}
+ />
+ <Button
+ className="custom-btn"
+ type="submit"
+ variant="success"
+ onClick={() => setIsFormSubmitted(true)}
+ >
+ Save
+ </Button>
+ </Modal.Footer>
+ </Form>
+ )}
+ </Modal>
+ );
+}
+
diff --git
a/ambari-admin/src/main/resources/ui/ambari-admin/src/screens/Views/types.ts
b/ambari-admin/src/main/resources/ui/ambari-admin/src/screens/Views/types.ts
new file mode 100644
index 0000000000..4f5a5afe29
--- /dev/null
+++ b/ambari-admin/src/main/resources/ui/ambari-admin/src/screens/Views/types.ts
@@ -0,0 +1,226 @@
+/**
+ * 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 { ControlType } from "./enums";
+
+interface ClusterInfoItemType {
+ href: string;
+ Clusters: {
+ cluster_id: number;
+ cluster_name: string;
+ };
+}
+
+interface ClusterInfoType {
+ href: string;
+ items: ClusterInfoItemType[];
+}
+
+interface RemoteClusterInfoItemType {
+ href: string;
+ ClusterInfo: {
+ cluster_id: number;
+ name: string;
+ services: string[];
+ };
+}
+
+interface RemoteClusterInfoType {
+ href: string;
+ items: RemoteClusterInfoItemType[];
+}
+
+interface MappingType {
+ label: string;
+ value: string;
+ placeholder: string;
+ defaultValue: string;
+ isRequired: boolean;
+ masked: boolean;
+ error: string;
+}
+
+interface ConfigType {
+ [key: string]: string;
+}
+
+interface ParametersType {
+ name: string;
+ description: string;
+ label: string;
+ placeholder: null;
+ defaultValue: null;
+ clusterConfig: string;
+ required: boolean;
+ masked: boolean;
+}
+
+export type ViewInputs = {
+ [key: string]: {
+ isEditable: boolean;
+ value: string;
+ label: string;
+ type: string;
+ originalValue: string;
+ required?: boolean;
+ hasError?: boolean;
+ clickCallback?: () => void;
+ };
+};
+
+export type ParameterFields = {
+ name: string;
+ description: string;
+ label: string;
+ placeholder: string | null;
+ defaultValue: string | null;
+ clusterConfig: string;
+ required: boolean;
+ masked: boolean;
+};
+
+export type User = {
+ href: string;
+ Users: {
+ user_name: string;
+ };
+};
+export type Group = {
+ href: string;
+ Groups: {
+ group_name: string;
+ };
+};
+export type Options = {
+ value: string;
+ label: string;
+};
+
+export type PrivilegesType = {
+ href: string;
+ PrivilegeInfo: {
+ instance_name: string;
+ permission_label: string;
+ permission_name: string;
+ principal_name: string;
+ principal_type: string;
+ privilege_id: number;
+ version: string;
+ view_name: string;
+ };
+};
+
+export type setPermissionsType = {
+ PrivilegeInfo: {
+ permission_name: string;
+ principal_name: string;
+ principal_type: string;
+ };
+};
+
+export type setDetailsType = {
+ ViewInstanceInfo: {
+ visible: string;
+ label: string;
+ description: string;
+ };
+};
+
+interface ViewDetailsItemType {
+ href: string;
+ ViewInfo: {
+ view_name: string;
+ };
+ versions: {
+ href: string;
+ ViewVersionInfo: {
+ archive: string;
+ build_number: string;
+ cluster_configurable: boolean;
+ description: string;
+ label: string;
+ masker_class: string | null;
+ max_ambari_version: string | null;
+ min_ambari_version: string;
+ parameters: ParametersType[];
+ status: string;
+ status_detail: string;
+ system: boolean;
+ version: string;
+ view_name: string;
+ };
+ instances: {}[];
+ permissions: {}[];
+ }[];
+}
+
+interface ViewDetailsType {
+ href: string;
+ items: ViewDetailsItemType[];
+}
+
+type ClusterType = "local" | "remote" | "custom";
+
+export type {
+ RemoteClusterInfoType,
+ RemoteClusterInfoItemType,
+ ClusterInfoType,
+ MappingType,
+ ClusterType,
+ ViewDetailsType,
+ ParametersType,
+ ConfigType,
+ ViewDetailsItemType,
+ ClusterInfoItemType,
+};
+
+interface FieldType {
+ label: string;
+ type: ControlType;
+ hasError: boolean;
+ isEditable: boolean;
+ id: string;
+ value: string | boolean;
+ originalValue: string;
+ apiResponseKey: string[];
+ required?: boolean;
+ validationRegEx?: RegExp;
+ errorMessage?: string;
+ isdeletable?: boolean;
+ deleteCallBack?: () => void;
+ href?: string;
+ placeholder?: string[];
+ [key:string]:string|unknown;
+}
+
+interface SectionType {
+ isEditable: boolean;
+ isEditing: boolean;
+ apiResponsible: any;
+ fields: FieldType[];
+}
+
+interface ViewSectionsType {
+ details: SectionType;
+ [key:string]:SectionType;
+}
+
+interface ValidationErrorType {
+ [key: string]: string;
+}
+
+export type{ViewSectionsType, FieldType, SectionType, ValidationErrorType }
diff --git
a/ambari-admin/src/main/resources/ui/ambari-admin/src/screens/Views/viewConstants.ts
b/ambari-admin/src/main/resources/ui/ambari-admin/src/screens/Views/viewConstants.ts
new file mode 100644
index 0000000000..5cdb5ab3be
--- /dev/null
+++
b/ambari-admin/src/main/resources/ui/ambari-admin/src/screens/Views/viewConstants.ts
@@ -0,0 +1,24 @@
+/**
+ * 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.
+ */
+const clusterType = {
+ "local": "LOCAL_AMBARI",
+ "remote": "REMOTE_AMBARI",
+ "custom": "NONE",
+};
+
+export { clusterType };
\ No newline at end of file
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]