From 9f310ffbf68109994307c7ceda3b17d6f0510ba9 Mon Sep 17 00:00:00 2001 From: Codebmk Date: Thu, 28 May 2026 18:29:45 +0300 Subject: [PATCH 01/11] Implement multi-select functionality in DevicesTable and enhance ReusableTable actions Add multi-select capability to the DevicesTable component, allowing users to select multiple devices for actions such as adding or removing from cohorts. Update the ReusableTable to support dynamic action handling based on selected items, improving user interaction and functionality. Additionally, clean up the action handling logic for better maintainability. --- .../devices/my-devices/page.tsx | 2 + .../features/devices/device-list-table.tsx | 65 +++++++++++------ .../components/shared/table/ReusableTable.tsx | 73 +++++++------------ 3 files changed, 72 insertions(+), 68 deletions(-) diff --git a/src/vertex/app/(authenticated)/devices/my-devices/page.tsx b/src/vertex/app/(authenticated)/devices/my-devices/page.tsx index 91d9c1bde2..aabd1c4044 100644 --- a/src/vertex/app/(authenticated)/devices/my-devices/page.tsx +++ b/src/vertex/app/(authenticated)/devices/my-devices/page.tsx @@ -192,6 +192,8 @@ const MyDevicesPage = () => { devices={filteredDevices} isLoading={isLoading} error={error} + multiSelect={true} + /> {/* Modals */} diff --git a/src/vertex/components/features/devices/device-list-table.tsx b/src/vertex/components/features/devices/device-list-table.tsx index 60061b6231..f9862cb679 100644 --- a/src/vertex/components/features/devices/device-list-table.tsx +++ b/src/vertex/components/features/devices/device-list-table.tsx @@ -1,6 +1,6 @@ import { ExclamationTriangleIcon } from "@radix-ui/react-icons"; import { AqFilterLines } from "@airqo/icons-react"; -import { Check, X } from "lucide-react"; +import { Check, Plus, Trash2, X } from "lucide-react"; import { DropdownMenu, DropdownMenuContent, @@ -10,7 +10,7 @@ import { import { Button } from "@/components/ui/button"; import { Device } from "@/app/types/devices"; import { useRouter, useSearchParams } from "next/navigation"; -import ReusableTable from "@/components/shared/table/ReusableTable"; +import ReusableTable, { TableAction } from "@/components/shared/table/ReusableTable"; import ReusableButton from "@/components/shared/button/ReusableButton"; import { useState, useMemo } from "react"; import { AssignCohortDevicesDialog } from "@/components/features/cohorts/assign-cohort-devices"; @@ -101,11 +101,19 @@ export default function DevicesTable({ setShowUnassignDialog(false); }; - const handleAddCohortDeviceActionSubmit = () => { + const handleAddCohortDeviceActionSubmit = ( + selectedIds: (string | number)[] + ) => { + if (selectedIds.length === 0) return; + setShowAssignDialog(true); }; - const handleUnassignActionSubmit = () => { + const handleUnassignActionSubmit = ( + selectedIds: (string | number)[] + ) => { + if (selectedIds.length === 0) return; + setShowUnassignDialog(true); }; @@ -135,6 +143,36 @@ export default function DevicesTable({ { key: "category", title: "Category" }, ], []); + const actions = useMemo(() => { + if (!multiSelect) return []; + + const baseActions: TableAction[] = [ + { + label: "Add to Cohort", + value: "assign_cohort", + handler: handleAddCohortDeviceActionSubmit, + icon: Plus, + }, + ]; + + if (selectedDeviceObjects.length > 0 && !isExternalOrg) { + baseActions.push({ + label: "Remove from Cohort", + value: "unassign_cohort", + handler: handleUnassignActionSubmit, + icon: Trash2, + }); + } + + return baseActions; + }, [ + multiSelect, + selectedDeviceObjects.length, + isExternalOrg, + handleAddCohortDeviceActionSubmit, + handleUnassignActionSubmit, + ]); + return (
setSelectedDeviceObjects(items as TableDevice[])} - actions={ - multiSelect - ? [ - { - label: "Add to Cohort", - value: "assign_cohort", - handler: handleAddCohortDeviceActionSubmit, - }, - ...(selectedDeviceObjects.length > 0 && !isExternalOrg - ? [{ - label: "Remove from Cohort", - value: "unassign_cohort", - handler: handleUnassignActionSubmit, - }] - : []) - ] - : [] - } + actions={actions} emptyState={ error ? (
diff --git a/src/vertex/components/shared/table/ReusableTable.tsx b/src/vertex/components/shared/table/ReusableTable.tsx index 7935190210..caa9e8835c 100644 --- a/src/vertex/components/shared/table/ReusableTable.tsx +++ b/src/vertex/components/shared/table/ReusableTable.tsx @@ -20,7 +20,6 @@ import { AqFilterLines, AqXClose, } from "@airqo/icons-react"; -import SelectField from "@/components/ui/select-field"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import ReusableButton from "@/components/shared/button/ReusableButton"; import { TableExportModal } from "./TableExportModal"; @@ -50,10 +49,16 @@ export type TableColumn = { export type SortingState = Array<{ id: string; desc: boolean }>; -interface TableAction { +type IconComponent = React.ComponentType<{ className?: string }>; + +type NoJSXIcon = + T extends React.ReactElement ? never : T; + +export interface TableAction { label: string; value: string; handler: (selectedIds: (string | number)[]) => void; + icon?: NoJSXIcon; } export type TableItem = { @@ -320,48 +325,41 @@ const TableHeader = ({ // --- MultiSelectActionBar Component --- interface MultiSelectActionBarProps { actions: TableAction[]; - selectedAction: string; - onActionChange: (action: string) => void; - onActionSubmit: () => void; + selectedIds: (string | number)[]; onClearSelection: () => void; } const MultiSelectActionBar: React.FC = ({ actions, - selectedAction, - onActionChange, - onActionSubmit, + selectedIds, onClearSelection, }) => { return ( -
+
- Clear selection + Clear - {actions.length > 0 && ( -
- onActionChange(String(e.target.value))} - placeholder="Select Action" - className="min-w-[12rem] text-sm" + +
+ +
+ {actions.map((action) => ( + action.handler(selectedIds)} + variant="outlined" + className="text-sm px-2 py-1" + title={action.label} + Icon={action.icon} > - {actions.map((action) => ( - - ))} - - - Apply + {action.label} -
- )} + ))} +
); }; @@ -645,7 +643,6 @@ const ReusableTable = ({ const stickyHeaderRef = useRef(null); const [selectedItems, setSelectedItems] = useState([]); - const [selectedAction, setSelectedAction] = useState(""); const [isExportModalOpen, setIsExportModalOpen] = useState(false); const headerCheckboxRef = useRef(null); @@ -1232,20 +1229,6 @@ const ReusableTable = ({ const isAnySelected = selectedItems.length > 0; - const handleActionChange = useCallback((action: string) => { - setSelectedAction(action); - }, []); - - const handleActionSubmit = useCallback(() => { - if (selectedAction && actions.length > 0) { - const action = actions.find((a) => a.value === selectedAction); - if (action && typeof action.handler === "function") { - action.handler(selectedItems.map(item => item.id)); - } - } - setSelectedAction(""); - }, [selectedAction, actions, selectedItems]); - const handleClearSelection = useCallback(() => { setSelectedItems([]); onSelectedItemsChange?.([]); @@ -1345,9 +1328,7 @@ const ReusableTable = ({ {multiSelect && isAnySelected && ( item.id)} onClearSelection={handleClearSelection} /> )} From b5d18724425f31636e448563d262184be0e006b1 Mon Sep 17 00:00:00 2001 From: Codebmk Date: Thu, 28 May 2026 18:39:08 +0300 Subject: [PATCH 02/11] Refactor device update logic to support bulk updates Change the updateDeviceGroup function to bulkUpdateDeviceDetails, allowing multiple device IDs and update data to be processed in a single API call. Update related hooks to accommodate the new bulk update functionality, enhancing efficiency and user experience. Additionally, improve error handling and success notifications in the useCreateDevice hook. --- src/vertex/core/apis/devices.ts | 25 +++++++--------- src/vertex/core/hooks/useDevices.ts | 46 +++++++++++++++++++---------- 2 files changed, 42 insertions(+), 29 deletions(-) diff --git a/src/vertex/core/apis/devices.ts b/src/vertex/core/apis/devices.ts index af8fad6118..433288f7b5 100644 --- a/src/vertex/core/apis/devices.ts +++ b/src/vertex/core/apis/devices.ts @@ -24,9 +24,8 @@ import type { ShippingBatchDetailsResponse, } from "@/app/types/devices"; -// Create secure API clients that use the proxy const jwtApiClient = createSecureApiClient(); -const tokenApiClient = createSecureApiClient(); + interface DeviceStatusSummary { _id: string; created_at: string; @@ -547,22 +546,20 @@ export const devices = { } }, - updateDeviceGroup: async ( - deviceId: string, - groupName: string + bulkUpdateDeviceDetails: async ( + deviceIds: string[], + updateData: Record ): Promise => { try { const requestBody = { - deviceIds: [deviceId], - updateData: { - groups: [groupName], - }, + deviceIds, + updateData, }; - const response = await jwtApiClient.put( - `/devices/bulk`, - requestBody, - { headers: { 'X-Auth-Type': 'JWT' } } - ); + + const response = await jwtApiClient.put(`/devices/bulk`, requestBody, { + headers: { "X-Auth-Type": "JWT" }, + }); + return response.data; } catch (error) { throw error; diff --git a/src/vertex/core/hooks/useDevices.ts b/src/vertex/core/hooks/useDevices.ts index e6f79ac893..75119cbf60 100644 --- a/src/vertex/core/hooks/useDevices.ts +++ b/src/vertex/core/hooks/useDevices.ts @@ -448,17 +448,24 @@ export const useUpdateDeviceGroup = () => { { deviceId: string; groupName: string } >({ mutationFn: ({ deviceId, groupName }) => - devices.updateDeviceGroup(deviceId, groupName), + devices.bulkUpdateDeviceDetails( + [deviceId], + { + groups: [groupName], + } + ), + onSuccess: () => { ReusableToast({ - message: 'Device has been successfully added to the group.', - type: 'SUCCESS', + message: "Device has been successfully added to the group.", + type: "SUCCESS", }); }, - onError: error => { + + onError: (error) => { ReusableToast({ message: `Group Update Failed: ${getApiErrorMessage(error)}`, - type: 'ERROR', + type: "ERROR", }); }, }); @@ -480,24 +487,33 @@ export const useCreateDevice = () => { tags?: string[]; } >({ - mutationFn: (variables) => { + mutationFn: async (variables) => { const { tags, ...rest } = variables; + const payload = { ...rest, ...(tags && tags.length > 0 && { tags }), }; + return devices.createDevice(payload); }, - onSuccess: (data) => { - if (data.created_device && activeGroup?.grp_title) { - updateDeviceGroup.mutate({ - deviceId: data.created_device._id || '', - groupName: activeGroup.grp_title, - }); + + onSuccess: async (data) => { + try { + if (data.created_device && activeGroup?.grp_title) { + await updateDeviceGroup.mutateAsync({ + deviceId: data.created_device._id || "", + groupName: activeGroup.grp_title, + }); + } + + queryClient.invalidateQueries({ queryKey: ["devices"] }); + queryClient.invalidateQueries({ queryKey: ["network-devices"] }); + queryClient.invalidateQueries({ queryKey: ["deviceActivities"] }); + } catch (error) { + // optional: toast or log + console.error("Failed to assign device to group", error); } - queryClient.invalidateQueries({ queryKey: ['devices'] }); - queryClient.invalidateQueries({ queryKey: ['network-devices'] }); - queryClient.invalidateQueries({ queryKey: ['deviceActivities'] }); }, }); }; From 145ca7ce89e48ab63a7c9c61dba9b336ed6243f9 Mon Sep 17 00:00:00 2001 From: Codebmk Date: Thu, 28 May 2026 19:22:46 +0300 Subject: [PATCH 03/11] Add bulk edit functionality to DevicesTable and implement bulk update hook Enhance the DevicesTable component by introducing a bulk edit option, allowing users to select multiple devices for editing. Implement a new useUpdateDeviceBulk hook to handle bulk updates, including success and error notifications. This update improves user experience by streamlining device management processes and ensuring efficient handling of multiple device updates. --- .../bulk-edit-device-details-modal.tsx | 216 ++++++++++++++++++ .../features/devices/device-list-table.tsx | 54 +++-- src/vertex/core/hooks/useDevices.ts | 37 +++ 3 files changed, 293 insertions(+), 14 deletions(-) create mode 100644 src/vertex/components/features/devices/bulk-edit-device-details-modal.tsx diff --git a/src/vertex/components/features/devices/bulk-edit-device-details-modal.tsx b/src/vertex/components/features/devices/bulk-edit-device-details-modal.tsx new file mode 100644 index 0000000000..27ec72de80 --- /dev/null +++ b/src/vertex/components/features/devices/bulk-edit-device-details-modal.tsx @@ -0,0 +1,216 @@ +import React, { useMemo, useState } from "react"; +import ReusableDialog from "@/components/shared/dialog/ReusableDialog"; +import ReusableButton from "@/components/shared/button/ReusableButton"; +import ReusableSelectInput from "@/components/shared/select/ReusableSelectInput"; +import ReusableInputField from "@/components/shared/inputfield/ReusableInputField"; +import { DEVICE_CATEGORIES } from "@/core/constants/devices"; +import { useUpdateDeviceBulk } from "@/core/hooks/useDevices"; + +type EditableDeviceField = + | "category" + | "network" + | "visibility" + | "authRequired" + | "tags"; + +type FieldValue = string | string[] | boolean | null; + +interface BulkEditDevicesModalProps { + open: boolean; + onClose: () => void; + deviceIds: string[]; +} + +export default function BulkEditDevicesModal({ + open, + onClose, + deviceIds, +}: BulkEditDevicesModalProps) { + const bulkUpdate = useUpdateDeviceBulk(); + + const [step, setStep] = useState<"choose_field" | "confirm">("choose_field"); + const [selectedField, setSelectedField] = useState(null); + const [value, setValue] = useState(null); + + const fieldOptions = useMemo( + () => [ + { label: "Category", value: "category" }, + { label: "Network", value: "network" }, + { label: "Visibility", value: "visibility" }, + { label: "Auth Required", value: "authRequired" }, + { label: "Tags", value: "tags" }, + ], + [] + ); + + const reset = () => { + setStep("choose_field"); + setSelectedField(null); + setValue(null); + }; + + const handleClose = () => { + reset(); + onClose(); + }; + + const handleProceed = () => { + if (!selectedField) return; + setStep("confirm"); + }; + + const handleSubmit = () => { + if (!selectedField) return; + + const updateData = { + [selectedField]: value, + }; + + bulkUpdate.mutate( + { + deviceIds, + updateData, + }, + { + onSuccess: () => { + handleClose(); + }, + } + ); + }; + + const renderFieldInput = () => { + switch (selectedField) { + case "category": + return ( + setValue(e.target.value)} + > + {DEVICE_CATEGORIES.map((c) => ( + + ))} + + ); + + case "network": + return ( + setValue(e.target.value)} + /> + ); + + case "tags": + return ( + + setValue(e.target.value.split(",").map((t) => t.trim())) + } + /> + ); + + case "visibility": + case "authRequired": + return ( + setValue(e.target.value === "true")} + > + + + + ); + + default: + return null; + } + }; + + return ( + + {step === "choose_field" && ( +
+ + setSelectedField(e.target.value as EditableDeviceField) + } + > + + {fieldOptions.map((f) => ( + + ))} + + + {selectedField && ( +
{renderFieldInput()}
+ )} + +
+ + Cancel + + + + Continue + +
+
+ )} + + {step === "confirm" && ( +
+
+

+ You are about to update: +

+

+ {selectedField} →{" "} + + {JSON.stringify(value)} + +

+

+ This will affect {deviceIds.length} devices. +

+
+ +
+ setStep("choose_field")} + > + Back + + + + Confirm Update + +
+
+ )} +
+ ); +} \ No newline at end of file diff --git a/src/vertex/components/features/devices/device-list-table.tsx b/src/vertex/components/features/devices/device-list-table.tsx index f9862cb679..737987239f 100644 --- a/src/vertex/components/features/devices/device-list-table.tsx +++ b/src/vertex/components/features/devices/device-list-table.tsx @@ -1,6 +1,6 @@ import { ExclamationTriangleIcon } from "@radix-ui/react-icons"; import { AqFilterLines } from "@airqo/icons-react"; -import { Check, Plus, Trash2, X } from "lucide-react"; +import { Check, Edit, Plus, Trash2, X } from "lucide-react"; import { DropdownMenu, DropdownMenuContent, @@ -12,13 +12,14 @@ import { Device } from "@/app/types/devices"; import { useRouter, useSearchParams } from "next/navigation"; import ReusableTable, { TableAction } from "@/components/shared/table/ReusableTable"; import ReusableButton from "@/components/shared/button/ReusableButton"; -import { useState, useMemo } from "react"; +import { useState, useMemo, useCallback } from "react"; import { AssignCohortDevicesDialog } from "@/components/features/cohorts/assign-cohort-devices"; import { useUserContext } from "@/core/hooks/useUserContext"; import { UnassignCohortDevicesDialog } from "../cohorts/unassign-cohort-devices"; import { useDevices, DeviceListingOptions } from "@/core/hooks/useDevices"; import { getColumns, type TableDevice } from "./utils/table-columns"; import { useServerSideTableState } from "@/core/hooks/useServerSideTableState"; +import BulkEditDevicesModal from "./bulk-edit-device-details-modal"; interface DevicesTableProps { itemsPerPage?: number; @@ -37,6 +38,8 @@ export default function DevicesTable({ const [selectedDeviceObjects, setSelectedDeviceObjects] = useState([]); const [showAssignDialog, setShowAssignDialog] = useState(false); const [showUnassignDialog, setShowUnassignDialog] = useState(false); + const [showBulkEditModal, setShowBulkEditModal] = useState(false); + const [bulkEditDeviceIds, setBulkEditDeviceIds] = useState([]); const { userContext, activeGroup, isExternalOrg } = useUserContext(); const isInternalView = userContext === "personal" && activeGroup?.grp_title?.toLowerCase() === "airqo"; @@ -101,20 +104,25 @@ export default function DevicesTable({ setShowUnassignDialog(false); }; - const handleAddCohortDeviceActionSubmit = ( - selectedIds: (string | number)[] - ) => { - if (selectedIds.length === 0) return; - - setShowAssignDialog(true); - }; + const handleAddCohortDeviceActionSubmit = useCallback( + (selectedIds: (string | number)[]) => { + if (!selectedIds.length) return; + setShowAssignDialog(true); + }, + [] + ); - const handleUnassignActionSubmit = ( - selectedIds: (string | number)[] - ) => { - if (selectedIds.length === 0) return; + const handleUnassignActionSubmit = useCallback( + (selectedIds: (string | number)[]) => { + if (!selectedIds.length) return; + setShowUnassignDialog(true); + }, + [] + ); - setShowUnassignDialog(true); + const handleBulkEditClose = () => { + setShowBulkEditModal(false); + setBulkEditDeviceIds([]); }; const devicesWithId: TableDevice[] = useMemo(() => { @@ -153,6 +161,18 @@ export default function DevicesTable({ handler: handleAddCohortDeviceActionSubmit, icon: Plus, }, + { + label: "Bulk Edit Devices", + value: "bulk_edit", + handler: (ids) => { + if (!ids.length) return; + + const safeIds = ids.map(String); + setBulkEditDeviceIds(safeIds); + setShowBulkEditModal(true); + }, + icon: Edit, + } ]; if (selectedDeviceObjects.length > 0 && !isExternalOrg) { @@ -299,6 +319,12 @@ export default function DevicesTable({ selectedDevices={selectedDeviceObjects} onSuccess={handleUnassignSuccess} /> + +
); } diff --git a/src/vertex/core/hooks/useDevices.ts b/src/vertex/core/hooks/useDevices.ts index 75119cbf60..08fb1d6311 100644 --- a/src/vertex/core/hooks/useDevices.ts +++ b/src/vertex/core/hooks/useDevices.ts @@ -441,6 +441,43 @@ export const useDeviceStatusFeed = (deviceNumber?: number) => { }); }; +export interface BulkDeviceUpdatePayload { + deviceIds: string[]; + updateData: Record; +} + +export const useUpdateDeviceBulk = () => { + const queryClient = useQueryClient(); + + return useMutation< + DeviceUpdateGroupResponse, + AxiosError, + BulkDeviceUpdatePayload + >({ + mutationFn: ({ deviceIds, updateData }) => + devices.bulkUpdateDeviceDetails(deviceIds, updateData), + + onSuccess: () => { + ReusableToast({ + message: "Devices updated successfully.", + type: "SUCCESS", + }); + + // invalidate all relevant caches + queryClient.invalidateQueries({ queryKey: ["devices"] }); + queryClient.invalidateQueries({ queryKey: ["network-devices"] }); + queryClient.invalidateQueries({ queryKey: ["deviceActivities"] }); + }, + + onError: (error) => { + ReusableToast({ + message: `Bulk Update Failed: ${getApiErrorMessage(error)}`, + type: "ERROR", + }); + }, + }); +}; + export const useUpdateDeviceGroup = () => { return useMutation< DeviceUpdateGroupResponse, From 0b6a26d5e7c68a015ea570d0d6beaf69055cb09e Mon Sep 17 00:00:00 2001 From: Codebmk Date: Thu, 28 May 2026 19:44:00 +0300 Subject: [PATCH 04/11] Implement bulk actions for device management in ClientPaginatedDevicesTable and NetworkDevicesTable Enhance the ClientPaginatedDevicesTable and NetworkDevicesTable components by adding bulk action capabilities, allowing users to assign devices to cohorts, unassign them, and perform bulk edits. Introduce modals for assigning and unassigning devices, improving user experience and streamlining device management processes. Update action handling logic to support multi-select functionality and ensure proper state management for selected devices. --- .../devices/my-devices/page.tsx | 1 - .../client-paginated-devices-table.tsx | 104 +++++++++++++++++- .../features/devices/device-list-table.tsx | 15 +-- .../networks/network-device-list-table.tsx | 86 +++++++++++---- 4 files changed, 172 insertions(+), 34 deletions(-) diff --git a/src/vertex/app/(authenticated)/devices/my-devices/page.tsx b/src/vertex/app/(authenticated)/devices/my-devices/page.tsx index aabd1c4044..81499a1b40 100644 --- a/src/vertex/app/(authenticated)/devices/my-devices/page.tsx +++ b/src/vertex/app/(authenticated)/devices/my-devices/page.tsx @@ -193,7 +193,6 @@ const MyDevicesPage = () => { isLoading={isLoading} error={error} multiSelect={true} - /> {/* Modals */} diff --git a/src/vertex/components/features/devices/client-paginated-devices-table.tsx b/src/vertex/components/features/devices/client-paginated-devices-table.tsx index c8923bc679..e3dd3f38ef 100644 --- a/src/vertex/components/features/devices/client-paginated-devices-table.tsx +++ b/src/vertex/components/features/devices/client-paginated-devices-table.tsx @@ -3,10 +3,14 @@ import { ExclamationTriangleIcon } from "@radix-ui/react-icons"; import { Device } from "@/app/types/devices"; import { useRouter } from "next/navigation"; -import ReusableTable from "@/components/shared/table/ReusableTable"; -import { useMemo } from "react"; +import ReusableTable, { TableAction } from "@/components/shared/table/ReusableTable"; +import { useCallback, useMemo, useState } from "react"; import { useUserContext } from "@/core/hooks/useUserContext"; import { getColumns, type TableDevice } from "./utils/table-columns"; +import { Edit, Plus, Trash2 } from "lucide-react"; +import BulkEditDevicesModal from "./bulk-edit-device-details-modal"; +import { AssignCohortDevicesDialog } from "../cohorts/assign-cohort-devices"; +import { UnassignCohortDevicesDialog } from "../cohorts/unassign-cohort-devices"; interface ClientPaginatedDevicesTableProps { devices: Device[]; @@ -33,6 +37,43 @@ export default function ClientPaginatedDevicesTable({ const { userContext, activeGroup } = useUserContext(); const isInternalView = userContext === "personal" && activeGroup?.grp_title?.toLowerCase() === "airqo"; + const [selectedDeviceObjects, setSelectedDeviceObjects] = useState([]); + const [showAssignDialog, setShowAssignDialog] = useState(false); + const [showUnassignDialog, setShowUnassignDialog] = useState(false); + const [showBulkEditModal, setShowBulkEditModal] = useState(false); + const [bulkEditDeviceIds, setBulkEditDeviceIds] = useState([]); + + const handleAssignSuccess = () => { + setSelectedDeviceObjects([]); + setShowAssignDialog(false); + }; + + const handleUnassignSuccess = () => { + setSelectedDeviceObjects([]); + setShowUnassignDialog(false); + }; + + const handleAddCohortDeviceActionSubmit = useCallback( + (selectedIds: (string | number)[]) => { + if (!selectedIds.length) return; + setShowAssignDialog(true); + }, + [] + ); + + const handleUnassignActionSubmit = useCallback( + (selectedIds: (string | number)[]) => { + if (!selectedIds.length) return; + setShowUnassignDialog(true); + }, + [] + ); + + const handleBulkEditClose = () => { + setShowBulkEditModal(false); + setBulkEditDeviceIds([]); + }; + const handleDeviceClick = (item: unknown) => { const device = item as Device; if (onDeviceClick) onDeviceClick(device); @@ -58,6 +99,43 @@ export default function ClientPaginatedDevicesTable({ ); }, [isInternalView, hiddenColumns]); + const actions = useMemo(() => { + if (!multiSelect) return []; + + const baseActions: TableAction[] = [ + { + label: "Add to Cohort", + value: "assign_cohort", + handler: handleAddCohortDeviceActionSubmit, + icon: Plus, + }, + { + label: "Bulk Edit Devices", + value: "bulk_edit", + handler: (ids) => { + if (!ids.length) return; + + const safeIds = ids.map(String); + setBulkEditDeviceIds(safeIds); + setShowBulkEditModal(true); + }, + icon: Edit, + }, + { + label: "Remove from Cohort", + value: "unassign_cohort", + handler: handleUnassignActionSubmit, + icon: Trash2, + } + ]; + + return baseActions; + }, [ + multiSelect, + handleAddCohortDeviceActionSubmit, + handleUnassignActionSubmit, + ]); + return (
setSelectedDeviceObjects(items as TableDevice[])} + actions={actions} emptyState={ error ? (
@@ -82,6 +162,26 @@ export default function ClientPaginatedDevicesTable({ searchable searchableColumns={["long_name", "name", "description", "site.name"]} /> + {/* Assign to Cohort Dialog */} + + + {/* Unassign from Cohort Dialog */} + +
); } \ No newline at end of file diff --git a/src/vertex/components/features/devices/device-list-table.tsx b/src/vertex/components/features/devices/device-list-table.tsx index 737987239f..124294d4e8 100644 --- a/src/vertex/components/features/devices/device-list-table.tsx +++ b/src/vertex/components/features/devices/device-list-table.tsx @@ -40,7 +40,7 @@ export default function DevicesTable({ const [showUnassignDialog, setShowUnassignDialog] = useState(false); const [showBulkEditModal, setShowBulkEditModal] = useState(false); const [bulkEditDeviceIds, setBulkEditDeviceIds] = useState([]); - const { userContext, activeGroup, isExternalOrg } = useUserContext(); + const { userContext, activeGroup } = useUserContext(); const isInternalView = userContext === "personal" && activeGroup?.grp_title?.toLowerCase() === "airqo"; const searchParams = useSearchParams(); @@ -172,23 +172,18 @@ export default function DevicesTable({ setShowBulkEditModal(true); }, icon: Edit, - } - ]; - - if (selectedDeviceObjects.length > 0 && !isExternalOrg) { - baseActions.push({ + }, + { label: "Remove from Cohort", value: "unassign_cohort", handler: handleUnassignActionSubmit, icon: Trash2, - }); - } + } + ]; return baseActions; }, [ multiSelect, - selectedDeviceObjects.length, - isExternalOrg, handleAddCohortDeviceActionSubmit, handleUnassignActionSubmit, ]); diff --git a/src/vertex/components/features/networks/network-device-list-table.tsx b/src/vertex/components/features/networks/network-device-list-table.tsx index 200e23a042..c760b77a43 100644 --- a/src/vertex/components/features/networks/network-device-list-table.tsx +++ b/src/vertex/components/features/networks/network-device-list-table.tsx @@ -1,14 +1,16 @@ import { Device } from "@/app/types/devices"; import { useRouter, useSearchParams } from "next/navigation"; -import ReusableTable from "@/components/shared/table/ReusableTable"; -import { useState, useMemo } from "react"; +import ReusableTable, { TableAction } from "@/components/shared/table/ReusableTable"; +import { useState, useMemo, useCallback } from "react"; import { useNetworkDevices } from "@/core/hooks/useNetworks"; import { getColumns, type TableDevice } from "@/components/features/devices/utils/table-columns"; import { useServerSideTableState } from "@/core/hooks/useServerSideTableState"; import { AssignCohortDevicesDialog } from "@/components/features/cohorts/assign-cohort-devices"; import { UnassignCohortDevicesDialog } from "@/components/features/cohorts/unassign-cohort-devices"; import { ExclamationTriangleIcon } from "@radix-ui/react-icons"; +import { Edit, Plus, Trash2 } from "lucide-react"; +import BulkEditDevicesModal from "../devices/bulk-edit-device-details-modal"; interface NetworkDevicesTableProps { networkName: string; @@ -29,6 +31,8 @@ export default function NetworkDevicesTable({ const [selectedDeviceObjects, setSelectedDeviceObjects] = useState([]); const [showAssignDialog, setShowAssignDialog] = useState(false); const [showUnassignDialog, setShowUnassignDialog] = useState(false); + const [showBulkEditModal, setShowBulkEditModal] = useState(false); + const [bulkEditDeviceIds, setBulkEditDeviceIds] = useState([]); const { pagination, @@ -70,12 +74,25 @@ export default function NetworkDevicesTable({ setShowUnassignDialog(false); }; - const handleAddCohortDeviceActionSubmit = () => { - setShowAssignDialog(true); - }; + const handleAddCohortDeviceActionSubmit = useCallback( + (selectedIds: (string | number)[]) => { + if (!selectedIds.length) return; + setShowAssignDialog(true); + }, + [] + ); + + const handleUnassignActionSubmit = useCallback( + (selectedIds: (string | number)[]) => { + if (!selectedIds.length) return; + setShowUnassignDialog(true); + }, + [] + ); - const handleUnassignActionSubmit = () => { - setShowUnassignDialog(true); + const handleBulkEditClose = () => { + setShowBulkEditModal(false); + setBulkEditDeviceIds([]); }; const devicesWithId: TableDevice[] = useMemo(() => { @@ -92,6 +109,40 @@ export default function NetworkDevicesTable({ const columns = useMemo(() => getColumns(false), []); + const actions = useMemo(() => { + const baseActions: TableAction[] = [ + { + label: "Add to Cohort", + value: "assign_cohort", + handler: handleAddCohortDeviceActionSubmit, + icon: Plus, + }, + { + label: "Bulk Edit Devices", + value: "bulk_edit", + handler: (ids) => { + if (!ids.length) return; + + const safeIds = ids.map(String); + setBulkEditDeviceIds(safeIds); + setShowBulkEditModal(true); + }, + icon: Edit, + }, + { + label: "Remove from Cohort", + value: "unassign_cohort", + handler: handleUnassignActionSubmit, + icon: Trash2, + } + ]; + + return baseActions; + }, [ + handleAddCohortDeviceActionSubmit, + handleUnassignActionSubmit, + ]); + return (
setSelectedDeviceObjects(items as TableDevice[])} - actions={[ - { - label: "Add to Cohort", - value: "assign_cohort", - handler: handleAddCohortDeviceActionSubmit, - }, - ...(selectedDeviceObjects.length > 0 - ? [{ - label: "Remove from Cohort", - value: "unassign_cohort", - handler: handleUnassignActionSubmit, - }] - : []) - ]} + actions={actions} emptyState={ error ? (
@@ -156,6 +194,12 @@ export default function NetworkDevicesTable({ selectedDevices={selectedDeviceObjects} onSuccess={handleUnassignSuccess} /> + +
); } From ebbaf62d61f5aae80ef74d45841c92c57e6db7fe Mon Sep 17 00:00:00 2001 From: Codebmk Date: Thu, 28 May 2026 19:48:09 +0300 Subject: [PATCH 05/11] Update changelog.md --- src/vertex/app/changelog.md | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/src/vertex/app/changelog.md b/src/vertex/app/changelog.md index 6b349f70d2..5f328b3cc0 100644 --- a/src/vertex/app/changelog.md +++ b/src/vertex/app/changelog.md @@ -2,6 +2,40 @@ > **Note**: This changelog consolidates all recent improvements, features, and fixes to the AirQo Vertex frontend. +--- + +## Version 1.23.55 +**Released:** May 28, 2026 + +### Bulk Edit & Multi-Select Actions for Devices + +Added comprehensive bulk editing capabilities across device management tables, enabling users to efficiently update multiple devices at once. This includes multi-select functionality, a new bulk edit modal, and backend support for bulk updates. + +
+Changes (4) + +- **Bulk Edit Modal**: Implemented `BulkEditDevicesModal` allowing users to update fields like Category, Network, Visibility, Auth Required, and Tags across multiple selected devices in a two-step flow (field selection → confirmation). +- **Multi-Select Support**: Enhanced `ReusableTable`, `DevicesTable`, `ClientPaginatedDevicesTable`, and `NetworkDevicesTable` with multi-select functionality and bulk action handling. +- **Bulk Update Hook & API**: Refactored device update logic and added `useUpdateDeviceBulk` hook to support efficient bulk operations in a single API call with proper success/error notifications. +- **UI Integration**: Integrated bulk edit action into device list tables and improved action handling for better user experience. + +
+ +
+Files Updated (8) + +- `src/vertex/components/features/devices/bulk-edit-device-details-modal.tsx` [NEW] +- `src/vertex/components/features/devices/device-list-table.tsx` [MODIFIED] +- `src/vertex/core/hooks/useDevices.ts` [MODIFIED] +- `src/vertex/core/apis/devices.ts` [MODIFIED] +- `src/vertex/app/(authenticated)/devices/my-devices/page.tsx` [MODIFIED] +- `src/vertex/components/features/devices/client-paginated-devices-table.tsx` [MODIFIED] +- `src/vertex/components/shared/table/ReusableTable.tsx` [MODIFIED] +- `src/vertex/components/features/networks/network-device-list-table.tsx` [MODIFIED] + +
+ + --- ## Version 1.23.54 From a0776b10898505ed8a84e5e07245a9e745dd72bb Mon Sep 17 00:00:00 2001 From: Codebmk Date: Thu, 28 May 2026 19:55:14 +0300 Subject: [PATCH 06/11] Refactor BulkEditDevicesModal for improved UI and functionality Enhance the BulkEditDevicesModal component by streamlining the submission logic and updating the dialog structure. Simplify the updateData object creation and improve the handling of the primary and secondary actions. Adjust the layout and spacing for better user experience, ensuring clear visibility of the selected field and confirmation details. This update also includes minor adjustments to input value handling for consistency. --- .../bulk-edit-device-details-modal.tsx | 95 ++++++++----------- 1 file changed, 37 insertions(+), 58 deletions(-) diff --git a/src/vertex/components/features/devices/bulk-edit-device-details-modal.tsx b/src/vertex/components/features/devices/bulk-edit-device-details-modal.tsx index 27ec72de80..85119f8f28 100644 --- a/src/vertex/components/features/devices/bulk-edit-device-details-modal.tsx +++ b/src/vertex/components/features/devices/bulk-edit-device-details-modal.tsx @@ -1,6 +1,5 @@ import React, { useMemo, useState } from "react"; import ReusableDialog from "@/components/shared/dialog/ReusableDialog"; -import ReusableButton from "@/components/shared/button/ReusableButton"; import ReusableSelectInput from "@/components/shared/select/ReusableSelectInput"; import ReusableInputField from "@/components/shared/inputfield/ReusableInputField"; import { DEVICE_CATEGORIES } from "@/core/constants/devices"; @@ -62,19 +61,12 @@ export default function BulkEditDevicesModal({ const handleSubmit = () => { if (!selectedField) return; - const updateData = { - [selectedField]: value, - }; + const updateData = { [selectedField]: value }; bulkUpdate.mutate( + { deviceIds, updateData }, { - deviceIds, - updateData, - }, - { - onSuccess: () => { - handleClose(); - }, + onSuccess: () => handleClose(), } ); }; @@ -85,7 +77,7 @@ export default function BulkEditDevicesModal({ return ( setValue(e.target.value)} > {DEVICE_CATEGORIES.map((c) => ( @@ -100,7 +92,7 @@ export default function BulkEditDevicesModal({ return ( setValue(e.target.value)} /> ); @@ -121,12 +113,12 @@ export default function BulkEditDevicesModal({ return ( setValue(e.target.value === "true")} - > - - - + > + + + ); default: @@ -139,10 +131,28 @@ export default function BulkEditDevicesModal({ isOpen={open} onClose={handleClose} title="Bulk Edit Devices" - className="w-[600px]" + size="lg" + // Primary & Secondary Actions + secondaryAction={ + step === "choose_field" + ? { + label: "Cancel", + onClick: handleClose, + } + : { + label: "Back", + onClick: () => setStep("choose_field"), + } + } + primaryAction={{ + label: step === "choose_field" ? "Continue" : "Confirm Update", + onClick: step === "choose_field" ? handleProceed : handleSubmit, + disabled: step === "choose_field" ? !selectedField : bulkUpdate.isPending, + className: step === "confirm" ? "min-w-[140px]" : undefined, + }} > {step === "choose_field" && ( -
+
{renderFieldInput()}
)} - -
- - Cancel - - - - Continue - -
)} {step === "confirm" && ( -
-
-

- You are about to update: -

-

+

+
+

You are about to update:

+

{selectedField} →{" "} - + {JSON.stringify(value)}

-

- This will affect {deviceIds.length} devices. +

+ This will affect {deviceIds.length} devices.

- -
- setStep("choose_field")} - > - Back - - - - Confirm Update - -
)} From 3bac97b679e98ddf5bbe6f094328283528f43b02 Mon Sep 17 00:00:00 2001 From: Codebmk Date: Thu, 28 May 2026 20:07:59 +0300 Subject: [PATCH 07/11] Guard against empty deviceIds before mutating. --- .../features/devices/bulk-edit-device-details-modal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vertex/components/features/devices/bulk-edit-device-details-modal.tsx b/src/vertex/components/features/devices/bulk-edit-device-details-modal.tsx index 85119f8f28..ac34287891 100644 --- a/src/vertex/components/features/devices/bulk-edit-device-details-modal.tsx +++ b/src/vertex/components/features/devices/bulk-edit-device-details-modal.tsx @@ -59,7 +59,7 @@ export default function BulkEditDevicesModal({ }; const handleSubmit = () => { - if (!selectedField) return; + if (!selectedField || deviceIds.length === 0) return; const updateData = { [selectedField]: value }; From f5a1940394ad58185bdff23b50b5e6eefec75d71 Mon Sep 17 00:00:00 2001 From: Codebmk Date: Thu, 28 May 2026 20:14:49 +0300 Subject: [PATCH 08/11] Enhance BulkEditDevicesModal validation and submission logic Refactor the BulkEditDevicesModal component to include validation for selected fields and their corresponding values before proceeding with updates. Introduce a new `hasValidValue` check to ensure that the input meets the required criteria for 'network' and 'tags'. Update the submission logic to prevent actions when validation fails, improving user experience and data integrity. --- .../bulk-edit-device-details-modal.tsx | 60 +++++++++---------- 1 file changed, 30 insertions(+), 30 deletions(-) diff --git a/src/vertex/components/features/devices/bulk-edit-device-details-modal.tsx b/src/vertex/components/features/devices/bulk-edit-device-details-modal.tsx index ac34287891..0e56c06ce4 100644 --- a/src/vertex/components/features/devices/bulk-edit-device-details-modal.tsx +++ b/src/vertex/components/features/devices/bulk-edit-device-details-modal.tsx @@ -35,13 +35,26 @@ export default function BulkEditDevicesModal({ () => [ { label: "Category", value: "category" }, { label: "Network", value: "network" }, - { label: "Visibility", value: "visibility" }, { label: "Auth Required", value: "authRequired" }, - { label: "Tags", value: "tags" }, ], [] ); + const hasValidValue = useMemo(() => { + if (!selectedField) return false; + if (value === null) return false; + + if (selectedField === "network") { + return typeof value === "string" && value.trim().length > 0; + } + + if (selectedField === "tags") { + return Array.isArray(value); + } + + return true; + }, [selectedField, value]); + const reset = () => { setStep("choose_field"); setSelectedField(null); @@ -54,12 +67,12 @@ export default function BulkEditDevicesModal({ }; const handleProceed = () => { - if (!selectedField) return; + if (!selectedField || !hasValidValue) return; setStep("confirm"); }; const handleSubmit = () => { - if (!selectedField || deviceIds.length === 0) return; + if (!selectedField || !hasValidValue || deviceIds.length === 0) return; const updateData = { [selectedField]: value }; @@ -97,22 +110,11 @@ export default function BulkEditDevicesModal({ /> ); - case "tags": - return ( - - setValue(e.target.value.split(",").map((t) => t.trim())) - } - /> - ); - - case "visibility": + case "authRequired": return ( setValue(e.target.value === "true")} > @@ -132,22 +134,18 @@ export default function BulkEditDevicesModal({ onClose={handleClose} title="Bulk Edit Devices" size="lg" - // Primary & Secondary Actions secondaryAction={ step === "choose_field" - ? { - label: "Cancel", - onClick: handleClose, - } - : { - label: "Back", - onClick: () => setStep("choose_field"), - } + ? { label: "Cancel", onClick: handleClose } + : { label: "Back", onClick: () => setStep("choose_field") } } primaryAction={{ label: step === "choose_field" ? "Continue" : "Confirm Update", onClick: step === "choose_field" ? handleProceed : handleSubmit, - disabled: step === "choose_field" ? !selectedField : bulkUpdate.isPending, + disabled: + step === "choose_field" + ? !selectedField || !hasValidValue + : bulkUpdate.isPending || !hasValidValue, className: step === "confirm" ? "min-w-[140px]" : undefined, }} > @@ -156,9 +154,11 @@ export default function BulkEditDevicesModal({ - setSelectedField(e.target.value as EditableDeviceField) - } + onChange={(e) => { + const newField = e.target.value as EditableDeviceField; + setSelectedField(newField); + setValue(null); + }} > {fieldOptions.map((f) => ( From 96a31ae1ad2e898303650ef010b2b0f91c311dcf Mon Sep 17 00:00:00 2001 From: Codebmk Date: Thu, 28 May 2026 20:16:19 +0300 Subject: [PATCH 09/11] Validate device IDs before processing updates in devices.ts Sanitize device IDs by trimming whitespace and filtering out empty values. Introduce an error throw if no valid device IDs are provided, ensuring that at least one device ID is required for the update operation. This change enhances input validation and prevents unnecessary API calls. --- src/vertex/core/apis/devices.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/vertex/core/apis/devices.ts b/src/vertex/core/apis/devices.ts index 433288f7b5..ff6eaa7db5 100644 --- a/src/vertex/core/apis/devices.ts +++ b/src/vertex/core/apis/devices.ts @@ -551,8 +551,13 @@ export const devices = { updateData: Record ): Promise => { try { + const sanitizedDeviceIds = deviceIds.map((id) => id.trim()).filter(Boolean); + if (sanitizedDeviceIds.length === 0) { + throw new Error("At least one device ID is required."); + } + const requestBody = { - deviceIds, + deviceIds: sanitizedDeviceIds, updateData, }; From 20537efd5e59c00985a37bd8ebb14233dc2da9ed Mon Sep 17 00:00:00 2001 From: Codebmk Date: Thu, 28 May 2026 20:17:25 +0300 Subject: [PATCH 10/11] Add cache invalidation for myDevices in useUpdateDeviceBulk hook Enhance the useUpdateDeviceBulk hook by adding cache invalidation for the myDevices query key alongside existing cache invalidations. This ensures that the device list remains up-to-date after bulk updates, improving data consistency and user experience. --- src/vertex/core/hooks/useDevices.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/vertex/core/hooks/useDevices.ts b/src/vertex/core/hooks/useDevices.ts index 08fb1d6311..9847714e2e 100644 --- a/src/vertex/core/hooks/useDevices.ts +++ b/src/vertex/core/hooks/useDevices.ts @@ -465,6 +465,7 @@ export const useUpdateDeviceBulk = () => { // invalidate all relevant caches queryClient.invalidateQueries({ queryKey: ["devices"] }); + queryClient.invalidateQueries({ queryKey: ["myDevices"] }); queryClient.invalidateQueries({ queryKey: ["network-devices"] }); queryClient.invalidateQueries({ queryKey: ["deviceActivities"] }); }, From 608164dec52cdb23aeb31f011d2c7fef5d2bf8a7 Mon Sep 17 00:00:00 2001 From: Codebmk Date: Thu, 28 May 2026 20:23:49 +0300 Subject: [PATCH 11/11] Improve error handling and refactor device assignment logic in useCreateDevice hook Refactor the useCreateDevice hook to enhance error handling when assigning newly created devices to groups. Introduce a more robust check for created device IDs and log errors appropriately. This change improves reliability and provides clearer feedback in case of failures during the device assignment process. --- src/vertex/core/hooks/useDevices.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/vertex/core/hooks/useDevices.ts b/src/vertex/core/hooks/useDevices.ts index 9847714e2e..8e80a8da75 100644 --- a/src/vertex/core/hooks/useDevices.ts +++ b/src/vertex/core/hooks/useDevices.ts @@ -538,19 +538,20 @@ export const useCreateDevice = () => { onSuccess: async (data) => { try { - if (data.created_device && activeGroup?.grp_title) { + const createdDeviceId = data.created_device?._id; + + if (createdDeviceId && activeGroup?.grp_title) { await updateDeviceGroup.mutateAsync({ - deviceId: data.created_device._id || "", + deviceId: createdDeviceId, groupName: activeGroup.grp_title, }); } - + } catch (error) { + logger.error("Failed to assign device to group", getApiErrorMessage(error)); + } finally { queryClient.invalidateQueries({ queryKey: ["devices"] }); queryClient.invalidateQueries({ queryKey: ["network-devices"] }); queryClient.invalidateQueries({ queryKey: ["deviceActivities"] }); - } catch (error) { - // optional: toast or log - console.error("Failed to assign device to group", error); } }, });