-
-
Notifications
You must be signed in to change notification settings - Fork 1.2k
feat/bulk pause resume #3626
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat/bulk pause resume #3626
Changes from 6 commits
094def1
6b9759e
4e284c1
ff34c87
1b0b17b
657a35e
387cb35
baf7344
e725128
ba2d23f
c72287c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,102 @@ | ||
| import React from "react"; | ||
| import Box from "@mui/material/Box"; | ||
| import Stack from "@mui/material/Stack"; | ||
| import Typography from "@mui/material/Typography"; | ||
| import Paper from "@mui/material/Paper"; | ||
| import Slide from "@mui/material/Slide"; | ||
| import IconButton from "@mui/material/IconButton"; | ||
| import { X } from "lucide-react"; | ||
|
|
||
| import { useTranslation } from "react-i18next"; | ||
| import { useTheme } from "@mui/material/styles"; | ||
| import useMediaQuery from "@mui/material/useMediaQuery"; | ||
| import { LAYOUT } from "@/Utils/Theme/constants"; | ||
| import { useSidebar } from "@/Hooks/useSidebar"; | ||
|
|
||
| interface BulkActionsBarProps { | ||
| selectedCount: number; | ||
| onCancel: () => void; | ||
| children?: React.ReactNode; | ||
| } | ||
|
|
||
| export const BulkActionsBar = ({ | ||
| selectedCount, | ||
| onCancel, | ||
| children, | ||
| }: BulkActionsBarProps) => { | ||
| const { t } = useTranslation(); | ||
| const theme = useTheme(); | ||
| const isOpen = selectedCount > 0; | ||
|
|
||
| const isSmall = useMediaQuery(theme.breakpoints.down("md")); | ||
| const { width, collapsedWidth, transition } = useSidebar(); | ||
|
|
||
| return ( | ||
| <Box | ||
| sx={{ | ||
| position: "fixed", | ||
| bottom: theme.spacing(LAYOUT.XS), | ||
| left: isSmall ? collapsedWidth : width, | ||
| right: 0, | ||
| display: "flex", | ||
| justifyContent: "center", | ||
| pointerEvents: "none", | ||
| zIndex: theme.zIndex.snackbar, | ||
| transition: transition.replace("width", "left"), | ||
| }} | ||
| > | ||
| <Slide | ||
| direction="up" | ||
| in={isOpen} | ||
| mountOnEnter | ||
| unmountOnExit | ||
| > | ||
| <Paper | ||
| elevation={6} | ||
| sx={{ | ||
| pointerEvents: "auto", | ||
| px: LAYOUT.MD, | ||
| py: LAYOUT.XS, | ||
| borderRadius: theme.shape.borderRadius, | ||
| backgroundColor: theme.palette.background.paper, | ||
| border: `1px solid ${theme.palette.divider}`, | ||
| display: "flex", | ||
| alignItems: "center", | ||
| gap: LAYOUT.XS, | ||
| boxShadow: theme.shadows[8], | ||
| }} | ||
| > | ||
| <Stack | ||
| direction="row" | ||
| alignItems="center" | ||
| gap={LAYOUT.XXS} | ||
| > | ||
| <Typography | ||
| variant="body1" | ||
| fontWeight={600} | ||
| color={theme.palette.text.primary} | ||
| > | ||
| {t("pages.common.monitors.actions.bulkSelected", { | ||
| count: selectedCount, | ||
| })} | ||
| </Typography> | ||
| <IconButton | ||
| size="small" | ||
| onClick={onCancel} | ||
| aria-label={t("common.buttons.cancel")} | ||
| > | ||
| <X size={18} /> | ||
| </IconButton> | ||
| </Stack> | ||
| <Stack | ||
| direction="row" | ||
| alignItems="center" | ||
| gap={LAYOUT.XXS} | ||
| > | ||
| {children} | ||
| </Stack> | ||
| </Paper> | ||
| </Slide> | ||
| </Box> | ||
| ); | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,84 @@ | ||
| import { useState, useEffect } from "react"; | ||
| import { post } from "@/Utils/ApiClient"; | ||
| import { useToast } from "@/Hooks/UseToast"; | ||
| import { useTranslation } from "react-i18next"; | ||
| import { logger } from "@/Utils/logger"; | ||
| import type { Monitor } from "@/Types/Monitor"; | ||
|
|
||
| interface ApiResponse { | ||
| success: boolean; | ||
| msg: string; | ||
| data: Monitor[]; | ||
| } | ||
|
|
||
| interface UseBulkMonitorActionsReturn { | ||
| selectedRows: string[]; | ||
| setSelectedRows: (rows: string[]) => void; | ||
| handleBulkPause: () => Promise<void>; | ||
| handleBulkResume: () => Promise<void>; | ||
| handleCancelSelection: () => void; | ||
| } | ||
|
|
||
| export const useBulkMonitorActions = ( | ||
| refetch: () => void, | ||
| page?: number | ||
| ): UseBulkMonitorActionsReturn => { | ||
| const [selectedRows, setSelectedRows] = useState<string[]>([]); | ||
| const { toastSuccess, toastError, toastInfo } = useToast(); | ||
| const { t } = useTranslation(); | ||
|
|
||
| // Clear selection when page changes | ||
| useEffect(() => { | ||
| setSelectedRows([]); | ||
| }, [page]); | ||
|
|
||
| const executeBulkAction = async (pause: boolean) => { | ||
| try { | ||
| const res = await post<ApiResponse>("/monitors/bulk/pause", { | ||
| monitorIds: selectedRows, | ||
| pause, | ||
| }); | ||
|
|
||
| const affectedCount = res.data?.data?.length ?? 0; | ||
|
|
||
| if (affectedCount === 0) { | ||
| const key = pause | ||
| ? "pages.common.monitors.bulkPause.alreadyPaused" | ||
| : "pages.common.monitors.bulkPause.alreadyRunning"; | ||
| toastInfo(t(key, { count: selectedRows.length })); | ||
| } else { | ||
| const key = pause | ||
| ? "pages.common.monitors.bulkPause.paused" | ||
| : "pages.common.monitors.bulkPause.resumed"; | ||
| toastSuccess(t(key, { count: affectedCount })); | ||
| } | ||
|
|
||
| setSelectedRows([]); | ||
| refetch(); | ||
| } catch (err: any) { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Errors should be typed as |
||
| const errMsg = err?.response?.data?.msg || err.message || "An error occurred"; | ||
| logger.error("Bulk pause/resume failed", err, { pause }); | ||
| toastError(errMsg); | ||
| } | ||
| }; | ||
|
|
||
| const handleBulkPause = async () => { | ||
| await executeBulkAction(true); | ||
| }; | ||
|
|
||
| const handleBulkResume = async () => { | ||
| await executeBulkAction(false); | ||
| }; | ||
|
|
||
| const handleCancelSelection = () => { | ||
| setSelectedRows([]); | ||
| }; | ||
|
|
||
| return { | ||
| selectedRows, | ||
| setSelectedRows, | ||
| handleBulkPause, | ||
| handleBulkResume, | ||
| handleCancelSelection, | ||
| }; | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 = ( | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't think a hook is necessary for this functionality; the abstraction obfuscates more than it simplifies. It moves state away from the page where it is used for no real gain in my opinion. All we're doing with this hook really is moving state out of the page it is used just to pipe it back in again. |
||
| 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, | ||
| }; | ||
| }; | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is a triple condition that is very easy to miss if you aren't very careful. It is also not easy to grep if we are looking for this behaviour later.
This is equivalent to
An explicit
booleanflag here makes more sense; we should be precise in our code rather than depending on side effects of something being defined or undefined.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Replaced the null and undefined checks in with a hideMobileLabel bool flag.