From a5042f49ac2be41ca24caf7202590d3db92f1fee Mon Sep 17 00:00:00 2001 From: ashu130698 Date: Sun, 30 Aug 2026 17:55:13 +0530 Subject: [PATCH] feat(ui): add bulk edit notifications modal and floating action bar to uptime monitors --- client/src/Components/inputs/Dialog.tsx | 3 + .../Components/monitors/BulkActionsBar.tsx | 41 +++-- .../monitors/BulkEditNotificationsModal.tsx | 153 ++++++++++++++++++ .../Components/monitors/MonitorListPage.tsx | 6 + client/src/Components/monitors/index.tsx | 1 + .../Components/UptimeMonitorsTable.tsx | 40 ++++- client/src/Pages/Uptime/Monitors/index.tsx | 34 +++- client/src/locales/en.json | 15 ++ 8 files changed, 268 insertions(+), 25 deletions(-) create mode 100644 client/src/Components/monitors/BulkEditNotificationsModal.tsx diff --git a/client/src/Components/inputs/Dialog.tsx b/client/src/Components/inputs/Dialog.tsx index 02d164abda..1053f8059c 100644 --- a/client/src/Components/inputs/Dialog.tsx +++ b/client/src/Components/inputs/Dialog.tsx @@ -21,6 +21,7 @@ export const DialogInput = ({ loading = false, cancelText, confirmText, + confirmDisabled = false, children, maxWidth, fullWidth = false, @@ -35,6 +36,7 @@ export const DialogInput = ({ loading?: boolean; cancelText?: string; confirmText?: string; + confirmDisabled?: boolean; children?: ReactNode; maxWidth?: DialogProps["maxWidth"]; fullWidth?: boolean; @@ -104,6 +106,7 @@ export const DialogInput = ({ variant="contained" color={confirmColor} onClick={onConfirm} + disabled={confirmDisabled} > {confirmText ?? t("common.buttons.confirm")} diff --git a/client/src/Components/monitors/BulkActionsBar.tsx b/client/src/Components/monitors/BulkActionsBar.tsx index aa520745f8..3cf3c2b26c 100644 --- a/client/src/Components/monitors/BulkActionsBar.tsx +++ b/client/src/Components/monitors/BulkActionsBar.tsx @@ -17,32 +17,39 @@ interface BulkActionsBarProps { selectedCount: number; onCancel: () => void; children?: React.ReactNode; + hidden?: boolean; } export const BulkActionsBar = ({ selectedCount, onCancel, children, + hidden = false, }: BulkActionsBarProps) => { const { t } = useTranslation(); const theme = useTheme(); - const isOpen = selectedCount > 0; + const isOpen = selectedCount > 0 && !hidden; const isSmall = useMediaQuery(theme.breakpoints.down("md")); const { width, collapsedWidth, transition } = useSidebar(); + const sidebarOffset = isSmall ? collapsedWidth : width; + return ( - @@ -91,11 +102,13 @@ export const BulkActionsBar = ({ {children} - + ); diff --git a/client/src/Components/monitors/BulkEditNotificationsModal.tsx b/client/src/Components/monitors/BulkEditNotificationsModal.tsx new file mode 100644 index 0000000000..3e33a6bae1 --- /dev/null +++ b/client/src/Components/monitors/BulkEditNotificationsModal.tsx @@ -0,0 +1,153 @@ +import React, { useState, useEffect, useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { useTheme } from "@mui/material/styles"; +import { + Dialog, + Autocomplete, + TextField, + RadioWithDescription, +} from "@/Components/inputs"; +import { SPACING, LAYOUT } from "@/Utils/Theme/constants"; +import Stack from "@mui/material/Stack"; +import RadioGroup from "@mui/material/RadioGroup"; +import Typography from "@mui/material/Typography"; +import Alert from "@mui/material/Alert"; +import { useGet, usePatch } from "@/Hooks/UseApi"; +import type { Notification } from "@/Types/Notification"; + +interface BulkEditNotificationsModalProps { + open: boolean; + onClose: () => void; + selectedMonitors: string[]; + onComplete: (success: boolean) => void; +} + +export const BulkEditNotificationsModal: React.FC = ({ + open, + onClose, + selectedMonitors, + onComplete, +}) => { + const { t } = useTranslation(); + const theme = useTheme(); + + const [action, setAction] = useState<"add" | "remove" | "set">("add"); + const [notificationIds, setNotificationIds] = useState([]); + + // Fetch available notifications + const { data: notifications } = useGet( + "/notifications/team", + undefined, + { + keepPreviousData: true, + } + ); + + const { patch, loading: isPatching, error } = usePatch(); + + // Reset state when modal opens + useEffect(() => { + if (open) { + setAction("add"); + setNotificationIds([]); + } + }, [open]); + + const handleConfirm = async () => { + const res = await patch("/monitors/notifications", { + monitorIds: selectedMonitors, + notificationIds, + action, + }); + + onComplete(!!res); + }; + + // Prevent submitting empty arrays unless the action is 'set' + const isMissingSelection = action !== "set" && notificationIds.length === 0; + + // Map notifications to the { id, name } structure expected by the Autocomplete component + const options = useMemo(() => { + return (notifications ?? []).map((n) => ({ + id: n.id, + name: `${n.notificationName} (${n.type.toUpperCase()})`, + })); + }, [notifications]); + + const selectedOptions = useMemo(() => { + return options.filter((opt) => notificationIds.includes(opt.id)); + }, [options, notificationIds]); + + return ( + + + {error && {error}} + + {t("pages.common.monitors.bulkEdit.selectedText", { + count: selectedMonitors.length, + })} + + + { + const val = e.target.value; + if (val === "add" || val === "remove" || val === "set") { + setAction(val); + } + }} + > + + + + + + option.name} + isOptionEqualToValue={(option, val) => option.id === val.id} + onChange={(_, newValue) => { + if (Array.isArray(newValue)) { + setNotificationIds(newValue.map((v) => v.id)); + } else { + setNotificationIds([]); + } + }} + fieldLabel={t("pages.common.monitors.bulkEdit.selectLabel")} + renderInput={(params) => ( + + )} + /> + + + ); +}; diff --git a/client/src/Components/monitors/MonitorListPage.tsx b/client/src/Components/monitors/MonitorListPage.tsx index 8244f38750..d259c888a3 100644 --- a/client/src/Components/monitors/MonitorListPage.tsx +++ b/client/src/Components/monitors/MonitorListPage.tsx @@ -21,6 +21,8 @@ interface MonitorListPageProps { actionLink: string; controller: MonitorListController; bulkActions?: boolean; + bulkActionsExtra?: ReactNode; + bulkActionsHidden?: boolean; showTypeFilter?: boolean; summaryProps?: { showBreached?: boolean }; priorityFallback?: ReactNode; @@ -37,6 +39,8 @@ export const MonitorListPage = ({ actionLink, controller: c, bulkActions, + bulkActionsExtra, + bulkActionsHidden, showTypeFilter, summaryProps, priorityFallback, @@ -125,6 +129,7 @@ export const MonitorListPage = ({ )} {children} diff --git a/client/src/Components/monitors/index.tsx b/client/src/Components/monitors/index.tsx index cf87eb0745..0470ed02c7 100644 --- a/client/src/Components/monitors/index.tsx +++ b/client/src/Components/monitors/index.tsx @@ -15,4 +15,5 @@ export * from "./charts/HistogramPageSpeedDetailsTooltip"; export * from "./charts/HistogramInfrastructure"; export * from "./HeaderMonitorsSummary"; export * from "./BulkActionsBar"; +export * from "./BulkEditNotificationsModal"; export * from "./MonitorListPage"; diff --git a/client/src/Pages/Uptime/Monitors/Components/UptimeMonitorsTable.tsx b/client/src/Pages/Uptime/Monitors/Components/UptimeMonitorsTable.tsx index ed32cf7eac..b3fffa85e9 100644 --- a/client/src/Pages/Uptime/Monitors/Components/UptimeMonitorsTable.tsx +++ b/client/src/Pages/Uptime/Monitors/Components/UptimeMonitorsTable.tsx @@ -14,17 +14,17 @@ import { ArrowDown, ArrowUp } from "lucide-react"; import { useTranslation } from "react-i18next"; import { useTheme } from "@mui/material/styles"; +import useMediaQuery from "@mui/material/useMediaQuery"; import { useNavigate } from "react-router-dom"; import { usePost } from "@/Hooks/UseApi"; import { useSelector } from "react-redux"; -import useMediaQuery from "@mui/material/useMediaQuery"; import type { Monitor } from "@/Types/Monitor"; import type { ActionMenuItem } from "@/Components/actions-menu"; import type { RootState } from "@/Types/state"; import { Checkbox } from "@/Components/inputs"; import type { Tag } from "@/Types/Tag"; -import { SPACING } from "@/Utils/Theme/constants"; +import { SPACING, LAYOUT } from "@/Utils/Theme/constants"; import { getUptimePercentageColor } from "@/Utils/MonitorUtils"; import { formatPercentage } from "@/Utils/FormatUtils"; @@ -65,10 +65,10 @@ export const MonitorTable = ({ }: MonitorTableProps) => { const { t } = useTranslation(); const theme = useTheme(); + const isSmall = useMediaQuery(theme.breakpoints.down("md")); const navigate = useNavigate(); const chartType = useSelector((state: RootState) => state.ui?.chartType ?? "histogram"); const { post } = usePost, Monitor>(); - const isSmall = useMediaQuery(theme.breakpoints.down("md")); const selectedSet = new Set(selectedRows); const isAllSelected = @@ -214,7 +214,7 @@ export const MonitorTable = ({ mobileLabel: t("common.table.headers.name"), content: ( handleSort(e, "name")} @@ -234,14 +234,14 @@ export const MonitorTable = ({ mobileLabel: t("common.table.headers.status"), content: ( handleSort(e, "status")} sx={{ cursor: "pointer" }} > - + {t("common.table.headers.status")} {renderSortIcon(sortField === "status")} @@ -311,14 +311,14 @@ export const MonitorTable = ({ mobileLabel: t("common.table.headers.type"), content: ( handleSort(e, "type")} sx={{ cursor: "pointer" }} > - + {t("common.table.headers.type")} {renderSortIcon(sortField === "type")} @@ -344,6 +344,30 @@ export const MonitorTable = ({ return ( + {isSmall && ( + + + handleSelectAll(e.target.checked)} + /> + + {t("pages.common.monitors.bulkEdit.selectAll")} + + + + )} { + const { t } = useTranslation(); const c = useMonitorListController({ types: "selectable", checksLimit: 25, @@ -12,6 +15,16 @@ const UptimeMonitorsPage = () => { rowsPerPageDefault: 10, }); + const [isBulkEditModalOpen, setIsBulkEditModalOpen] = useState(false); + + const handleBulkEditComplete = (success: boolean) => { + setIsBulkEditModalOpen(false); + if (success) { + c.setSelectedRows([]); + c.refetch(); + } + }; + return ( { actionLink="/uptime/create" controller={c} bulkActions + bulkActionsHidden={isBulkEditModalOpen} + bulkActionsExtra={ + + } > { selectedRows={c.selectedRows} onSelectionChange={c.setSelectedRows} /> + setIsBulkEditModalOpen(false)} + selectedMonitors={c.selectedRows} + onComplete={handleBulkEditComplete} + /> ); }; diff --git a/client/src/locales/en.json b/client/src/locales/en.json index e82f849490..ca9958d570 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -30,6 +30,7 @@ "home": "Home" }, "buttons": { + "selected": "selected", "addMember": "Add member", "cancel": "Cancel", "close": "Close", @@ -504,6 +505,20 @@ }, "common": { "monitors": { + "bulkEdit": { + "selectAll": "Select All", + "editButton": "Edit Notifications", + "title": "Edit Notifications", + "selectedText": "You are applying changes to {{count}} monitor(s).", + "actionAdd": "Add", + "actionAddDesc": "Adds the selected notifications to these monitors without removing existing ones.", + "actionRemove": "Remove", + "actionRemoveDesc": "Removes exclusively the selected notifications from these monitors.", + "actionSet": "Replace all", + "actionSetDesc": "Replaces all existing notifications. Leave empty to clear all notifications.", + "selectLabel": "Notification Channels", + "selectPlaceholder": "Select channels..." + }, "actions": { "configure": "Configure", "delete": "Delete",