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 61aa58a6db AMBARI-26360 : Ambari Web React: Common components phase-1 
(#4030)
61aa58a6db is described below

commit 61aa58a6dba2b74ecb1c24422a9d1ffe508edecb
Author: Himanshu Maurya <[email protected]>
AuthorDate: Tue Aug 12 10:59:47 2025 +0530

    AMBARI-26360 : Ambari Web React: Common components phase-1 (#4030)
---
 ambari-web/latest/package.json                     |   3 +-
 ambari-web/latest/src/Utils/Utility.ts             |  27 +-
 ambari-web/latest/src/Utils/timezone.ts            | 422 +++++++++++++++++++++
 .../latest/src/components/ConfirmationModal.tsx    |   4 +-
 ambari-web/latest/src/components/Modal.tsx         |  32 +-
 .../latest/src/components/NestedDropdown.tsx       | 101 +++++
 .../latest/src/components/SelectTimeRangeModal.tsx | 196 ++++++++++
 .../src/{main.tsx => components/Tooltip.tsx}       |  41 +-
 ambari-web/latest/src/components/TooltipInput.tsx  |  62 +++
 ambari-web/latest/src/main.tsx                     |   1 -
 10 files changed, 848 insertions(+), 41 deletions(-)

diff --git a/ambari-web/latest/package.json b/ambari-web/latest/package.json
index 39fcbca399..f6fbcbae89 100755
--- a/ambari-web/latest/package.json
+++ b/ambari-web/latest/package.json
@@ -14,10 +14,11 @@
     "@fortawesome/react-fontawesome": "^0.2.2",
     "@types/lodash": "^4.17.16",
     "bootstrap": "^5.3.6",
+    "classnames": "^2.5.1",
+    "dayjs": "^1.11.13",
     "i18next": "^25.1.2",
     "i18next-browser-languagedetector": "^8.1.0",
     "lodash": "^4.17.21",
-    "moment-timezone": "^0.5.48",
     "react": "^19.0.0",
     "react-bootstrap": "^2.10.10",
     "react-bootstrap-icons": "^1.11.6",
diff --git a/ambari-web/latest/src/Utils/Utility.ts 
b/ambari-web/latest/src/Utils/Utility.ts
index dd493db95d..ad6ea17a22 100644
--- a/ambari-web/latest/src/Utils/Utility.ts
+++ b/ambari-web/latest/src/Utils/Utility.ts
@@ -16,8 +16,9 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import moment from "moment-timezone";
+
 import _,{  startCase, get, has } from "lodash";
+import { detectUserTimezone, parseTimezones } from "./timezone";
 
 export const padNumber = (num: number) => (num < 10 ? `0${num}` : num);
 const components: any = {
@@ -128,23 +129,9 @@ const componentsMap: any = {
 };
 
 export const getUserTimezone = () => {
-  const timezone = moment.tz.guess();
-  const offset = moment.tz(timezone).format("Z");
-  const abbr = moment.tz(timezone).format("z");
-  const cities = moment.tz
-    .names()
-    .filter((name) => moment.tz(name).format("Z") === offset);
- 
-  const formattedContinent = cities.map((city) =>
-    city.split("/")[0].replace("_", " ")
-  )[0];
-  const formattedCities = cities.map((city) =>
-    city.split("/")[1].replace("_", " ")
-  );
- 
-  return `(UTC${offset} ${abbr}) ${formattedContinent} / 
${formattedCities.join(
-    ", "
-  )}`;
+  const timeZones = parseTimezones();
+  const userTimezone = detectUserTimezone();
+  return timeZones.find((tz) => tz.value === userTimezone)?.label || "";
 };
  
 export const formatDate = (date: Date) => {
@@ -420,7 +407,7 @@ const getHighestPriorityStatus = (statuses: any[]) => {
     return statusOrder.indexOf(status) < 
statusOrder.indexOf(highestPriorityStatus)
         ? status
         : highestPriorityStatus;
-  }, statusOrder?.at(-1));
+  }, statusOrder[statusOrder.length - 1]);
 };
 
 export function sortPropertyLight(arr: any[], path: string, desc: boolean = 
false) {
@@ -467,4 +454,4 @@ export function 
isShownOnAddServiceAssignMasterPage(component:string,isMaster:bo
     isVisible =  isVisible && component !== 'SECONDARY_NAMENODE';
   }
   return isVisible;
-}
\ No newline at end of file
+}
diff --git a/ambari-web/latest/src/Utils/timezone.ts 
b/ambari-web/latest/src/Utils/timezone.ts
new file mode 100644
index 0000000000..853990aba1
--- /dev/null
+++ b/ambari-web/latest/src/Utils/timezone.ts
@@ -0,0 +1,422 @@
+/**
+ * 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 dayjs from "dayjs";
+import utc from "dayjs/plugin/utc";
+import timezone from "dayjs/plugin/timezone";
+
+dayjs.extend(utc);
+dayjs.extend(timezone);
+
+interface FormattedTimezone {
+  groupByKey: string;
+  utcOffset: number;
+  formattedOffset: string;
+  value: string;
+  region: string;
+  city: string;
+}
+
+interface ShownTimezone {
+  utcOffset: number;
+  label: string;
+  value: string;
+  zones: FormattedTimezone[];
+}
+
+interface TimezoneInfo {
+  name: string;
+  abbreviation: string;
+}
+
+const TIMEZONES: TimezoneInfo[] = [
+  // Africa
+  { name: "Africa/Abidjan", abbreviation: "GMT" },
+  { name: "Africa/Accra", abbreviation: "GMT" },
+  { name: "Africa/Algiers", abbreviation: "CET" },
+  { name: "Africa/Bissau", abbreviation: "GMT" },
+  { name: "Africa/Cairo", abbreviation: "EET" },
+  { name: "Africa/Casablanca", abbreviation: "WET" },
+  { name: "Africa/Ceuta", abbreviation: "CET" },
+  { name: "Africa/Johannesburg", abbreviation: "SAST" },
+  { name: "Africa/Lagos", abbreviation: "WAT" },
+  { name: "Africa/Maputo", abbreviation: "CAT" },
+  { name: "Africa/Monrovia", abbreviation: "GMT" },
+  { name: "Africa/Nairobi", abbreviation: "EAT" },
+  { name: "Africa/Ndjamena", abbreviation: "WAT" },
+  { name: "Africa/Tripoli", abbreviation: "EET" },
+  { name: "Africa/Tunis", abbreviation: "CET" },
+  { name: "Africa/Windhoek", abbreviation: "CAT" },
+
+  // America
+  { name: "America/Adak", abbreviation: "HST" },
+  { name: "America/Anchorage", abbreviation: "AKST" },
+  { name: "America/Araguaina", abbreviation: "BRT" },
+  { name: "America/Argentina/Buenos_Aires", abbreviation: "ART" },
+  { name: "America/Asuncion", abbreviation: "PYT" },
+  { name: "America/Bahia", abbreviation: "BRT" },
+  { name: "America/Barbados", abbreviation: "AST" },
+  { name: "America/Belem", abbreviation: "BRT" },
+  { name: "America/Belize", abbreviation: "CST" },
+  { name: "America/Bogota", abbreviation: "COT" },
+  { name: "America/Cancun", abbreviation: "EST" },
+  { name: "America/Caracas", abbreviation: "VET" },
+  { name: "America/Chicago", abbreviation: "CST" },
+  { name: "America/Chihuahua", abbreviation: "MST" },
+  { name: "America/Costa_Rica", abbreviation: "CST" },
+  { name: "America/Dawson_Creek", abbreviation: "MST" },
+  { name: "America/Denver", abbreviation: "MST" },
+  { name: "America/Edmonton", abbreviation: "MST" },
+  { name: "America/El_Salvador", abbreviation: "CST" },
+  { name: "America/Fortaleza", abbreviation: "BRT" },
+  { name: "America/Godthab", abbreviation: "WGT" },
+  { name: "America/Guatemala", abbreviation: "CST" },
+  { name: "America/Guayaquil", abbreviation: "ECT" },
+  { name: "America/Halifax", abbreviation: "AST" },
+  { name: "America/Havana", abbreviation: "CST" },
+  { name: "America/Indianapolis", abbreviation: "EST" },
+  { name: "America/Juneau", abbreviation: "AKST" },
+  { name: "America/La_Paz", abbreviation: "BOT" },
+  { name: "America/Lima", abbreviation: "PET" },
+  { name: "America/Los_Angeles", abbreviation: "PST" },
+  { name: "America/Managua", abbreviation: "CST" },
+  { name: "America/Manaus", abbreviation: "AMT" },
+  { name: "America/Matamoros", abbreviation: "CST" },
+  { name: "America/Mazatlan", abbreviation: "MST" },
+  { name: "America/Mexico_City", abbreviation: "CST" },
+  { name: "America/Monterrey", abbreviation: "CST" },
+  { name: "America/Montevideo", abbreviation: "UYT" },
+  { name: "America/Nassau", abbreviation: "EST" },
+  { name: "America/New_York", abbreviation: "EST" },
+  { name: "America/Noronha", abbreviation: "FNT" },
+  { name: "America/Panama", abbreviation: "EST" },
+  { name: "America/Phoenix", abbreviation: "MST" },
+  { name: "America/Port_of_Spain", abbreviation: "AST" },
+  { name: "America/Port-au-Prince", abbreviation: "EST" },
+  { name: "America/Puerto_Rico", abbreviation: "AST" },
+  { name: "America/Regina", abbreviation: "CST" },
+  { name: "America/Rio_Branco", abbreviation: "ACT" },
+  { name: "America/Santiago", abbreviation: "CLT" },
+  { name: "America/Santo_Domingo", abbreviation: "AST" },
+  { name: "America/Sao_Paulo", abbreviation: "BRT" },
+  { name: "America/St_Johns", abbreviation: "NST" },
+  { name: "America/Tegucigalpa", abbreviation: "CST" },
+  { name: "America/Tijuana", abbreviation: "PST" },
+  { name: "America/Toronto", abbreviation: "EST" },
+  { name: "America/Vancouver", abbreviation: "PST" },
+  { name: "America/Winnipeg", abbreviation: "CST" },
+
+  // Asia
+  { name: "Asia/Amman", abbreviation: "EET" },
+  { name: "Asia/Baghdad", abbreviation: "AST" },
+  { name: "Asia/Bahrain", abbreviation: "AST" },
+  { name: "Asia/Bangkok", abbreviation: "ICT" },
+  { name: "Asia/Beirut", abbreviation: "EET" },
+  { name: "Asia/Calcutta", abbreviation: "IST" },
+  { name: "Asia/Colombo", abbreviation: "IST" },
+  { name: "Asia/Damascus", abbreviation: "EET" },
+  { name: "Asia/Dhaka", abbreviation: "BST" },
+  { name: "Asia/Dubai", abbreviation: "GST" },
+  { name: "Asia/Hong_Kong", abbreviation: "HKT" },
+  { name: "Asia/Irkutsk", abbreviation: "IRKT" },
+  { name: "Asia/Jakarta", abbreviation: "WIB" },
+  { name: "Asia/Jerusalem", abbreviation: "IST" },
+  { name: "Asia/Kabul", abbreviation: "AFT" },
+  { name: "Asia/Karachi", abbreviation: "PKT" },
+  { name: "Asia/Kathmandu", abbreviation: "NPT" },
+  { name: "Asia/Kolkata", abbreviation: "IST" },
+  { name: "Asia/Krasnoyarsk", abbreviation: "KRAT" },
+  { name: "Asia/Kuala_Lumpur", abbreviation: "MYT" },
+  { name: "Asia/Kuwait", abbreviation: "AST" },
+  { name: "Asia/Magadan", abbreviation: "MAGT" },
+  { name: "Asia/Manila", abbreviation: "PHT" },
+  { name: "Asia/Muscat", abbreviation: "GST" },
+  { name: "Asia/Nicosia", abbreviation: "EET" },
+  { name: "Asia/Qatar", abbreviation: "AST" },
+  { name: "Asia/Rangoon", abbreviation: "MMT" },
+  { name: "Asia/Riyadh", abbreviation: "AST" },
+  { name: "Asia/Seoul", abbreviation: "KST" },
+  { name: "Asia/Shanghai", abbreviation: "CST" },
+  { name: "Asia/Singapore", abbreviation: "SGT" },
+  { name: "Asia/Taipei", abbreviation: "CST" },
+  { name: "Asia/Tehran", abbreviation: "IRST" },
+  { name: "Asia/Tokyo", abbreviation: "JST" },
+  { name: "Asia/Vladivostok", abbreviation: "VLAT" },
+  { name: "Asia/Yakutsk", abbreviation: "YAKT" },
+  { name: "Asia/Yekaterinburg", abbreviation: "YEKT" },
+  { name: "Asia/Yerevan", abbreviation: "AMT" },
+
+  // Atlantic
+  { name: "Atlantic/Azores", abbreviation: "AZOT" },
+  { name: "Atlantic/Bermuda", abbreviation: "AST" },
+  { name: "Atlantic/Canary", abbreviation: "WET" },
+  { name: "Atlantic/Cape_Verde", abbreviation: "CVT" },
+  { name: "Atlantic/Reykjavik", abbreviation: "GMT" },
+
+  // Australia
+  { name: "Australia/Adelaide", abbreviation: "ACST" },
+  { name: "Australia/Brisbane", abbreviation: "AEST" },
+  { name: "Australia/Darwin", abbreviation: "ACST" },
+  { name: "Australia/Hobart", abbreviation: "AEST" },
+  { name: "Australia/Melbourne", abbreviation: "AEST" },
+  { name: "Australia/Perth", abbreviation: "AWST" },
+  { name: "Australia/Sydney", abbreviation: "AEST" },
+
+  // Europe
+  { name: "Europe/Amsterdam", abbreviation: "CET" },
+  { name: "Europe/Athens", abbreviation: "EET" },
+  { name: "Europe/Belgrade", abbreviation: "CET" },
+  { name: "Europe/Berlin", abbreviation: "CET" },
+  { name: "Europe/Brussels", abbreviation: "CET" },
+  { name: "Europe/Bucharest", abbreviation: "EET" },
+  { name: "Europe/Budapest", abbreviation: "CET" },
+  { name: "Europe/Copenhagen", abbreviation: "CET" },
+  { name: "Europe/Dublin", abbreviation: "GMT" },
+  { name: "Europe/Helsinki", abbreviation: "EET" },
+  { name: "Europe/Istanbul", abbreviation: "TRT" },
+  { name: "Europe/Kaliningrad", abbreviation: "EET" },
+  { name: "Europe/Kiev", abbreviation: "EET" },
+  { name: "Europe/Lisbon", abbreviation: "WET" },
+  { name: "Europe/London", abbreviation: "GMT" },
+  { name: "Europe/Madrid", abbreviation: "CET" },
+  { name: "Europe/Malta", abbreviation: "CET" },
+  { name: "Europe/Minsk", abbreviation: "MSK" },
+  { name: "Europe/Moscow", abbreviation: "MSK" },
+  { name: "Europe/Paris", abbreviation: "CET" },
+  { name: "Europe/Prague", abbreviation: "CET" },
+  { name: "Europe/Riga", abbreviation: "EET" },
+  { name: "Europe/Rome", abbreviation: "CET" },
+  { name: "Europe/Sofia", abbreviation: "EET" },
+  { name: "Europe/Stockholm", abbreviation: "CET" },
+  { name: "Europe/Tallinn", abbreviation: "EET" },
+  { name: "Europe/Vienna", abbreviation: "CET" },
+  { name: "Europe/Vilnius", abbreviation: "EET" },
+  { name: "Europe/Warsaw", abbreviation: "CET" },
+  { name: "Europe/Zurich", abbreviation: "CET" },
+
+  // Pacific
+  { name: "Pacific/Auckland", abbreviation: "NZST" },
+  { name: "Pacific/Fiji", abbreviation: "FJT" },
+  { name: "Pacific/Guam", abbreviation: "ChST" },
+  { name: "Pacific/Honolulu", abbreviation: "HST" },
+  { name: "Pacific/Midway", abbreviation: "SST" },
+  { name: "Pacific/Noumea", abbreviation: "NCT" },
+  { name: "Pacific/Pago_Pago", abbreviation: "SST" },
+  { name: "Pacific/Port_Moresby", abbreviation: "PGT" },
+  { name: "Pacific/Tongatapu", abbreviation: "TOT" },
+];
+
+export const timezoneNames: string[] = TIMEZONES.map((tz) => tz.name);
+
+export const timezoneAbbreviations: Record<string, string> = TIMEZONES.reduce(
+  (acc, tz) => {
+    acc[tz.name] = tz.abbreviation;
+    return acc;
+  },
+  {} as Record<string, string>
+);
+
+export const timezoneData: TimezoneInfo[] = TIMEZONES;
+
+export const getTimezoneAbbreviation = (timezoneName: string): string => {
+  const timezone = TIMEZONES.find((tz) => tz.name === timezoneName);
+  return timezone?.abbreviation || "UTC";
+};
+
+export const getTimezoneByAbbreviation = (
+  abbreviation: string
+): string | undefined => {
+  const timezone = TIMEZONES.find((tz) => tz.abbreviation === abbreviation);
+  return timezone?.name;
+};
+
+export const getTimezonesByRegion = (region: string): TimezoneInfo[] => {
+  return TIMEZONES.filter((tz) => tz.name.startsWith(region + "/"));
+};
+
+export const getAllTimezoneNames = (): string[] => {
+  let timezones = timezoneNames;
+  // Try to use Intl.supportedValuesOf if available in the browser
+  try {
+    // @ts-ignore - TypeScript might not recognize this newer API
+    if (typeof Intl.supportedValuesOf === "function") {
+      // @ts-ignore
+      const supportedTimezones = Intl.supportedValuesOf("timeZone");
+      if (Array.isArray(supportedTimezones) && supportedTimezones.length > 0) {
+        // Use the browser's supported timezones if available
+        // @ts-ignore
+        timezones = supportedTimezones;
+      }
+    }
+  } catch (e) {
+    console.warn(
+      "Intl.supportedValuesOf not available, using fallback timezone list"
+    );
+  }
+  return timezones.filter((timeZoneName: string) => {
+    return (
+      timeZoneName.indexOf("Etc/") !== 0 &&
+      timeZoneName !== timeZoneName.toUpperCase()
+    );
+  });
+};
+
+const groupPropertyValues = (collection: any, key: string) => {
+  const group: { [key: string]: any[] } = {};
+  collection.forEach((item: any) => {
+    const value = item[key];
+    if (!group[value]) {
+      group[value] = [item];
+    } else {
+      group[value].push(item);
+    }
+  });
+  return group;
+};
+
+export const groupTimezones = (zones: FormattedTimezone[]): ShownTimezone[] => 
{
+  const groupedByOffset = groupPropertyValues(zones, "groupByKey");
+  const newZones: ShownTimezone[] = [];
+
+  Object.keys(groupedByOffset).forEach((offset) => {
+    const groupedByRegion = groupPropertyValues(
+      groupedByOffset[offset],
+      "region"
+    );
+
+    Object.keys(groupedByRegion).forEach((region) => {
+      const cities = groupedByRegion[region]
+        .map((zone) => zone.city)
+        .filter((city) => city !== "" && city !== city.toUpperCase())
+        .filter((city, index, self) => self.indexOf(city) === index) // unique 
cities
+        .join(", ");
+
+      const firstZone = groupedByRegion[region][0];
+      const formattedOffset = firstZone.formattedOffset;
+      const utcOffset = firstZone.utcOffset;
+      const value = `${firstZone.groupByKey}|${region}`;
+      const abbr = getTimezoneAbbreviation(firstZone.value);
+
+      newZones.push({
+        utcOffset: utcOffset,
+        label: `(UTC${formattedOffset} ${abbr}) ${region}${
+          cities ? " / " + cities : ""
+        }`,
+        value: value,
+        zones: groupedByRegion[region],
+      });
+    });
+  });
+
+  return newZones.sort((a, b) => a.utcOffset - b.utcOffset);
+};
+
+export const parseTimezones = (): ShownTimezone[] => {
+  const currentYear = new Date().getFullYear();
+  const jan = new Date(currentYear, 0, 1);
+  const jul = new Date(currentYear, 6, 1);
+
+  const zones = getAllTimezoneNames()
+    .map((timeZoneName) => {
+      const zone = dayjs().tz(timeZoneName);
+
+      if (!zone.isValid()) {
+        return null;
+      }
+
+      const offset = zone.format("Z");
+      const regionCity = timeZoneName.split("/");
+      const region = regionCity[0];
+      const city = regionCity.length === 2 ? regionCity[1] : "";
+
+      const janOffset = dayjs(jan).tz(timeZoneName).utcOffset();
+      const julOffset = dayjs(jul).tz(timeZoneName).utcOffset();
+
+      return {
+        groupByKey: `${janOffset}${julOffset}`,
+        utcOffset: zone.utcOffset(),
+        formattedOffset: offset,
+        value: timeZoneName,
+        region: region,
+        city: city.replace(/_/g, " "),
+      };
+    })
+    .filter((zone): zone is FormattedTimezone => zone !== null)
+    .sort((zoneA, zoneB) => {
+      if (zoneA.utcOffset === zoneB.utcOffset) {
+        if (zoneA.value === zoneB.value) {
+          return 0;
+        }
+        return zoneA.value < zoneB.value ? -1 : 1;
+      } else {
+        return zoneA.utcOffset < zoneB.utcOffset ? -1 : 1;
+      }
+    });
+
+  return groupTimezones(zones);
+};
+
+export const getTimezones = (): ShownTimezone[] => {
+  return parseTimezones();
+};
+
+export const getTimezonesMappedByValue = (): Record<string, ShownTimezone> => {
+  const ret: Record<string, ShownTimezone> = {};
+  parseTimezones().forEach((tz) => {
+    ret[tz.value] = tz;
+  });
+  return ret;
+};
+
+export const detectUserTimezone = (region?: string): string => {
+  region = (region || "").toLowerCase();
+  const currentYear = new Date().getFullYear();
+  const jan = new Date(currentYear, 0, 1);
+  const jul = new Date(currentYear, 6, 1);
+  const janOffset = -jan.getTimezoneOffset();
+  const julOffset = -jul.getTimezoneOffset();
+
+  const validZones: string[] = [];
+
+  const timeZones = parseTimezones();
+  for (let i = 0; i < timeZones.length; i++) {
+    const zones = timeZones[i].zones;
+    for (let j = 0; j < zones.length; j++) {
+      const tzJanOffset = dayjs(jan).tz(zones[j].value).utcOffset();
+      const tzJulOffset = dayjs(jul).tz(zones[j].value).utcOffset();
+
+      if (tzJanOffset === janOffset && tzJulOffset === julOffset) {
+        validZones.push(timeZones[i].value);
+        break;
+      }
+    }
+  }
+
+  if (validZones.length) {
+    if (region) {
+      for (let i = 0; i < validZones.length; i++) {
+        if (validZones[i].toLowerCase().indexOf(region) !== -1) {
+          return validZones[i];
+        }
+      }
+      return validZones[0];
+    }
+    return validZones[0];
+  }
+  return "";
+};
diff --git a/ambari-web/latest/src/components/ConfirmationModal.tsx 
b/ambari-web/latest/src/components/ConfirmationModal.tsx
index 811c54aecb..a776c0cea2 100644
--- a/ambari-web/latest/src/components/ConfirmationModal.tsx
+++ b/ambari-web/latest/src/components/ConfirmationModal.tsx
@@ -26,6 +26,7 @@ type ConfirmationModalProps = {
   successCallback: () => void;
   buttonVariant?: string;
   cancellable?: boolean;
+  okButtonText?: string;
 };
 
 export default function ConfirmationModal({
@@ -36,6 +37,7 @@ export default function ConfirmationModal({
   successCallback,
   buttonVariant = "success",
   cancellable = true,
+  okButtonText = "OK",
 }: ConfirmationModalProps) {
   return (
     <Modal
@@ -68,7 +70,7 @@ export default function ConfirmationModal({
           size="sm"
           data-testid="confirm-ok-btn"
         >
-          OK
+          {okButtonText}
         </Button>
       </Modal.Footer>
     </Modal>
diff --git a/ambari-web/latest/src/components/Modal.tsx 
b/ambari-web/latest/src/components/Modal.tsx
index 5eb54d0883..8acbcb0749 100644
--- a/ambari-web/latest/src/components/Modal.tsx
+++ b/ambari-web/latest/src/components/Modal.tsx
@@ -18,6 +18,7 @@
 import { Button, Modal as ReactModal } from "react-bootstrap";
 import DefaultButton from "./DefaultButton";
 import { ReactNode } from "react";
+import classNames from "classnames";
 
 export type ModalProps = {
   isOpen: boolean;
@@ -37,6 +38,7 @@ export type ModalProps = {
     cancelableViaSuccessBtn?: boolean;
     okButtonVariant?: string;
     okButtonDisabled?: boolean;
+    modalBodyClassName?: string;
     extraButtons?: {
       text: string;
       onClick: () => void;
@@ -67,6 +69,7 @@ export default function Modal({
     okButtonVariant = "success",
     cancelableViaSuccessBtn = true,
     okButtonDisabled = false,
+    modalBodyClassName = "",
     extraButtons = [],
   } = options;
 
@@ -78,10 +81,13 @@ export default function Modal({
     <ReactModal
       show={isOpen}
       onHide={onClose}
-      className={
-        `custom-modal-container make-scrollable custom-scrollbar ${className} 
` +
+      className={classNames(
+        "custom-modal-container",
+        "make-scrollable",
+        "custom-scrollbar",
+        className,
         modalSize
-      }
+      )}
       data-testid="confirmation-modal"
     >
       <ReactModal.Header closeButton={cancelableViaIcon}>
@@ -89,7 +95,7 @@ export default function Modal({
           <h2>{modalTitle}</h2>
         </ReactModal.Title>
       </ReactModal.Header>
-      <ReactModal.Body>
+      <ReactModal.Body className={classNames(modalBodyClassName)}>
         <div className="pre-wrap">{modalBody}</div>
       </ReactModal.Body>
       {!shouldShowFooter ? null : (
@@ -107,7 +113,13 @@ export default function Modal({
           {sortedExtraButtons.map((button: any) => (
             <Button
               key={button.text}
-              className="ps-3 pe-3 text-white custom-btn"
+              className={classNames(
+                "ps-3",
+                "pe-3",
+                "text-white",
+                "custom-btn",
+                button.className
+              )}
               variant={button.variant}
               onClick={button.onClick}
               size={buttonSize}
@@ -118,9 +130,13 @@ export default function Modal({
           ))}
           {cancelableViaSuccessBtn ? (
             <Button
-              className={`ps-3 pe-3 text-white custom-btn ${
-                okButtonDisabled ? "disabled-btn" : ""
-              }`}
+              className={classNames(
+                "ps-3",
+                "pe-3",
+                "text-white",
+                "custom-btn",
+                { "disabled-btn": okButtonDisabled }
+              )}
               variant={okButtonVariant}
               onClick={successCallback}
               size={buttonSize}
diff --git a/ambari-web/latest/src/components/NestedDropdown.tsx 
b/ambari-web/latest/src/components/NestedDropdown.tsx
new file mode 100644
index 0000000000..f8c6ee54d3
--- /dev/null
+++ b/ambari-web/latest/src/components/NestedDropdown.tsx
@@ -0,0 +1,101 @@
+/**
+ * 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 { Dropdown } from "react-bootstrap";
+import React, { memo, ReactNode } from "react";
+import { get } from "lodash";
+import { getIcon } from "./icon";
+
+type DropDirections = "up" | "down" | "start" | "end";
+
+type Menu = {
+  label: ReactNode;
+  submenu?: Menu[];
+  [key: string]: any;
+};
+
+type NestedDropdownProps = {
+  menu: Menu;
+  dropDirection?: DropDirections;
+};
+
+type SubmenuProps = {
+  items?: Menu[];
+  dropDirection?: DropDirections;
+};
+
+const Submenu = memo(({ items, dropDirection }: SubmenuProps) => {
+  return (
+    <Dropdown.Menu>
+      {items &&
+        items.map((item: any, index: number) => {
+          return (
+            <React.Fragment key={`menu-item-${item.label?.toString() || 
index}`}>
+              {item.submenu && item.submenu.length && !get(item, "isDisabled", 
false) ? (
+                <Dropdown drop={dropDirection}>
+                  <Dropdown.Toggle className="custom-text-toggle">
+                    {get(item, "icon", false)
+                      ? getIcon(item.icon, get(item, "iconClass", ""))
+                      : null}
+                    <span>{item.label}</span>
+                  </Dropdown.Toggle>
+                  <Submenu items={item.submenu} dropDirection={dropDirection} 
/>
+                </Dropdown>
+              ) : (
+                get(item, "isVisible", true) && (
+                  <Dropdown.Item
+                    className={
+                      get(item, "isDisabled", false) ? "disabled-btn" : ""
+                    }
+                    onClick={() => {
+                      if (!get(item, "isDisabled", false)) {
+                        item.onClick();
+                      }
+                    }}
+                  >
+                    {get(item, "icon", false)
+                      ? getIcon(item.icon, get(item, "iconClass", ""))
+                      : null}
+                    {item.label}
+                  </Dropdown.Item>
+                )
+              )}
+            </React.Fragment>
+          );
+        })}
+    </Dropdown.Menu>
+  );
+});
+
+function NestedDropdown({ menu, dropDirection }: NestedDropdownProps) {
+  dropDirection = dropDirection || "down";
+
+  return (
+    <Dropdown drop="down">
+      <Dropdown.Toggle
+        variant="primary"
+        className="custom-btn text-white ps-3 pe-3"
+      >
+        <span className="me-2">{menu.label}</span>
+      </Dropdown.Toggle>
+      <Submenu items={menu.submenu} dropDirection={dropDirection} />
+    </Dropdown>
+  );
+}
+
+export default memo(NestedDropdown);
diff --git a/ambari-web/latest/src/components/SelectTimeRangeModal.tsx 
b/ambari-web/latest/src/components/SelectTimeRangeModal.tsx
new file mode 100644
index 0000000000..37df4b41d5
--- /dev/null
+++ b/ambari-web/latest/src/components/SelectTimeRangeModal.tsx
@@ -0,0 +1,196 @@
+/**
+ * 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 } from "react-bootstrap";
+import Modal from "./Modal";
+import { formatDate, getTimeInNumber, getUserTimezone } from 
"../Utils/Utility";
+import { useState } from "react";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { faCircleXmark } from "@fortawesome/free-solid-svg-icons";
+import { get } from "lodash";
+import { validateDateTime } from "../Utils/validators";
+import { durationMap, durationOptions } from "./constants";
+
+type SelectTimeRangeModalProps = {
+  isOpen: boolean;
+  onClose: () => void;
+  successCallback: (data: any) => void;
+};
+
+export default function SelectTimeRangeModal({
+  isOpen,
+  onClose,
+  successCallback,
+}: SelectTimeRangeModalProps) {
+  const [formData, setFormData] = useState({
+    startTime: {
+      value: formatDate(new Date()),
+      error: "",
+    },
+    endTime: {
+      value: formatDate(new Date()),
+      error: "",
+    },
+  });
+  const [selectedDuration, setSelectedDuration] =
+    useState<string>("15 minutes");
+
+  const hasError = () => {
+    return Object.values(formData).some((data) => data.error);
+  };
+
+  const handleSubmit = () => {
+    if (hasError()) {
+      return;
+    }
+
+    const startTimeNumber = getTimeInNumber(formData.startTime.value);
+    let endTimeNumber = getTimeInNumber(formData.endTime.value);
+
+    if (selectedDuration !== "Custom") {
+      endTimeNumber = startTimeNumber + durationMap[selectedDuration];
+    }
+
+    successCallback({
+      startTime: startTimeNumber,
+      endTime: endTimeNumber,
+    });
+  };
+
+  const validateTimeRange = (key: string, time: string) => {
+    if (!validateDateTime(time)) {
+      setFormData({
+        ...formData,
+        [key]: {
+          value: time,
+          error: "Date is incorrect",
+        },
+      });
+      return;
+    }
+    const errors = { startTime: "", endTime: "" };
+    const currentTimeInNumber = getTimeInNumber(formatDate(new Date()));
+
+    const newTime: { [key: string]: string } = {
+      startTime: formData.startTime.value,
+      endTime: formData.endTime.value,
+    };
+
+    newTime[key] = time;
+
+    if (
+      newTime.startTime &&
+      getTimeInNumber(newTime.startTime) > currentTimeInNumber
+    ) {
+      errors.startTime = "The specified time is in the future";
+    }
+
+    if (
+      selectedDuration === "Custom" &&
+      newTime.startTime &&
+      newTime.endTime &&
+      getTimeInNumber(newTime.endTime) < getTimeInNumber(newTime.startTime)
+    ) {
+      errors.endTime = "End Date must be after Start Date";
+    }
+
+    setFormData({
+      startTime: {
+        value: newTime.startTime,
+        error: errors.startTime,
+      },
+      endTime: {
+        value: newTime.endTime,
+        error: errors.endTime,
+      },
+    });
+  };
+
+  const getModalBody = () => {
+    return (
+      <Form>
+        <Form.Group className="mb-3">
+          <Form.Label>Start Time</Form.Label>
+          <Form.Control
+            value={get(formData, "startTime.value", "")}
+            type="datetime-local"
+            className="custom-form-control"
+            onChange={(e) => validateTimeRange("startTime", e.target.value)}
+          />
+          {get(formData, "startTime.error", "") && (
+            <div className="text-danger mt-3">
+              <FontAwesomeIcon icon={faCircleXmark} />{" "}
+              {get(formData, "startTime.error", "")}
+            </div>
+          )}
+        </Form.Group>
+        <Form.Group className="mb-3">
+          <Form.Label>Duration</Form.Label>
+          <Form.Select
+            className="custom-form-control"
+            value={selectedDuration}
+            onChange={(e) => setSelectedDuration(e.target.value)}
+          >
+            {durationOptions.map((option) => (
+              <option key={option.value} value={option.label}>
+                {option.label}
+              </option>
+            ))}
+          </Form.Select>
+        </Form.Group>
+        {selectedDuration === "Custom" ? (
+          <Form.Group className="mb-3">
+            <Form.Label>End Time</Form.Label>
+            <Form.Control
+              value={get(formData, "endTime.value", "")}
+              type="datetime-local"
+              className="custom-form-control"
+              onChange={(e) => validateTimeRange("endTime", e.target.value)}
+            />
+            {get(formData, "endTime.error", "") && (
+              <div className="text-danger mt-3">
+                <FontAwesomeIcon icon={faCircleXmark} />{" "}
+                {get(formData, "endTime.error", "")}
+              </div>
+            )}
+          </Form.Group>
+        ) : null}
+        <div>
+          <span>Timezone: </span>
+          {getUserTimezone()}
+        </div>
+      </Form>
+    );
+  };
+
+  return (
+    <Modal
+      isOpen={isOpen}
+      onClose={onClose}
+      modalTitle="Select Time Range"
+      modalBody={getModalBody()}
+      successCallback={handleSubmit}
+      options={{
+        modalSize: "modal-sm",
+        cancelableViaIcon: true,
+        cancelableViaBtn: true,
+        okButtonVariant: "primary",
+      }}
+    />
+  );
+}
diff --git a/ambari-web/latest/src/main.tsx 
b/ambari-web/latest/src/components/Tooltip.tsx
old mode 100755
new mode 100644
similarity index 52%
copy from ambari-web/latest/src/main.tsx
copy to ambari-web/latest/src/components/Tooltip.tsx
index 3db5474459..204b704cd0
--- a/ambari-web/latest/src/main.tsx
+++ b/ambari-web/latest/src/components/Tooltip.tsx
@@ -1,4 +1,3 @@
-
 /**
  * Licensed to the Apache Software Foundation (ASF) under one
  * or more contributor license agreements.  See the NOTICE file
@@ -16,13 +15,35 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import { StrictMode } from 'react'
-import { createRoot } from 'react-dom/client'
-import './index.css'
-import App from './App.tsx'
 
-createRoot(document.getElementById('root')!).render(
-  <StrictMode>
-    <App />
-  </StrictMode>,
-)
+import React from "react";
+import { OverlayTrigger, Popover } from "react-bootstrap";
+
+interface TooltipComponentProps {
+  message: string;
+  children: any;
+  heading?: string;
+  placement?: "top" | "right" | "bottom" | "left";
+}
+
+const Tooltip: React.FC<TooltipComponentProps> = ({
+  message,
+  children,
+  heading = "",
+  placement = "top",
+}) => {
+  const renderPopover = (props: any) => (
+    <Popover id="popover-basic" {...props}>
+      {heading && <Popover.Header as="h3">{heading}</Popover.Header>}
+      <Popover.Body>{message}</Popover.Body>
+    </Popover>
+  );
+
+  return (
+    <OverlayTrigger placement={placement} overlay={renderPopover}>
+      {children}
+    </OverlayTrigger>
+  );
+};
+
+export default Tooltip;
diff --git a/ambari-web/latest/src/components/TooltipInput.tsx 
b/ambari-web/latest/src/components/TooltipInput.tsx
new file mode 100644
index 0000000000..f54085b6de
--- /dev/null
+++ b/ambari-web/latest/src/components/TooltipInput.tsx
@@ -0,0 +1,62 @@
+/**
+ * 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 } from "react-bootstrap";
+import Tooltip from "./Tooltip";
+import { ChangeEventHandler, FocusEventHandler } from "react";
+
+type tooltipPropsType = {
+  message: string;
+  heading?: string;
+  placement?: "top" | "right" | "bottom" | "left";
+};
+
+type formControlPropsType = {
+  type: "text" | "password" | "email" | "number" | "checkbox";
+  placeholder?: string;
+  value?: string;
+  className?: string;
+  onChange?: ChangeEventHandler<HTMLInputElement>;
+  onBlur?: FocusEventHandler<HTMLInputElement>;
+  label?: string;
+  checked?: boolean;
+};
+
+type TooltipInputProps = {
+  tooltipProps: tooltipPropsType;
+  formControlProps: formControlPropsType;
+};
+
+export default function TooltipInput({
+  tooltipProps,
+  formControlProps,
+}: TooltipInputProps) {
+  const { type, ...restProps } = formControlProps;
+
+  return (
+    <div>
+      <Tooltip {...tooltipProps}>
+        {type === "checkbox" ? (
+          <Form.Check type={type} {...restProps} />
+        ) : (
+          <Form.Control type={type} {...restProps} />
+        )}
+      </Tooltip>
+    </div>
+  );
+}
diff --git a/ambari-web/latest/src/main.tsx b/ambari-web/latest/src/main.tsx
index 3db5474459..d9c7ca43c0 100755
--- a/ambari-web/latest/src/main.tsx
+++ b/ambari-web/latest/src/main.tsx
@@ -18,7 +18,6 @@
  */
 import { StrictMode } from 'react'
 import { createRoot } from 'react-dom/client'
-import './index.css'
 import App from './App.tsx'
 
 createRoot(document.getElementById('root')!).render(


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


Reply via email to