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]