From 03a3592eaf8d828c9744db28f95d0f16403a41a4 Mon Sep 17 00:00:00 2001 From: BwanikaRobert Date: Wed, 3 Jun 2026 20:49:32 +0300 Subject: [PATCH 1/7] refactor(devices): decouple useDevices mutations from ReusableToast --- .../admin/shipping/[batchId]/page.tsx | 64 +++---- .../bulk-edit-device-details-modal.tsx | 21 ++- .../shipping/PrepareShippingModal.tsx | 35 ++-- src/vertex/core/hooks/useDevices.ts | 172 ++++++++---------- 4 files changed, 131 insertions(+), 161 deletions(-) diff --git a/src/vertex/app/(authenticated)/admin/shipping/[batchId]/page.tsx b/src/vertex/app/(authenticated)/admin/shipping/[batchId]/page.tsx index 32b37da61e..f63017e579 100644 --- a/src/vertex/app/(authenticated)/admin/shipping/[batchId]/page.tsx +++ b/src/vertex/app/(authenticated)/admin/shipping/[batchId]/page.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { useParams, useRouter } from 'next/navigation'; -import { useShippingBatchDetails } from '@/core/hooks/useDevices'; +import { useShippingBatchDetails, useGenerateShippingLabels } from '@/core/hooks/useDevices'; import { format } from 'date-fns'; import { ArrowLeft } from 'lucide-react'; import ReusableTable, { TableColumn } from '@/components/shared/table/ReusableTable'; @@ -10,8 +10,8 @@ import { ShippingStatusDevice } from '@/app/types/devices'; import { Skeleton } from "@/components/ui/skeleton"; import ReusableButton from '@/components/shared/button/ReusableButton'; import { AqArrowLeft } from '@airqo/icons-react'; -import { useGenerateShippingLabels } from '@/core/hooks/useDevices'; -import ReusableToast from '@/components/shared/toast/ReusableToast'; +import { useBanner } from '@/context/banner-context'; +import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'; import ShippingLabelPrintModal from '@/components/features/shipping/ShippingLabelPrintModal'; import { useQueryClient } from '@tanstack/react-query'; import { useState, useCallback } from 'react'; @@ -29,7 +29,18 @@ const BatchDetailsPage = () => { const queryClient = useQueryClient(); const batchId = params.batchId as string; const { data, isLoading, error } = useShippingBatchDetails(batchId); - const { mutate: generateLabels, isPending: isGenerating, data: labelsData } = useGenerateShippingLabels(); + const { showBanner } = useBanner(); + const { mutate: generateLabels, isPending: isGenerating, data: labelsData } = useGenerateShippingLabels({ + onSuccess: (data) => { + if (data.success) { + showBanner({ severity: 'success', message: `Successfully generated ${data.shipping_labels.labels.length} shipping label(s)`, scoped: false }); + setShowLabelModal(true); + } + }, + onError: (error) => { + showBanner({ severity: 'error', message: `Label Generation Failed: ${getApiErrorMessage(error)}`, scoped: false }); + }, + }); const [showLabelModal, setShowLabelModal] = useState(false); usePageTitle({ title: data?.batch?.batch_name || "Shipping Batch", @@ -38,10 +49,7 @@ const BatchDetailsPage = () => { const handleGenerateLabels = useCallback((ids: (string | number)[]) => { if (!ids || ids.length === 0) { - ReusableToast({ - message: 'Please select at least one device', - type: 'ERROR', - }); + showBanner({ severity: 'error', message: 'Please select at least one device', scoped: false }); return; } @@ -51,26 +59,12 @@ const BatchDetailsPage = () => { .filter(name => name && name.trim().length > 0); if (selectedDeviceNames.length === 0) { - ReusableToast({ - message: 'Selected devices have no valid names', - type: 'ERROR', - }); + showBanner({ severity: 'error', message: 'Selected devices have no valid names', scoped: false }); return; } - generateLabels(selectedDeviceNames, { - onSuccess: (data) => { - if (data.success) { - ReusableToast({ - message: `Successfully generated ${data.shipping_labels.labels.length} shipping label(s)`, - type: 'SUCCESS', - }); - - setShowLabelModal(true); - } - } - }); - }, [generateLabels, data?.batch?.devices]); + generateLabels(selectedDeviceNames); + }, [generateLabels, data?.batch?.devices, showBanner]); const handleGenerateAllLabels = useCallback(() => { const allDeviceNames = (data?.batch?.devices || []) @@ -79,26 +73,12 @@ const BatchDetailsPage = () => { .filter(name => name && name.trim().length > 0); if (allDeviceNames.length === 0) { - ReusableToast({ - message: 'No unclaimed devices found with valid names in this batch', - type: 'ERROR', - }); + showBanner({ severity: 'error', message: 'No unclaimed devices found with valid names in this batch', scoped: false }); return; } - generateLabels(allDeviceNames, { - onSuccess: (data) => { - if (data.success) { - ReusableToast({ - message: `Successfully generated ${data.shipping_labels.labels.length} shipping label(s)`, - type: 'SUCCESS', - }); - - setShowLabelModal(true); - } - } - }); - }, [generateLabels, data?.batch?.devices]); + generateLabels(allDeviceNames); + }, [generateLabels, data?.batch?.devices, showBanner]); const handleCloseModal = useCallback(() => { setShowLabelModal(false); 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 0e56c06ce4..788e0fb253 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 @@ -4,6 +4,9 @@ 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"; +import { useBanner } from "@/context/banner-context"; +import { useBannerWithDelay } from "@/core/hooks/useBannerWithDelay"; +import { getApiErrorMessage } from "@/core/utils/getApiErrorMessage"; type EditableDeviceField = | "category" @@ -25,7 +28,16 @@ export default function BulkEditDevicesModal({ onClose, deviceIds, }: BulkEditDevicesModalProps) { - const bulkUpdate = useUpdateDeviceBulk(); + const { showBanner } = useBanner(); + const { showBannerWithDelay } = useBannerWithDelay(); + const bulkUpdate = useUpdateDeviceBulk({ + onSuccess: () => { + showBannerWithDelay({ severity: 'success', message: 'Devices updated successfully.', scoped: false }); + }, + onError: (error) => { + showBanner({ severity: 'error', message: `Bulk update failed: ${getApiErrorMessage(error)}`, scoped: true }); + }, + }); const [step, setStep] = useState<"choose_field" | "confirm">("choose_field"); const [selectedField, setSelectedField] = useState(null); @@ -76,12 +88,7 @@ export default function BulkEditDevicesModal({ const updateData = { [selectedField]: value }; - bulkUpdate.mutate( - { deviceIds, updateData }, - { - onSuccess: () => handleClose(), - } - ); + bulkUpdate.mutate({ deviceIds, updateData }, { onSuccess: () => handleClose() }); }; const renderFieldInput = () => { diff --git a/src/vertex/components/features/shipping/PrepareShippingModal.tsx b/src/vertex/components/features/shipping/PrepareShippingModal.tsx index ce1d75bdc4..6914c50ee4 100644 --- a/src/vertex/components/features/shipping/PrepareShippingModal.tsx +++ b/src/vertex/components/features/shipping/PrepareShippingModal.tsx @@ -4,9 +4,11 @@ import React, { useState, useRef } from 'react'; import { usePrepareBulkDevicesForShipping } from '@/core/hooks/useDevices'; import ReusableInputField from '@/components/shared/inputfield/ReusableInputField'; import ReusableButton from '@/components/shared/button/ReusableButton'; -import ReusableToast from '@/components/shared/toast/ReusableToast'; import { AqPlus, AqXClose, AqUploadCloud02 } from '@airqo/icons-react'; import ReusableDialog from '@/components/shared/dialog/ReusableDialog'; +import { useBanner } from '@/context/banner-context'; +import { useBannerWithDelay } from '@/core/hooks/useBannerWithDelay'; +import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'; interface PrepareShippingModalProps { isOpen: boolean; @@ -22,13 +24,22 @@ export const PrepareShippingModal: React.FC = ({ isOp const [filePreview, setFilePreview] = useState<{ headers: string[], data: unknown[][], fileName: string } | null>(null); const [selectedColumn, setSelectedColumn] = useState(0); const fileInputRef = useRef(null); - const { mutate: prepareBulk, isPending } = usePrepareBulkDevicesForShipping(); + const { showBanner } = useBanner(); + const { showBannerWithDelay } = useBannerWithDelay(); + const { mutate: prepareBulk, isPending } = usePrepareBulkDevicesForShipping({ + onSuccess: (data) => { + showBannerWithDelay({ severity: 'success', message: data.message, scoped: false }); + }, + onError: (error) => { + showBanner({ severity: 'error', message: `Bulk Preparation Failed: ${getApiErrorMessage(error)}`, scoped: true }); + }, + }); const handleAddDevice = () => { const trimmedInput = currentInput.trim(); if (!trimmedInput) return; if (devices.includes(trimmedInput)) { - ReusableToast({ message: 'Device already added', type: 'ERROR' }); + showBanner({ severity: 'error', message: 'Device already added', scoped: true }); return; } setDevices([...devices, trimmedInput]); @@ -56,13 +67,13 @@ export const PrepareShippingModal: React.FC = ({ isOp const fileExtension = file.name.split('.').pop()?.toLowerCase(); if (!['csv', 'xlsx', 'xls'].includes(fileExtension || '')) { - ReusableToast({ message: 'Invalid file format. Please upload a CSV or Excel file.', type: 'ERROR' }); + showBanner({ severity: 'error', message: 'Invalid file format. Please upload a CSV or Excel file.', scoped: true }); e.target.value = ''; return; } if (file.size > 5 * 1024 * 1024) { - ReusableToast({ message: 'File too large. Maximum size is 5MB.', type: 'ERROR' }); + showBanner({ severity: 'error', message: 'File too large. Maximum size is 5MB.', scoped: true }); e.target.value = ''; return; } @@ -90,7 +101,7 @@ export const PrepareShippingModal: React.FC = ({ isOp setIsImporting(false); }, error: (error) => { - ReusableToast({ message: `Error parsing CSV: ${error.message}`, type: 'ERROR' }); + showBanner({ severity: 'error', message: `Error parsing CSV: ${error.message}`, scoped: true }); setIsImporting(false); }, }); @@ -114,13 +125,13 @@ export const PrepareShippingModal: React.FC = ({ isOp setIsImporting(false); }; reader.onerror = () => { - ReusableToast({ message: 'Error reading Excel file', type: 'ERROR' }); + showBanner({ severity: 'error', message: 'Error reading Excel file', scoped: true }); setIsImporting(false); }; reader.readAsBinaryString(file); } } catch { - ReusableToast({ message: 'Error importing file. Please ensure papaparse and xlsx libraries are installed.', type: 'ERROR' }); + showBanner({ severity: 'error', message: 'Error importing file. Please ensure papaparse and xlsx libraries are installed.', scoped: true }); setIsImporting(false); } @@ -146,7 +157,7 @@ export const PrepareShippingModal: React.FC = ({ isOp const processImportedDevices = (importedDevices: string[]) => { if (importedDevices.length === 0) { - ReusableToast({ message: 'No valid device names found in the selected column', type: 'ERROR' }); + showBanner({ severity: 'error', message: 'No valid device names found in the selected column', scoped: true }); return; } @@ -159,16 +170,16 @@ export const PrepareShippingModal: React.FC = ({ isOp if (duplicateCount > 0) { message += ` (${duplicateCount} duplicate${duplicateCount !== 1 ? 's' : ''} skipped)`; } - ReusableToast({ message, type: 'SUCCESS' }); + showBanner({ severity: 'success', message, scoped: true }); } else if (duplicateCount > 0) { - ReusableToast({ message: 'All devices in the file are already added', type: 'INFO' }); + showBanner({ severity: 'info', message: 'All devices in the file are already added', scoped: true }); } }; const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); if (devices.length === 0) { - ReusableToast({ message: 'Please add at least one device', type: 'ERROR' }); + showBanner({ severity: 'error', message: 'Please add at least one device', scoped: true }); return; } prepareBulk( diff --git a/src/vertex/core/hooks/useDevices.ts b/src/vertex/core/hooks/useDevices.ts index 8e80a8da75..6164388754 100644 --- a/src/vertex/core/hooks/useDevices.ts +++ b/src/vertex/core/hooks/useDevices.ts @@ -43,7 +43,6 @@ import type { } from '@/app/types/devices'; import { AxiosError } from 'axios'; import { useDispatch } from 'react-redux'; -import ReusableToast from '@/components/shared/toast/ReusableToast'; import { getApiErrorMessage } from '../utils/getApiErrorMessage'; import logger from '@/lib/logger'; @@ -272,7 +271,12 @@ export const useDeviceAvailability = (deviceName: string) => { }); }; -export const useClaimDevice = () => { +interface UseClaimDeviceOptions { + onSuccess?: () => void; + onError?: (error: AxiosError) => void; +} + +export const useClaimDevice = (options?: UseClaimDeviceOptions) => { const queryClient = useQueryClient(); return useMutation< @@ -282,23 +286,22 @@ export const useClaimDevice = () => { >({ mutationFn: devices.claimDevice, onSuccess: () => { - ReusableToast({ - message: 'Device Claimed Successfully!', - type: 'SUCCESS', - }); queryClient.invalidateQueries({ queryKey: ['myDevices'] }); queryClient.invalidateQueries({ queryKey: ['devices'] }); + options?.onSuccess?.(); }, - onError: error => { - ReusableToast({ - message: `Claim Failed: ${getApiErrorMessage(error)}`, - type: 'ERROR', - }); + onError: (error) => { + options?.onError?.(error); }, }); }; -export const useBulkClaimDevices = () => { +interface UseBulkClaimDevicesOptions { + onSuccess?: (data: BulkDeviceClaimResponse) => void; + onError?: (error: AxiosError) => void; +} + +export const useBulkClaimDevices = (options?: UseBulkClaimDevicesOptions) => { const queryClient = useQueryClient(); return useMutation< @@ -307,36 +310,13 @@ export const useBulkClaimDevices = () => { BulkDeviceClaimRequest >({ mutationFn: devices.claimDevicesBulk, - onSuccess: (response) => { - const { successful_claims, failed_claims } = response.data; - const successful_count = successful_claims.length; - const failed_count = failed_claims.length; - - if (failed_count === 0) { - ReusableToast({ - message: `All ${successful_count} device${successful_count !== 1 ? 's' : ''} claimed successfully!`, - type: 'SUCCESS', - }); - } else if (successful_count === 0) { - ReusableToast({ - message: `Failed to claim all ${failed_count} device${failed_count !== 1 ? 's' : ''}`, - type: 'ERROR', - }); - } else { - ReusableToast({ - message: `${successful_count} device${successful_count !== 1 ? 's' : ''} claimed, ${failed_count} failed`, - type: 'WARNING', - }); - } - + onSuccess: (data) => { queryClient.invalidateQueries({ queryKey: ['myDevices'] }); queryClient.invalidateQueries({ queryKey: ['devices'] }); + options?.onSuccess?.(data); }, - onError: error => { - ReusableToast({ - message: `Bulk Claim Failed: ${getApiErrorMessage(error)}`, - type: 'ERROR', - }); + onError: (error) => { + options?.onError?.(error); }, }); }; @@ -357,7 +337,12 @@ export const useAssignDeviceToOrganization = () => { }); }; -export const useUnassignDeviceFromOrganization = () => { +interface UseUnassignDeviceFromOrganizationOptions { + onSuccess?: (data: DeviceAssignmentResponse) => void; + onError?: (error: AxiosError) => void; +} + +export const useUnassignDeviceFromOrganization = (options?: UseUnassignDeviceFromOrganizationOptions) => { const queryClient = useQueryClient(); return useMutation< @@ -367,19 +352,13 @@ export const useUnassignDeviceFromOrganization = () => { >({ mutationFn: ({ deviceName, userId }) => devices.unassignDeviceFromOrganization(deviceName, userId), - onSuccess: data => { - ReusableToast({ - message: `${data.device.name} is now personal only.`, - type: 'SUCCESS', - }); + onSuccess: (data) => { queryClient.invalidateQueries({ queryKey: ['myDevices'] }); queryClient.invalidateQueries({ queryKey: ['devices'] }); + options?.onSuccess?.(data); }, - onError: error => { - ReusableToast({ - message: `Unassignment Failed: ${getApiErrorMessage(error)}`, - type: 'ERROR', - }); + onError: (error) => { + options?.onError?.(error); }, }); }; @@ -446,7 +425,12 @@ export interface BulkDeviceUpdatePayload { updateData: Record; } -export const useUpdateDeviceBulk = () => { +interface UseUpdateDeviceBulkOptions { + onSuccess?: () => void; + onError?: (error: AxiosError) => void; +} + +export const useUpdateDeviceBulk = (options?: UseUpdateDeviceBulkOptions) => { const queryClient = useQueryClient(); return useMutation< @@ -458,53 +442,39 @@ export const useUpdateDeviceBulk = () => { 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"] }); + options?.onSuccess?.(); }, onError: (error) => { - ReusableToast({ - message: `Bulk Update Failed: ${getApiErrorMessage(error)}`, - type: "ERROR", - }); + options?.onError?.(error); }, }); }; -export const useUpdateDeviceGroup = () => { +interface UseUpdateDeviceGroupOptions { + onSuccess?: () => void; + onError?: (error: AxiosError) => void; +} + +export const useUpdateDeviceGroup = (options?: UseUpdateDeviceGroupOptions) => { return useMutation< DeviceUpdateGroupResponse, AxiosError, { deviceId: string; groupName: string } >({ mutationFn: ({ deviceId, groupName }) => - devices.bulkUpdateDeviceDetails( - [deviceId], - { - groups: [groupName], - } - ), + devices.bulkUpdateDeviceDetails([deviceId], { groups: [groupName] }), onSuccess: () => { - ReusableToast({ - message: "Device has been successfully added to the group.", - type: "SUCCESS", - }); + options?.onSuccess?.(); }, onError: (error) => { - ReusableToast({ - message: `Group Update Failed: ${getApiErrorMessage(error)}`, - type: "ERROR", - }); + options?.onError?.(error); }, }); }; @@ -740,7 +710,12 @@ export const useDecryptDeviceKeys = () => { }); }; -export const usePrepareDeviceForShipping = () => { +interface UsePrepareDeviceForShippingOptions { + onSuccess?: (data: PrepareDeviceResponse) => void; + onError?: (error: AxiosError) => void; +} + +export const usePrepareDeviceForShipping = (options?: UsePrepareDeviceForShippingOptions) => { const queryClient = useQueryClient(); return useMutation< @@ -751,22 +726,21 @@ export const usePrepareDeviceForShipping = () => { mutationFn: ({ deviceName, tokenType }) => devices.prepareDeviceForShipping(deviceName, tokenType), onSuccess: (data) => { - ReusableToast({ - message: data.message, - type: 'SUCCESS', - }); queryClient.invalidateQueries({ queryKey: ['shippingStatus'] }); + options?.onSuccess?.(data); }, onError: (error) => { - ReusableToast({ - message: `Preparation Failed: ${getApiErrorMessage(error)}`, - type: 'ERROR', - }); + options?.onError?.(error); }, }); }; -export const usePrepareBulkDevicesForShipping = () => { +interface UsePrepareBulkDevicesForShippingOptions { + onSuccess?: (data: BulkPrepareResponse) => void; + onError?: (error: AxiosError) => void; +} + +export const usePrepareBulkDevicesForShipping = (options?: UsePrepareBulkDevicesForShippingOptions) => { const queryClient = useQueryClient(); return useMutation< @@ -777,34 +751,32 @@ export const usePrepareBulkDevicesForShipping = () => { mutationFn: ({ deviceNames, tokenType, batchName }) => devices.prepareBulkDevicesForShipping(deviceNames, tokenType, batchName), onSuccess: (data) => { - ReusableToast({ - message: data.message, - type: 'SUCCESS', - }); - // queryClient.invalidateQueries({ queryKey: ['shippingStatus'] }); queryClient.invalidateQueries({ queryKey: ['shippingBatches'] }); + options?.onSuccess?.(data); }, onError: (error) => { - ReusableToast({ - message: `Bulk Preparation Failed: ${getApiErrorMessage(error)}`, - type: 'ERROR', - }); + options?.onError?.(error); }, }); }; -export const useGenerateShippingLabels = () => { +interface UseGenerateShippingLabelsOptions { + onSuccess?: (data: GenerateLabelsResponse) => void; + onError?: (error: AxiosError) => void; +} + +export const useGenerateShippingLabels = (options?: UseGenerateShippingLabelsOptions) => { return useMutation< GenerateLabelsResponse, AxiosError, string[] >({ mutationFn: (deviceNames) => devices.generateShippingLabels(deviceNames), + onSuccess: (data) => { + options?.onSuccess?.(data); + }, onError: (error) => { - ReusableToast({ - message: `Label Generation Failed: ${getApiErrorMessage(error)}`, - type: 'ERROR', - }); + options?.onError?.(error); }, }); }; From b345d574cde304cc9db908953bf9f2eb6bbc40f8 Mon Sep 17 00:00:00 2001 From: BwanikaRobert Date: Wed, 3 Jun 2026 21:26:49 +0300 Subject: [PATCH 2/7] Add auseClipboard to reduce the duplication of the copying functionality across components --- src/vertex/core/hooks/useClipboard.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 src/vertex/core/hooks/useClipboard.ts diff --git a/src/vertex/core/hooks/useClipboard.ts b/src/vertex/core/hooks/useClipboard.ts new file mode 100644 index 0000000000..f5789d386a --- /dev/null +++ b/src/vertex/core/hooks/useClipboard.ts @@ -0,0 +1,16 @@ +import { useBanner } from '@/context/banner-context'; + +export const useClipboard = () => { + const { showBanner } = useBanner(); + + const handleCopy = async (text: string) => { + try { + await navigator.clipboard.writeText(text); + showBanner({ severity: 'success', message: 'Copied', scoped: false }); + } catch { + showBanner({ severity: 'error', message: 'Failed to copy', scoped: false }); + } + }; + + return { handleCopy }; +}; From 55641b409b85f591d0278c38095064da7c5feb37 Mon Sep 17 00:00:00 2001 From: BwanikaRobert Date: Wed, 3 Jun 2026 21:29:49 +0300 Subject: [PATCH 3/7] Change the remaining device modules to use the info banner --- .../features/devices/device-activity-item.tsx | 7 ++++--- .../devices/device-measurements-api-card.tsx | 14 +++++--------- .../features/devices/orphaned-devices-alert.tsx | 8 +++----- 3 files changed, 12 insertions(+), 17 deletions(-) diff --git a/src/vertex/components/features/devices/device-activity-item.tsx b/src/vertex/components/features/devices/device-activity-item.tsx index 9f5db793a7..f4c1e5231b 100644 --- a/src/vertex/components/features/devices/device-activity-item.tsx +++ b/src/vertex/components/features/devices/device-activity-item.tsx @@ -2,8 +2,8 @@ import React, { useState } from "react"; import { format, parseISO, isValid } from "date-fns"; import { AqCopy01, AqChevronDown, AqChevronUp, AqMonitor } from "@airqo/icons-react"; import { DeviceActivity } from "@/core/apis/devices"; -import ReusableToast from "@/components/shared/toast/ReusableToast"; import ReusableButton from "@/components/shared/button/ReusableButton"; +import { useBanner } from "@/context/banner-context"; interface DeviceActivityItemProps { activity: DeviceActivity; @@ -28,6 +28,7 @@ const DeviceActivityItem: React.FC = ({ (typeof activity.description === "string" && /recalled/i.test(activity.description)); const [isPreviousSiteOpen, setIsPreviousSiteOpen] = useState(false); + const { showBanner } = useBanner(); return (
@@ -110,9 +111,9 @@ const DeviceActivityItem: React.FC = ({ onClick={async () => { try { await navigator.clipboard.writeText(previousSiteId); - ReusableToast({ message: "Copied", type: "SUCCESS" }); + showBanner({ severity: 'success', message: 'Copied', scoped: false }); } catch { - ReusableToast({ message: "Failed to copy", type: "ERROR" }); + showBanner({ severity: 'error', message: 'Failed to copy', scoped: false }); } }} className="p-1" diff --git a/src/vertex/components/features/devices/device-measurements-api-card.tsx b/src/vertex/components/features/devices/device-measurements-api-card.tsx index 4c32f3d6ac..b1507d4c99 100644 --- a/src/vertex/components/features/devices/device-measurements-api-card.tsx +++ b/src/vertex/components/features/devices/device-measurements-api-card.tsx @@ -2,13 +2,15 @@ import { Card } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Copy } from "lucide-react"; import React from "react"; -import ReusableToast from "@/components/shared/toast/ReusableToast"; +import { useClipboard } from "@/core/hooks/useClipboard"; interface DeviceMeasurementsApiCardProps { deviceId: string; } const DeviceMeasurementsApiCard: React.FC = ({ deviceId }) => { + const { handleCopy } = useClipboard(); + return (

Device Measurements API

@@ -23,10 +25,7 @@ const DeviceMeasurementsApiCard: React.FC = ({ d variant="ghost" size="icon" className="hover:bg-transparent" - onClick={() => { - navigator.clipboard.writeText(`https://api.airqo.net/api/v2/devices/measurements/devices/${deviceId}/recent?token=YOUR_TOKEN`); - ReusableToast({ message: "Copied", type: "SUCCESS" }); - }} + onClick={() => handleCopy(`https://api.airqo.net/api/v2/devices/measurements/devices/${deviceId}/recent?token=YOUR_TOKEN`)} > @@ -43,10 +42,7 @@ const DeviceMeasurementsApiCard: React.FC = ({ d variant="ghost" size="icon" className="hover:bg-transparent" - onClick={() => { - navigator.clipboard.writeText(`https://api.airqo.net/api/v2/devices/measurements/devices/${deviceId}/historical?token=YOUR_TOKEN`); - ReusableToast({ message: "Copied", type: "SUCCESS" }); - }} + onClick={() => handleCopy(`https://api.airqo.net/api/v2/devices/measurements/devices/${deviceId}/historical?token=YOUR_TOKEN`)} > diff --git a/src/vertex/components/features/devices/orphaned-devices-alert.tsx b/src/vertex/components/features/devices/orphaned-devices-alert.tsx index 21a15734c6..9dc1ec6623 100644 --- a/src/vertex/components/features/devices/orphaned-devices-alert.tsx +++ b/src/vertex/components/features/devices/orphaned-devices-alert.tsx @@ -4,7 +4,7 @@ import { AlertTriangle } from 'lucide-react'; import ReusableButton from '@/components/shared/button/ReusableButton'; import { useAssignDevicesToCohort } from '@/core/hooks/useCohorts'; import { useQueryClient } from '@tanstack/react-query'; -import ReusableToast from '@/components/shared/toast/ReusableToast'; +import { useBanner } from '@/context/banner-context'; import { useAppSelector } from '@/core/redux/hooks'; import { useOrphanedDevices } from '@/core/hooks/useDevices'; @@ -17,6 +17,7 @@ export const OrphanedDevicesAlert: React.FC = ({ user const { userDetails } = useAppSelector((state) => state.user); const { mutate: assignDevices, isPending: isAssigning } = useAssignDevicesToCohort(); const queryClient = useQueryClient(); + const { showBanner } = useBanner(); if (isLoading || !data || !data.devices || data.devices.length === 0) { return null; @@ -26,10 +27,7 @@ export const OrphanedDevicesAlert: React.FC = ({ user const cohortId = userDetails?.cohort_ids?.[0]; if (!cohortId) { - ReusableToast({ - message: "An issue occurred while setting up your device. Contact support.", - type: "ERROR" - }); + showBanner({ severity: 'error', message: 'An issue occurred while setting up your device. Contact support.', scoped: false }); return; } From 5b56ee3db8cbbcdf8e3c2289e7fac569a527adc8 Mon Sep 17 00:00:00 2001 From: BwanikaRobert Date: Wed, 3 Jun 2026 22:11:37 +0300 Subject: [PATCH 4/7] Add query invalidating to useUpdateDeviceGroup --- src/vertex/core/hooks/useDevices.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/vertex/core/hooks/useDevices.ts b/src/vertex/core/hooks/useDevices.ts index a058db5b54..7e2c3c70d5 100644 --- a/src/vertex/core/hooks/useDevices.ts +++ b/src/vertex/core/hooks/useDevices.ts @@ -482,6 +482,7 @@ interface UseUpdateDeviceGroupOptions { } export const useUpdateDeviceGroup = (options?: UseUpdateDeviceGroupOptions) => { + const queryClient = useQueryClient(); return useMutation< DeviceUpdateGroupResponse, AxiosError, @@ -491,7 +492,12 @@ export const useUpdateDeviceGroup = (options?: UseUpdateDeviceGroupOptions) => { devices.bulkUpdateDeviceDetails([deviceId], { groups: [groupName] }), onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["devices"] }); + queryClient.invalidateQueries({ queryKey: ["myDevices"] }); + queryClient.invalidateQueries({ queryKey: ["network-devices"] }); + queryClient.invalidateQueries({ queryKey: ["deviceActivities"] }); options?.onSuccess?.(); + }, onError: (error) => { From a916c6eae301f67c1de98debb17980024847630f Mon Sep 17 00:00:00 2001 From: BwanikaRobert Date: Wed, 3 Jun 2026 22:31:25 +0300 Subject: [PATCH 5/7] refactor replace the hardcoded handle copy function with the useClipboard hook --- .../cohorts/cohort-measurements-api-card.tsx | 13 ++----------- .../features/devices/device-activity-item.tsx | 11 +++-------- .../features/grids/admin-levels-modal.tsx | 18 ++---------------- .../features/grids/grid-details-card.tsx | 14 ++------------ .../grids/grid-measurements-api-card.tsx | 14 ++------------ src/vertex/core/hooks/useClipboard.ts | 17 ++++++++++++++--- 6 files changed, 25 insertions(+), 62 deletions(-) diff --git a/src/vertex/components/features/cohorts/cohort-measurements-api-card.tsx b/src/vertex/components/features/cohorts/cohort-measurements-api-card.tsx index 17cdd0ab59..d158e0e304 100644 --- a/src/vertex/components/features/cohorts/cohort-measurements-api-card.tsx +++ b/src/vertex/components/features/cohorts/cohort-measurements-api-card.tsx @@ -4,23 +4,14 @@ import { Card } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Copy } from "lucide-react"; import React from "react"; -import { useBanner } from "@/context/banner-context"; +import { useClipboard } from "@/core/hooks/useClipboard"; interface CohortMeasurementsApiCardProps { cohortId: string; } const CohortMeasurementsApiCard: React.FC = ({ cohortId }) => { - const { showBanner } = useBanner(); - - const handleCopy = async (text: string) => { - try { - await navigator.clipboard.writeText(text); - showBanner({ severity: 'success', message: 'API URL copied to clipboard', scoped: false }); - } catch { - showBanner({ severity: 'error', message: 'Failed to copy to clipboard', scoped: false }); - } - }; + const { handleCopy } = useClipboard({ successMessage: 'API URL copied to clipboard', errorMessage: 'Failed to copy to clipboard' }); return ( diff --git a/src/vertex/components/features/devices/device-activity-item.tsx b/src/vertex/components/features/devices/device-activity-item.tsx index f4c1e5231b..9295660050 100644 --- a/src/vertex/components/features/devices/device-activity-item.tsx +++ b/src/vertex/components/features/devices/device-activity-item.tsx @@ -3,7 +3,7 @@ import { format, parseISO, isValid } from "date-fns"; import { AqCopy01, AqChevronDown, AqChevronUp, AqMonitor } from "@airqo/icons-react"; import { DeviceActivity } from "@/core/apis/devices"; import ReusableButton from "@/components/shared/button/ReusableButton"; -import { useBanner } from "@/context/banner-context"; +import { useClipboard } from "@/core/hooks/useClipboard"; interface DeviceActivityItemProps { activity: DeviceActivity; @@ -28,7 +28,7 @@ const DeviceActivityItem: React.FC = ({ (typeof activity.description === "string" && /recalled/i.test(activity.description)); const [isPreviousSiteOpen, setIsPreviousSiteOpen] = useState(false); - const { showBanner } = useBanner(); + const { handleCopy } = useClipboard(); return (
@@ -109,12 +109,7 @@ const DeviceActivityItem: React.FC = ({ { - try { - await navigator.clipboard.writeText(previousSiteId); - showBanner({ severity: 'success', message: 'Copied', scoped: false }); - } catch { - showBanner({ severity: 'error', message: 'Failed to copy', scoped: false }); - } + handleCopy(previousSiteId); }} className="p-1" Icon={AqCopy01} diff --git a/src/vertex/components/features/grids/admin-levels-modal.tsx b/src/vertex/components/features/grids/admin-levels-modal.tsx index 2ddf49fec4..143e1263be 100644 --- a/src/vertex/components/features/grids/admin-levels-modal.tsx +++ b/src/vertex/components/features/grids/admin-levels-modal.tsx @@ -7,6 +7,7 @@ import ReusableButton from "@/components/shared/button/ReusableButton"; import ReusableInputField from "@/components/shared/inputfield/ReusableInputField"; import { Copy, Edit2, Check, X } from "lucide-react"; import { useBanner } from "@/context/banner-context"; +import { useClipboard } from "@/core/hooks/useClipboard"; import { getApiErrorMessage } from "@/core/utils/getApiErrorMessage"; interface AdminLevelsModalProps { @@ -40,22 +41,7 @@ export function AdminLevelsModal({ isOpen, onClose }: AdminLevelsModalProps) { } }); - const handleCopyId = async (id: string) => { - try { - await navigator.clipboard.writeText(id); - showBanner({ - message: "ID copied to clipboard", - severity: "success", - scoped: true, - }); - } catch { - showBanner({ - message: "Failed to copy ID", - severity: "error", - scoped: true, - }); - } - }; + const { handleCopy: handleCopyId } = useClipboard({ successMessage: 'ID copied to clipboard', errorMessage: 'Failed to copy ID', scoped: true }); const startEditing = (id: string, name: string) => { setEditingId(id); diff --git a/src/vertex/components/features/grids/grid-details-card.tsx b/src/vertex/components/features/grids/grid-details-card.tsx index 2ff938ac8a..7a4eca2b1b 100644 --- a/src/vertex/components/features/grids/grid-details-card.tsx +++ b/src/vertex/components/features/grids/grid-details-card.tsx @@ -6,7 +6,7 @@ import { Loader2 } from "lucide-react"; import ReusableButton from "@/components/shared/button/ReusableButton"; import { AqCopy01, AqEdit01 } from "@airqo/icons-react"; import { Grid } from "@/app/types/grids"; -import { useBanner } from "@/context/banner-context"; +import { useClipboard } from "@/core/hooks/useClipboard"; interface GridDetailsCardProps { grid: Grid; @@ -15,21 +15,11 @@ interface GridDetailsCardProps { } const GridDetailsCard: React.FC = ({ grid, onEdit, loading }) => { - const { showBanner } = useBanner(); - if (loading) { return ; } - const handleCopy = async (text: string) => { - if (!text) return; - try { - await navigator.clipboard.writeText(text); - showBanner({ message: "Copied to clipboard", severity: "success", scoped: false }); - } catch { - showBanner({ message: "Failed to copy to clipboard", severity: "error", scoped: false }); - } - }; + const { handleCopy } = useClipboard({ successMessage: 'Copied to clipboard', errorMessage: 'Failed to copy to clipboard' }); return ( diff --git a/src/vertex/components/features/grids/grid-measurements-api-card.tsx b/src/vertex/components/features/grids/grid-measurements-api-card.tsx index 435ce88c90..209f8d087d 100644 --- a/src/vertex/components/features/grids/grid-measurements-api-card.tsx +++ b/src/vertex/components/features/grids/grid-measurements-api-card.tsx @@ -5,7 +5,7 @@ import { Loader2 } from "lucide-react"; import ReusableButton from "@/components/shared/button/ReusableButton"; import { AqCopy01 } from "@airqo/icons-react"; import { Grid } from "@/app/types/grids"; -import { useBanner } from "@/context/banner-context"; +import { useClipboard } from "@/core/hooks/useClipboard"; interface GridMeasurementsApiCardProps { grid: Grid; @@ -13,21 +13,11 @@ interface GridMeasurementsApiCardProps { } const GridMeasurementsApiCard: React.FC = ({ grid, loading }) => { - const { showBanner } = useBanner(); - if (loading) { return ; } - const handleCopy = async (text: string) => { - if (!text) return; - try { - await navigator.clipboard.writeText(text); - showBanner({ message: "API URL copied!", severity: "success", scoped: false }); - } catch { - showBanner({ message: "Failed to copy to clipboard", severity: "error", scoped: false }); - } - }; + const { handleCopy } = useClipboard({ successMessage: 'API URL copied!', errorMessage: 'Failed to copy to clipboard' }); const apiBase = (process.env.NEXT_PUBLIC_API_BASE_URL || "https://api.airqo.net").replace(/\/$/, ""); const recentApiUrl = `${apiBase}/api/v2/devices/measurements/${grid._id}`; diff --git a/src/vertex/core/hooks/useClipboard.ts b/src/vertex/core/hooks/useClipboard.ts index f5789d386a..82a1624e15 100644 --- a/src/vertex/core/hooks/useClipboard.ts +++ b/src/vertex/core/hooks/useClipboard.ts @@ -1,14 +1,25 @@ import { useBanner } from '@/context/banner-context'; -export const useClipboard = () => { +interface UseClipboardOptions { + successMessage?: string; + errorMessage?: string; + scoped?: boolean; +} + +export const useClipboard = (options?: UseClipboardOptions) => { const { showBanner } = useBanner(); + const { + successMessage = 'Copied', + errorMessage = 'Failed to copy', + scoped = false, + } = options ?? {}; const handleCopy = async (text: string) => { try { await navigator.clipboard.writeText(text); - showBanner({ severity: 'success', message: 'Copied', scoped: false }); + showBanner({ severity: 'success', message: successMessage, scoped }); } catch { - showBanner({ severity: 'error', message: 'Failed to copy', scoped: false }); + showBanner({ severity: 'error', message: errorMessage, scoped }); } }; From 56068c4bc9a1326c884c343c075b39c244a2639e Mon Sep 17 00:00:00 2001 From: BwanikaRobert Date: Thu, 4 Jun 2026 12:01:29 +0300 Subject: [PATCH 6/7] update changelog.md --- src/vertex/app/changelog.md | 38 +++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/src/vertex/app/changelog.md b/src/vertex/app/changelog.md index 298969e18c..27e81b9064 100644 --- a/src/vertex/app/changelog.md +++ b/src/vertex/app/changelog.md @@ -4,6 +4,44 @@ --- +## Version 1.23.59 +**Released:** June 04, 2026 + +### Decouple useDevices Mutations from ReusableToast + +Removed all hardcoded `ReusableToast` calls from mutation hooks in `useDevices.ts` and delegated notification responsibility to the calling UI layer via optional `onSuccess`/`onError` callback interfaces. Also migrated remaining device-related components still using `ReusableToast` directly, and extracted a shared `useClipboard` hook to eliminate the repeated clipboard copy pattern across the codebase. + +
+Changes (5) + +- **Hooks Decoupled**: Removed `ReusableToast` from 8 mutation hooks — `useClaimDevice`, `useBulkClaimDevices`, `useUnassignDeviceFromOrganization`, `useUpdateDeviceBulk`, `useUpdateDeviceGroup`, `usePrepareDeviceForShipping`, `usePrepareBulkDevicesForShipping`, `useGenerateShippingLabels` — and added optional `onSuccess`/`onError` callback interfaces so the UI layer controls notification scope. +- **Bulk Edit Modal**: Wired `useUpdateDeviceBulk` hook-level callbacks; error uses `scoped: true` inline in the dialog, success uses `showBannerWithDelay` (`scoped: false`) after the dialog closes. +- **Prepare Shipping Modal**: Replaced all 8 `ReusableToast` calls with `useBanner`; file/import validation errors use `scoped: true`, bulk preparation success uses `showBannerWithDelay` (`scoped: false`). +- **Remaining Device Components**: Migrated `device-measurements-api-card.tsx`, `device-activity-item.tsx`, and `orphaned-devices-alert.tsx` from `ReusableToast` to `useBanner`. Migrated `shipping/[batchId]/page.tsx` with hook-level callbacks and `showBanner` (`scoped: false`). +- **Shared `useClipboard` Hook**: Extracted a `useClipboard` hook (accepts optional `successMessage`, `errorMessage`, `scoped`) to replace the repeated `navigator.clipboard.writeText` + banner pattern duplicated across `cohort-measurements-api-card`, `grid-measurements-api-card`, `grid-details-card`, `admin-levels-modal`, and `device-activity-item`. + +
+ +
+Files Updated (12) + +- `src/vertex/core/hooks/useDevices.ts` [MODIFIED] +- `src/vertex/core/hooks/useClipboard.ts` [NEW] +- `src/vertex/components/features/devices/bulk-edit-device-details-modal.tsx` [MODIFIED] +- `src/vertex/components/features/devices/device-measurements-api-card.tsx` [MODIFIED] +- `src/vertex/components/features/devices/device-activity-item.tsx` [MODIFIED] +- `src/vertex/components/features/devices/orphaned-devices-alert.tsx` [MODIFIED] +- `src/vertex/components/features/shipping/PrepareShippingModal.tsx` [MODIFIED] +- `src/vertex/app/(authenticated)/admin/shipping/[batchId]/page.tsx` [MODIFIED] +- `src/vertex/components/features/cohorts/cohort-measurements-api-card.tsx` [MODIFIED] +- `src/vertex/components/features/grids/grid-measurements-api-card.tsx` [MODIFIED] +- `src/vertex/components/features/grids/grid-details-card.tsx` [MODIFIED] +- `src/vertex/components/features/grids/admin-levels-modal.tsx` [MODIFIED] + +
+ +--- + ## Version 1.23.58 **Released:** June 03, 2026 From 27cbb160516620b4ce4549107523b27c63314772 Mon Sep 17 00:00:00 2001 From: BwanikaRobert Date: Thu, 4 Jun 2026 12:43:01 +0300 Subject: [PATCH 7/7] Remove conditional hook initialisation --- src/vertex/components/features/grids/grid-details-card.tsx | 4 ++-- .../components/features/grids/grid-measurements-api-card.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/vertex/components/features/grids/grid-details-card.tsx b/src/vertex/components/features/grids/grid-details-card.tsx index 7a4eca2b1b..0498eea523 100644 --- a/src/vertex/components/features/grids/grid-details-card.tsx +++ b/src/vertex/components/features/grids/grid-details-card.tsx @@ -15,12 +15,12 @@ interface GridDetailsCardProps { } const GridDetailsCard: React.FC = ({ grid, onEdit, loading }) => { + const { handleCopy } = useClipboard({ successMessage: 'Copied to clipboard', errorMessage: 'Failed to copy to clipboard' }); + if (loading) { return ; } - const { handleCopy } = useClipboard({ successMessage: 'Copied to clipboard', errorMessage: 'Failed to copy to clipboard' }); - return (
diff --git a/src/vertex/components/features/grids/grid-measurements-api-card.tsx b/src/vertex/components/features/grids/grid-measurements-api-card.tsx index 209f8d087d..90b736178d 100644 --- a/src/vertex/components/features/grids/grid-measurements-api-card.tsx +++ b/src/vertex/components/features/grids/grid-measurements-api-card.tsx @@ -13,12 +13,12 @@ interface GridMeasurementsApiCardProps { } const GridMeasurementsApiCard: React.FC = ({ grid, loading }) => { + const { handleCopy } = useClipboard({ successMessage: 'API URL copied!', errorMessage: 'Failed to copy to clipboard' }); + if (loading) { return ; } - const { handleCopy } = useClipboard({ successMessage: 'API URL copied!', errorMessage: 'Failed to copy to clipboard' }); - const apiBase = (process.env.NEXT_PUBLIC_API_BASE_URL || "https://api.airqo.net").replace(/\/$/, ""); const recentApiUrl = `${apiBase}/api/v2/devices/measurements/${grid._id}`; const historicalApiUrl = `${apiBase}/api/v2/devices/measurements/grids/${grid._id}`;