From 6b9759e455f4f0b0f5eba375f33ffe117c1f6b7e Mon Sep 17 00:00:00 2001 From: Retr01-sudo Date: Fri, 1 May 2026 19:43:27 +0530 Subject: [PATCH] feat(monitors): create bulk actions ui components and selection hooks - Extracts generic table selection logic into useTableSelection hook - Creates BulkActionsBar component with Collapse animations - Implements useBulkMonitorActions to handle API logic and toasts - Adds i18n translation strings across all 16 locales Fixes #3602 --- .../Components/monitors/BulkActionsBar.tsx | 78 +++++++++++++++++++ client/src/Components/monitors/index.tsx | 1 + client/src/Hooks/useBulkMonitorActions.ts | 62 +++++++++++++++ client/src/Hooks/useTableSelection.ts | 74 ++++++++++++++++++ client/src/locales/ar.json | 5 +- client/src/locales/cs.json | 5 +- client/src/locales/de.json | 5 +- client/src/locales/en.json | 5 +- client/src/locales/es.json | 5 +- client/src/locales/fi.json | 5 +- client/src/locales/fr.json | 5 +- client/src/locales/ja.json | 5 +- client/src/locales/pt-BR.json | 5 +- client/src/locales/ru.json | 5 +- client/src/locales/th.json | 5 +- client/src/locales/tr.json | 5 +- client/src/locales/uk.json | 5 +- client/src/locales/vi.json | 5 +- client/src/locales/zh-CN.json | 5 +- client/src/locales/zh-TW.json | 5 +- 20 files changed, 279 insertions(+), 16 deletions(-) create mode 100644 client/src/Components/monitors/BulkActionsBar.tsx create mode 100644 client/src/Hooks/useBulkMonitorActions.ts create mode 100644 client/src/Hooks/useTableSelection.ts diff --git a/client/src/Components/monitors/BulkActionsBar.tsx b/client/src/Components/monitors/BulkActionsBar.tsx new file mode 100644 index 000000000..aef5e4871 --- /dev/null +++ b/client/src/Components/monitors/BulkActionsBar.tsx @@ -0,0 +1,78 @@ +import Box from "@mui/material/Box"; +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +import Button from "@mui/material/Button"; +import Collapse from "@mui/material/Collapse"; +import { Play, Pause } from "lucide-react"; + +import { useTranslation } from "react-i18next"; +import { useTheme } from "@mui/material/styles"; + +interface BulkActionsBarProps { + selectedCount: number; + onResume: () => void; + onPause: () => void; + onCancel: () => void; +} + +export const BulkActionsBar = ({ + selectedCount, + onResume, + onPause, + onCancel, +}: BulkActionsBarProps) => { + const { t } = useTranslation(); + const theme = useTheme(); + + return ( + 0}> + + + + {t("pages.common.monitors.actions.bulkSelected", { + count: selectedCount, + })} + + + + + + + + + + ); +}; diff --git a/client/src/Components/monitors/index.tsx b/client/src/Components/monitors/index.tsx index efba79a08..46532706f 100644 --- a/client/src/Components/monitors/index.tsx +++ b/client/src/Components/monitors/index.tsx @@ -14,3 +14,4 @@ export * from "./charts/HistogramPageSpeedDetails"; export * from "./charts/HistogramPageSpeedDetailsTooltip"; export * from "./charts/HistogramInfrastructure"; export * from "./HeaderMonitorsSummary"; +export * from "./BulkActionsBar"; diff --git a/client/src/Hooks/useBulkMonitorActions.ts b/client/src/Hooks/useBulkMonitorActions.ts new file mode 100644 index 000000000..3bb520aa1 --- /dev/null +++ b/client/src/Hooks/useBulkMonitorActions.ts @@ -0,0 +1,62 @@ +import { useState, useEffect } from "react"; +import { usePost } from "@/Hooks/UseApi"; + +interface BulkPauseBody { + monitorIds: string[]; + pause: boolean; +} + +interface UseBulkMonitorActionsReturn { + selectedRows: string[]; + setSelectedRows: (rows: string[]) => void; + handleBulkPause: () => Promise; + handleBulkResume: () => Promise; + handleCancelSelection: () => void; +} + +export const useBulkMonitorActions = ( + refetch: () => void, + page?: number +): UseBulkMonitorActionsReturn => { + const [selectedRows, setSelectedRows] = useState([]); + const { post: bulkPost } = usePost(); + + // Clear selection when page changes + useEffect(() => { + setSelectedRows([]); + }, [page]); + + const handleBulkPause = async () => { + const result = await bulkPost("/monitors/bulk/pause", { + monitorIds: selectedRows, + pause: true, + }); + if (result) { + setSelectedRows([]); + refetch(); + } + }; + + const handleBulkResume = async () => { + const result = await bulkPost("/monitors/bulk/pause", { + monitorIds: selectedRows, + pause: false, + }); + if (result) { + setSelectedRows([]); + refetch(); + } + }; + + const handleCancelSelection = () => { + setSelectedRows([]); + }; + + return { + selectedRows, + setSelectedRows, + handleBulkPause, + handleBulkResume, + handleCancelSelection, + }; +}; diff --git a/client/src/Hooks/useTableSelection.ts b/client/src/Hooks/useTableSelection.ts new file mode 100644 index 000000000..890225ade --- /dev/null +++ b/client/src/Hooks/useTableSelection.ts @@ -0,0 +1,74 @@ +import { useMemo, useCallback } from "react"; + +interface SelectableItem { + id: string; +} + +interface UseTableSelectionReturn { + isAllSelected: boolean; + isSomeSelected: boolean; + handleSelectAll: (checked: boolean) => void; + handleSelectRow: (itemId: string, checked: boolean) => void; + isRowSelected: (itemId: string) => boolean; +} + +export const useTableSelection = ( + items: SelectableItem[], + selectedIds: string[], + onSelectionChange?: (selected: string[]) => void +): UseTableSelectionReturn => { + const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]); + + const isAllSelected = useMemo( + () => + items.length > 0 && + items.every((item) => selectedSet.has(item.id)) && + selectedIds.length >= items.length, + [items, selectedSet, selectedIds.length] + ); + + const isSomeSelected = useMemo( + () => selectedIds.length > 0 && !isAllSelected, + [selectedIds.length, isAllSelected] + ); + + const handleSelectAll = useCallback( + (checked: boolean) => { + if (onSelectionChange) { + if (checked) { + const allIds = items.map((item) => item.id); + onSelectionChange(allIds); + } else { + onSelectionChange([]); + } + } + }, + [items, onSelectionChange] + ); + + const handleSelectRow = useCallback( + (itemId: string, checked: boolean) => { + if (onSelectionChange) { + if (checked) { + onSelectionChange([...selectedIds, itemId]); + } else { + onSelectionChange(selectedIds.filter((id) => id !== itemId)); + } + } + }, + [selectedIds, onSelectionChange] + ); + + const isRowSelected = useCallback( + (itemId: string) => selectedSet.has(itemId), + [selectedSet] + ); + + return { + isAllSelected, + isSomeSelected, + handleSelectAll, + handleSelectRow, + isRowSelected, + }; +}; diff --git a/client/src/locales/ar.json b/client/src/locales/ar.json index 11fa81309..70509feed 100644 --- a/client/src/locales/ar.json +++ b/client/src/locales/ar.json @@ -421,7 +421,10 @@ "inviteMember": "دعوة عضو", "openSite": "فتح الموقع", "pause": "إيقاف مؤقت", - "resume": "استئناف" + "resume": "استئناف", + "bulkSelected": "تم تحديد {{count}} جهاز(أجهزة) مراقبة", + "selectAll": "تحديد جميع أجهزة المراقبة", + "selectMonitor": "تحديد {{name}}" }, "statBoxes": { "activeFor": "نشط منذ", diff --git a/client/src/locales/cs.json b/client/src/locales/cs.json index 454f0b673..e86677c29 100644 --- a/client/src/locales/cs.json +++ b/client/src/locales/cs.json @@ -421,7 +421,10 @@ "inviteMember": "Pozvat člena", "openSite": "Otevřít stránku", "pause": "Pozastavit", - "resume": "Obnovit" + "resume": "Obnovit", + "bulkSelected": "{{count}} monitor(ů) vybráno", + "selectAll": "Vybrat všechny monitory", + "selectMonitor": "Vybrat {{name}}" }, "statBoxes": { "activeFor": "Aktivní po dobu", diff --git a/client/src/locales/de.json b/client/src/locales/de.json index 8090aa5f3..cd93c4810 100644 --- a/client/src/locales/de.json +++ b/client/src/locales/de.json @@ -421,7 +421,10 @@ "inviteMember": "Mitglied einladen", "openSite": "Seite öffnen", "pause": "Pausieren", - "resume": "Fortsetzen" + "resume": "Fortsetzen", + "bulkSelected": "{{count}} Monitor(e) ausgewählt", + "selectAll": "Alle Monitore auswählen", + "selectMonitor": "{{name}} auswählen" }, "statBoxes": { "activeFor": "Aktiv seit", diff --git a/client/src/locales/en.json b/client/src/locales/en.json index d6e8bb4f3..2e22baa07 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -421,7 +421,10 @@ "inviteMember": "Invite member", "openSite": "Open site", "pause": "Pause", - "resume": "Resume" + "resume": "Resume", + "bulkSelected": "{{count}} Monitor(s) selected", + "selectAll": "Select all monitors", + "selectMonitor": "Select {{name}}" }, "statBoxes": { "activeFor": "Active for", diff --git a/client/src/locales/es.json b/client/src/locales/es.json index 506c317e3..04f447e40 100644 --- a/client/src/locales/es.json +++ b/client/src/locales/es.json @@ -428,7 +428,10 @@ "inviteMember": "Invitar miembro", "openSite": "Abrir sitio", "pause": "Pausar", - "resume": "Reanudar" + "resume": "Reanudar", + "bulkSelected": "{{count}} monitor(es) seleccionado(s)", + "selectAll": "Seleccionar todos los monitores", + "selectMonitor": "Seleccionar {{name}}" }, "statBoxes": { "activeFor": "Activo desde hace", diff --git a/client/src/locales/fi.json b/client/src/locales/fi.json index 6238837f2..74afedd84 100644 --- a/client/src/locales/fi.json +++ b/client/src/locales/fi.json @@ -421,7 +421,10 @@ "inviteMember": "Kutsu jäsen", "openSite": "Avaa sivusto", "pause": "Keskeytä", - "resume": "Jatka" + "resume": "Jatka", + "bulkSelected": "{{count}} monitori(a) valittu", + "selectAll": "Valitse kaikki monitorit", + "selectMonitor": "Valitse {{name}}" }, "statBoxes": { "activeFor": "Aktiivinen", diff --git a/client/src/locales/fr.json b/client/src/locales/fr.json index 306a2bd61..8e3c78b85 100644 --- a/client/src/locales/fr.json +++ b/client/src/locales/fr.json @@ -421,7 +421,10 @@ "inviteMember": "Inviter un membre", "openSite": "Ouvrir le site", "pause": "Pause", - "resume": "Reprendre" + "resume": "Reprendre", + "bulkSelected": "{{count}} moniteur(s) sélectionné(s)", + "selectAll": "Sélectionner tous les moniteurs", + "selectMonitor": "Sélectionner {{name}}" }, "statBoxes": { "activeFor": "Actif depuis", diff --git a/client/src/locales/ja.json b/client/src/locales/ja.json index 923213a46..09db6971f 100644 --- a/client/src/locales/ja.json +++ b/client/src/locales/ja.json @@ -421,7 +421,10 @@ "inviteMember": "メンバーを招待", "openSite": "サイトを開く", "pause": "一時停止", - "resume": "再開" + "resume": "再開", + "bulkSelected": "{{count}} 件のモニターを選択中", + "selectAll": "すべてのモニターを選択", + "selectMonitor": "{{name}} を選択" }, "statBoxes": { "activeFor": "稼働期間", diff --git a/client/src/locales/pt-BR.json b/client/src/locales/pt-BR.json index f7b7c4cbc..6c97a9869 100644 --- a/client/src/locales/pt-BR.json +++ b/client/src/locales/pt-BR.json @@ -421,7 +421,10 @@ "inviteMember": "Convidar membro", "openSite": "Abrir site", "pause": "Pausar", - "resume": "Retomar" + "resume": "Retomar", + "bulkSelected": "{{count}} monitor(es) selecionado(s)", + "selectAll": "Selecionar todos os monitores", + "selectMonitor": "Selecionar {{name}}" }, "statBoxes": { "activeFor": "Ativo há", diff --git a/client/src/locales/ru.json b/client/src/locales/ru.json index 1a5d12452..778d481f9 100644 --- a/client/src/locales/ru.json +++ b/client/src/locales/ru.json @@ -421,7 +421,10 @@ "inviteMember": "Пригласить участника", "openSite": "Открыть сайт", "pause": "Пауза", - "resume": "Продолжить" + "resume": "Продолжить", + "bulkSelected": "Выбрано мониторов: {{count}}", + "selectAll": "Выбрать все мониторы", + "selectMonitor": "Выбрать {{name}}" }, "statBoxes": { "activeFor": "Активен", diff --git a/client/src/locales/th.json b/client/src/locales/th.json index 433ee10cd..aab97dc93 100644 --- a/client/src/locales/th.json +++ b/client/src/locales/th.json @@ -421,7 +421,10 @@ "inviteMember": "เชิญสมาชิก", "openSite": "เปิดเว็บไซต์", "pause": "หยุดชั่วคราว", - "resume": "ดำเนินการต่อ" + "resume": "ดำเนินการต่อ", + "bulkSelected": "เลือก {{count}} มอนิเตอร์แล้ว", + "selectAll": "เลือกมอนิเตอร์ทั้งหมด", + "selectMonitor": "เลือก {{name}}" }, "statBoxes": { "activeFor": "ทำงานมาแล้ว", diff --git a/client/src/locales/tr.json b/client/src/locales/tr.json index a77dd8eb3..6e6aaf365 100644 --- a/client/src/locales/tr.json +++ b/client/src/locales/tr.json @@ -421,7 +421,10 @@ "inviteMember": "Üye davet et", "openSite": "Siteyi aç", "pause": "Duraklat", - "resume": "Devam et" + "resume": "Devam et", + "bulkSelected": "{{count}} monitör seçildi", + "selectAll": "Tüm monitörleri seç", + "selectMonitor": "{{name}} seç" }, "statBoxes": { "activeFor": "Aktif süresi", diff --git a/client/src/locales/uk.json b/client/src/locales/uk.json index 5b467e4b4..f7ff24647 100644 --- a/client/src/locales/uk.json +++ b/client/src/locales/uk.json @@ -421,7 +421,10 @@ "inviteMember": "Запросити учасника", "openSite": "Відкрити сайт", "pause": "Призупинити", - "resume": "Відновити" + "resume": "Відновити", + "bulkSelected": "Вибрано моніторів: {{count}}", + "selectAll": "Вибрати всі монітори", + "selectMonitor": "Вибрати {{name}}" }, "statBoxes": { "activeFor": "Активний протягом", diff --git a/client/src/locales/vi.json b/client/src/locales/vi.json index dc3e804de..90d8afd04 100644 --- a/client/src/locales/vi.json +++ b/client/src/locales/vi.json @@ -421,7 +421,10 @@ "inviteMember": "Mời thành viên", "openSite": "Mở trang web", "pause": "Tạm dừng", - "resume": "Tiếp tục" + "resume": "Tiếp tục", + "bulkSelected": "Đã chọn {{count}} monitor", + "selectAll": "Chọn tất cả monitor", + "selectMonitor": "Chọn {{name}}" }, "statBoxes": { "activeFor": "Hoạt động từ", diff --git a/client/src/locales/zh-CN.json b/client/src/locales/zh-CN.json index ed5e70c1e..73a4ce166 100644 --- a/client/src/locales/zh-CN.json +++ b/client/src/locales/zh-CN.json @@ -421,7 +421,10 @@ "inviteMember": "邀请成员", "openSite": "打开站点", "pause": "暂停", - "resume": "恢复" + "resume": "恢复", + "bulkSelected": "已选择 {{count}} 个监控", + "selectAll": "选择所有监控", + "selectMonitor": "选择 {{name}}" }, "statBoxes": { "activeFor": "已活跃", diff --git a/client/src/locales/zh-TW.json b/client/src/locales/zh-TW.json index ff6e9f6dd..91fc7766e 100644 --- a/client/src/locales/zh-TW.json +++ b/client/src/locales/zh-TW.json @@ -421,7 +421,10 @@ "inviteMember": "邀請成員", "openSite": "開啟網站", "pause": "暫停", - "resume": "恢復" + "resume": "恢復", + "bulkSelected": "已選擇 {{count}} 個監控", + "selectAll": "選擇所有監控", + "selectMonitor": "選擇 {{name}}" }, "statBoxes": { "activeFor": "已啟用",