diff --git a/src/vertex/app/(authenticated)/devices/my-devices/page.tsx b/src/vertex/app/(authenticated)/devices/my-devices/page.tsx index 91d9c1bde2..81499a1b40 100644 --- a/src/vertex/app/(authenticated)/devices/my-devices/page.tsx +++ b/src/vertex/app/(authenticated)/devices/my-devices/page.tsx @@ -192,6 +192,7 @@ const MyDevicesPage = () => { devices={filteredDevices} isLoading={isLoading} error={error} + multiSelect={true} /> {/* Modals */} 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 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..0e56c06ce4 --- /dev/null +++ b/src/vertex/components/features/devices/bulk-edit-device-details-modal.tsx @@ -0,0 +1,195 @@ +import React, { useMemo, useState } from "react"; +import ReusableDialog from "@/components/shared/dialog/ReusableDialog"; +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: "Auth Required", value: "authRequired" }, + ], + [] + ); + + 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); + setValue(null); + }; + + const handleClose = () => { + reset(); + onClose(); + }; + + const handleProceed = () => { + if (!selectedField || !hasValidValue) return; + setStep("confirm"); + }; + + const handleSubmit = () => { + if (!selectedField || !hasValidValue || deviceIds.length === 0) 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 "authRequired": + return ( + setValue(e.target.value === "true")} + > + + + + ); + + default: + return null; + } + }; + + return ( + setStep("choose_field") } + } + primaryAction={{ + label: step === "choose_field" ? "Continue" : "Confirm Update", + onClick: step === "choose_field" ? handleProceed : handleSubmit, + disabled: + step === "choose_field" + ? !selectedField || !hasValidValue + : bulkUpdate.isPending || !hasValidValue, + className: step === "confirm" ? "min-w-[140px]" : undefined, + }} + > + {step === "choose_field" && ( +
+ { + const newField = e.target.value as EditableDeviceField; + setSelectedField(newField); + setValue(null); + }} + > + + {fieldOptions.map((f) => ( + + ))} + + + {selectedField && ( +
{renderFieldInput()}
+ )} +
+ )} + + {step === "confirm" && ( +
+
+

You are about to update:

+

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

+

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

+
+
+ )} +
+ ); +} \ No newline at end of file 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 60061b6231..124294d4e8 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, Edit, Plus, Trash2, X } from "lucide-react"; import { DropdownMenu, DropdownMenuContent, @@ -10,15 +10,16 @@ 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 { 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,7 +38,9 @@ export default function DevicesTable({ const [selectedDeviceObjects, setSelectedDeviceObjects] = useState([]); const [showAssignDialog, setShowAssignDialog] = useState(false); const [showUnassignDialog, setShowUnassignDialog] = useState(false); - const { userContext, activeGroup, isExternalOrg } = useUserContext(); + const [showBulkEditModal, setShowBulkEditModal] = useState(false); + const [bulkEditDeviceIds, setBulkEditDeviceIds] = useState([]); + const { userContext, activeGroup } = useUserContext(); const isInternalView = userContext === "personal" && activeGroup?.grp_title?.toLowerCase() === "airqo"; const searchParams = useSearchParams(); @@ -101,12 +104,25 @@ export default function DevicesTable({ 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(() => { @@ -135,6 +151,43 @@ 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, + }, + { + 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={ - 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 ? (
@@ -278,6 +314,12 @@ export default function DevicesTable({ selectedDevices={selectedDeviceObjects} onSuccess={handleUnassignSuccess} /> + +
); } 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} /> + +
); } 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} /> )} diff --git a/src/vertex/core/apis/devices.ts b/src/vertex/core/apis/devices.ts index af8fad6118..ff6eaa7db5 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,25 @@ export const devices = { } }, - updateDeviceGroup: async ( - deviceId: string, - groupName: string + bulkUpdateDeviceDetails: async ( + deviceIds: string[], + 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: [deviceId], - updateData: { - groups: [groupName], - }, + deviceIds: sanitizedDeviceIds, + 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..8e80a8da75 100644 --- a/src/vertex/core/hooks/useDevices.ts +++ b/src/vertex/core/hooks/useDevices.ts @@ -441,6 +441,44 @@ 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: ["myDevices"] }); + 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, @@ -448,17 +486,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 +525,34 @@ 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 { + const createdDeviceId = data.created_device?._id; + + if (createdDeviceId && activeGroup?.grp_title) { + await updateDeviceGroup.mutateAsync({ + 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"] }); } - queryClient.invalidateQueries({ queryKey: ['devices'] }); - queryClient.invalidateQueries({ queryKey: ['network-devices'] }); - queryClient.invalidateQueries({ queryKey: ['deviceActivities'] }); }, }); };