diff --git a/src/vertex/app/changelog.md b/src/vertex/app/changelog.md index 484f671d68..6761cfbe60 100644 --- a/src/vertex/app/changelog.md +++ b/src/vertex/app/changelog.md @@ -4,6 +4,38 @@ --- +## Version 1.23.49 +**Released:** May 26, 2026 + +### Grid Management Banner Migration + +Migrated all toast-based notifications in the Grid Management module to the centralized `InfoBanner` system powered by `useBanner`. Since most grid actions occur inside dialogs, banner scoping was applied to keep errors inline in the active dialog while deferring success banners until the dialog has closed. + +
+Grid Management Banner Migration (4) + +- **Dialog Error Feedback**: `create-grid.tsx`, `edit-grid-details-dialog.tsx`, and `admin-levels-modal.tsx` now use `scoped: true` so validation and action errors remain visible inside the active dialog without closing it. +- **Post-Dialog Success Feedback**: `create-grid.tsx` and `edit-grid-details-dialog.tsx` now use `scoped: false` with a `setTimeout(100ms)` to allow the dialog to unmount before showing the global success banner. + - **Admin Level Error Feedback Migrated**: `create-admin-level.tsx` now routes existing error feedback through the centralized banner flow instead of the previous toast-based mechanism. +- **Copy Feedback Hardened**: `grid-details-card.tsx` and `grid-measurements-api-card.tsx` now use `scoped: false` and handle async copy failures with a fallback error message. + +
+ +
+Files Updated (7) + +- `src/vertex/components/features/grids/create-grid.tsx` [MODIFIED] +- `src/vertex/components/features/grids/edit-grid-details-dialog.tsx` [MODIFIED] +- `src/vertex/components/features/grids/admin-levels-modal.tsx` [MODIFIED] +- `src/vertex/components/features/grids/create-admin-level.tsx` [MODIFIED] +- `src/vertex/components/features/grids/grid-details-card.tsx` [MODIFIED] +- `src/vertex/components/features/grids/grid-measurements-api-card.tsx` [MODIFIED] +- `src/vertex/core/hooks/useGrids.ts` [MODIFIED] + +
+ +--- + ## Version 1.23.48 **Released:** May 23, 2026 diff --git a/src/vertex/components/features/grids/admin-levels-modal.tsx b/src/vertex/components/features/grids/admin-levels-modal.tsx index 76b04456f7..2ddf49fec4 100644 --- a/src/vertex/components/features/grids/admin-levels-modal.tsx +++ b/src/vertex/components/features/grids/admin-levels-modal.tsx @@ -6,7 +6,8 @@ import ReusableDialog from "@/components/shared/dialog/ReusableDialog"; import ReusableButton from "@/components/shared/button/ReusableButton"; import ReusableInputField from "@/components/shared/inputfield/ReusableInputField"; import { Copy, Edit2, Check, X } from "lucide-react"; -import ReusableToast from "@/components/shared/toast/ReusableToast"; +import { useBanner } from "@/context/banner-context"; +import { getApiErrorMessage } from "@/core/utils/getApiErrorMessage"; interface AdminLevelsModalProps { isOpen: boolean; @@ -15,21 +16,43 @@ interface AdminLevelsModalProps { export function AdminLevelsModal({ isOpen, onClose }: AdminLevelsModalProps) { const { adminLevels, isLoading: isLoadingLevels } = useAdminLevels(); - const { updateAdminLevel, isLoading: isUpdating } = useUpdateAdminLevel(); + const { showBanner } = useBanner(); + const [editingId, setEditingId] = useState(null); const [editValue, setEditValue] = useState(""); + const { updateAdminLevel, isLoading: isUpdating } = useUpdateAdminLevel({ + onSuccess: (data) => { + showBanner({ + message: `Admin level updated to '${data.admin_levels.name}' successfully`, + severity: "success", + scoped: true, + }); + setEditingId(null); + setEditValue(""); + }, + onError: (error) => { + showBanner({ + message: `Failed to update admin level: ${getApiErrorMessage(error)}`, + severity: "error", + scoped: true, + }); + } + }); + const handleCopyId = async (id: string) => { try { await navigator.clipboard.writeText(id); - ReusableToast({ + showBanner({ message: "ID copied to clipboard", - type: "SUCCESS", + severity: "success", + scoped: true, }); } catch { - ReusableToast({ + showBanner({ message: "Failed to copy ID", - type: "ERROR", + severity: "error", + scoped: true, }); } }; @@ -49,15 +72,7 @@ export function AdminLevelsModal({ isOpen, onClose }: AdminLevelsModalProps) { if (!name) { return; } - updateAdminLevel( - { levelId, data: { name } }, - { - onSuccess: () => { - setEditingId(null); - setEditValue(""); - }, - } - ); + updateAdminLevel({ levelId, data: { name } }); }; return ( diff --git a/src/vertex/components/features/grids/create-admin-level.tsx b/src/vertex/components/features/grids/create-admin-level.tsx index 5d6021f00d..de52605348 100644 --- a/src/vertex/components/features/grids/create-admin-level.tsx +++ b/src/vertex/components/features/grids/create-admin-level.tsx @@ -17,6 +17,9 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { AdminLevelsModal } from "./admin-levels-modal"; +import { useBanner } from "@/context/banner-context"; +import { getApiErrorMessage } from "@/core/utils/getApiErrorMessage"; +import { useDeferredBanner } from "@/core/hooks/useDeferredBanner"; const adminLevelFormSchema = z.object({ name: z.string().min(2, { @@ -29,7 +32,8 @@ type AdminLevelFormValues = z.infer; export function CreateAdminLevel() { const [open, setOpen] = useState(false); const [viewModalOpen, setViewModalOpen] = useState(false); - const { createAdminLevel, isLoading } = useCreateAdminLevel(); + const { showBanner } = useBanner(); + const { showDeferredBanner } = useDeferredBanner(); const form = useForm({ resolver: zodResolver(adminLevelFormSchema), @@ -43,12 +47,22 @@ export function CreateAdminLevel() { form.reset(); }; + const { createAdminLevel, isLoading } = useCreateAdminLevel({ + onSuccess: () => { + handleClose(); + showDeferredBanner({ message: "Admin level created successfully", severity: "success", scoped: false }); + }, + onError: (error) => { + showBanner({ + message: `Failed to create admin level: ${getApiErrorMessage(error)}`, + severity: "error", + scoped: true, + }); + }, + }); + const onSubmit = (data: AdminLevelFormValues) => { - createAdminLevel(data, { - onSuccess: () => { - handleClose(); - }, - }); + createAdminLevel(data); }; return ( diff --git a/src/vertex/components/features/grids/create-grid.tsx b/src/vertex/components/features/grids/create-grid.tsx index b028f9a82d..f3a2eb7b30 100644 --- a/src/vertex/components/features/grids/create-grid.tsx +++ b/src/vertex/components/features/grids/create-grid.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState,useEffect } from "react"; import { zodResolver } from "@hookform/resolvers/zod"; import { useForm } from "react-hook-form"; import * as z from "zod"; @@ -15,6 +15,9 @@ import ReusableButton from "@/components/shared/button/ReusableButton"; import ReusableInputField from "@/components/shared/inputfield/ReusableInputField"; import { useNetworks } from "@/core/hooks/useNetworks"; import ReusableSelectInput from "@/components/shared/select/ReusableSelectInput"; +import { useBanner } from "@/context/banner-context"; +import { getApiErrorMessage } from "@/core/utils/getApiErrorMessage"; +import { useDeferredBanner } from "@/core/hooks/useDeferredBanner"; // Lazy load MiniMap to reduce initial bundle size const MiniMap = dynamic(() => import("@/components/features/mini-map/mini-map"), { @@ -58,7 +61,9 @@ type GridFormValues = z.infer; export function CreateGridForm() { const [open, setOpen] = useState(false); const polygon = useAppSelector((state) => state.grids.polygon); - const { createGrid, isLoading } = useCreateGrid(); + const { showBanner } = useBanner(); + const { showDeferredBanner } = useDeferredBanner(); + const { networks, isLoading: isLoadingNetworks } = useNetworks(); const form = useForm({ @@ -71,6 +76,25 @@ export function CreateGridForm() { }, }); + const handleClose = () => { + setOpen(false); + form.reset(); + }; + + const { createGrid, isLoading } = useCreateGrid({ + onSuccess: () => { + handleClose(); + showDeferredBanner({ message: `New grid added!`, severity: "success", scoped: false }); + }, + onError: (error) => { + showBanner({ + message: `Failed to create grid: ${getApiErrorMessage(error)}`, + severity: "error", + scoped: true, + }); + } + }); + useEffect(() => { if (polygon) { form.setValue("shapefile", JSON.stringify(polygon), { shouldValidate: true }); @@ -79,11 +103,6 @@ export function CreateGridForm() { } }, [polygon, form]); - const handleClose = () => { - setOpen(false); - form.reset(); - }; - const onSubmit = (data: GridFormValues) => { const gridData = { name: data.name, @@ -92,11 +111,7 @@ export function CreateGridForm() { network: data.network, }; - createGrid(gridData, { - onSuccess: () => { - handleClose(); - }, - }); + createGrid(gridData); }; return ( diff --git a/src/vertex/components/features/grids/edit-grid-details-dialog.tsx b/src/vertex/components/features/grids/edit-grid-details-dialog.tsx index 3d0141e0bd..94bc93ee12 100644 --- a/src/vertex/components/features/grids/edit-grid-details-dialog.tsx +++ b/src/vertex/components/features/grids/edit-grid-details-dialog.tsx @@ -6,6 +6,9 @@ import { useUpdateGridDetails } from "@/core/hooks/useGrids"; import { usePermission } from "@/core/hooks/usePermissions"; import { PERMISSIONS } from "@/core/permissions/constants"; import { Grid } from "@/app/types/grids"; +import { useBanner } from "@/context/banner-context"; +import { getApiErrorMessage } from "@/core/utils/getApiErrorMessage"; +import { useDeferredBanner } from "@/core/hooks/useDeferredBanner"; interface EditGridDetailsDialogProps { open: boolean; @@ -19,7 +22,23 @@ const EditGridDetailsDialog: React.FC = ({ onClose, }) => { const [form, setForm] = useState({ name: "", visibility: false, admin_level: "" }); - const { updateGridDetails, isLoading } = useUpdateGridDetails(grid._id); + const { showBanner } = useBanner(); + const { showDeferredBanner } = useDeferredBanner(); + + const { updateGridDetails, isLoading } = useUpdateGridDetails(grid._id, { + onSuccess: () => { + onClose(); + showDeferredBanner({ message: "Grid details updated successfully", severity: "success", scoped: false }); + }, + onError: (error) => { + showBanner({ + message: `Failed to update grid: ${getApiErrorMessage(error)}`, + severity: "error", + scoped: true, + }); + } + }); + const canUpdate = usePermission(PERMISSIONS.SITE.UPDATE); useEffect(() => { @@ -39,6 +58,11 @@ const EditGridDetailsDialog: React.FC = ({ const trimmedAdminLevel = form.admin_level.trim(); if (trimmedName.length === 0 || trimmedAdminLevel.length === 0) { + showBanner({ + severity: "error", + message: "Grid Name and Admin Level cannot be empty.", + scoped: true, + }); return; } @@ -56,9 +80,8 @@ const EditGridDetailsDialog: React.FC = ({ try { await updateGridDetails(updates); - onClose(); } catch { - return + return; } }; diff --git a/src/vertex/components/features/grids/grid-details-card.tsx b/src/vertex/components/features/grids/grid-details-card.tsx index d29329b8a3..2ff938ac8a 100644 --- a/src/vertex/components/features/grids/grid-details-card.tsx +++ b/src/vertex/components/features/grids/grid-details-card.tsx @@ -5,8 +5,8 @@ import { Badge } from "@/components/ui/badge"; import { Loader2 } from "lucide-react"; import ReusableButton from "@/components/shared/button/ReusableButton"; import { AqCopy01, AqEdit01 } from "@airqo/icons-react"; -import ReusableToast from "@/components/shared/toast/ReusableToast"; import { Grid } from "@/app/types/grids"; +import { useBanner } from "@/context/banner-context"; interface GridDetailsCardProps { grid: Grid; @@ -15,14 +15,19 @@ interface GridDetailsCardProps { } const GridDetailsCard: React.FC = ({ grid, onEdit, loading }) => { + const { showBanner } = useBanner(); + if (loading) { return ; } - const handleCopy = (text: string) => { - if (text) { - navigator.clipboard.writeText(text); - ReusableToast({ message: "Copied to clipboard", type: "SUCCESS" }); + 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 }); } }; 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 7ba270fe01..435ce88c90 100644 --- a/src/vertex/components/features/grids/grid-measurements-api-card.tsx +++ b/src/vertex/components/features/grids/grid-measurements-api-card.tsx @@ -4,8 +4,8 @@ import { Card } from "@/components/ui/card"; import { Loader2 } from "lucide-react"; import ReusableButton from "@/components/shared/button/ReusableButton"; import { AqCopy01 } from "@airqo/icons-react"; -import ReusableToast from "@/components/shared/toast/ReusableToast"; import { Grid } from "@/app/types/grids"; +import { useBanner } from "@/context/banner-context"; interface GridMeasurementsApiCardProps { grid: Grid; @@ -13,6 +13,8 @@ interface GridMeasurementsApiCardProps { } const GridMeasurementsApiCard: React.FC = ({ grid, loading }) => { + const { showBanner } = useBanner(); + if (loading) { return ; } @@ -20,21 +22,10 @@ const GridMeasurementsApiCard: React.FC = ({ grid, const handleCopy = async (text: string) => { if (!text) return; try { - if (navigator?.clipboard?.writeText) { - await navigator.clipboard.writeText(text); - } else { - const el = document.createElement("textarea"); - el.value = text; - el.style.position = "fixed"; - el.style.opacity = "0"; - document.body.appendChild(el); - el.select(); - document.execCommand("copy"); - document.body.removeChild(el); - } - ReusableToast({ message: "API URL copied!", type: "SUCCESS" }); + await navigator.clipboard.writeText(text); + showBanner({ message: "API URL copied!", severity: "success", scoped: false }); } catch { - ReusableToast({ message: "Failed to copy to clipboard", type: "ERROR" }); + showBanner({ message: "Failed to copy to clipboard", severity: "error", scoped: false }); } }; diff --git a/src/vertex/context/banner-context.tsx b/src/vertex/context/banner-context.tsx index 8fb984e875..0bf29966e0 100644 --- a/src/vertex/context/banner-context.tsx +++ b/src/vertex/context/banner-context.tsx @@ -15,7 +15,7 @@ interface BannerState { isScoped: boolean; } -type ShowBannerOptions = BannerDisplayProps & { scoped?: boolean }; +export type ShowBannerOptions = BannerDisplayProps & { scoped?: boolean }; interface BannerContextValue { state: BannerState; diff --git a/src/vertex/core/constants/ui.ts b/src/vertex/core/constants/ui.ts new file mode 100644 index 0000000000..d413b88f97 --- /dev/null +++ b/src/vertex/core/constants/ui.ts @@ -0,0 +1,5 @@ +/** + * Delay (ms) before showing a global banner after a dialog closes. + * Allows the dialog's unmount cleanup (which clears scoped banners) to complete first. + */ +export const AFTER_DIALOG_CLOSE_MS = 100; diff --git a/src/vertex/core/hooks/useDeferredBanner.ts b/src/vertex/core/hooks/useDeferredBanner.ts new file mode 100644 index 0000000000..f7540f4642 --- /dev/null +++ b/src/vertex/core/hooks/useDeferredBanner.ts @@ -0,0 +1,23 @@ +import { useEffect, useRef } from 'react'; +import { useBanner, type ShowBannerOptions } from '@/context/banner-context'; +import { AFTER_DIALOG_CLOSE_MS } from '@/core/constants/ui'; + +export const useDeferredBanner = () => { + const { showBanner } = useBanner(); + const timerRef = useRef | null>(null); + + useEffect(() => { + return () => { + if (timerRef.current) clearTimeout(timerRef.current); + }; + }, []); + + const showDeferredBanner = (options: ShowBannerOptions) => { + if (timerRef.current) clearTimeout(timerRef.current); + timerRef.current = setTimeout(() => { + showBanner(options); + }, AFTER_DIALOG_CLOSE_MS); + }; + + return { showDeferredBanner }; +}; diff --git a/src/vertex/core/hooks/useGrids.ts b/src/vertex/core/hooks/useGrids.ts index 44fd98bf6f..88a3f2496f 100644 --- a/src/vertex/core/hooks/useGrids.ts +++ b/src/vertex/core/hooks/useGrids.ts @@ -9,8 +9,7 @@ import { CreateGrid, Grid, GridsSummaryResponse } from "@/app/types/grids"; import { setError, setGrids } from "../redux/slices/gridsSlice"; import { useDispatch } from "react-redux"; import React from "react"; -import ReusableToast from "@/components/shared/toast/ReusableToast"; -import { getApiErrorMessage } from "../utils/getApiErrorMessage"; + interface ErrorResponse { message: string; @@ -95,8 +94,13 @@ export const useGridDetails = (gridId: string) => { }; }; +interface UseUpdateGridDetailsOptions { + onSuccess?: (data: Grid) => void; + onError?: (error: AxiosError) => void; +} + // Hook to update grid details -export const useUpdateGridDetails = (gridId: string) => { +export const useUpdateGridDetails = (gridId: string, options?: UseUpdateGridDetailsOptions) => { const queryClient = useQueryClient(); const { mutateAsync: updateGridDetails, @@ -105,81 +109,62 @@ export const useUpdateGridDetails = (gridId: string) => { } = useMutation, { name?: string; visibility?: boolean; admin_level?: string }>({ mutationFn: (updatedFields: { name?: string; visibility?: boolean; admin_level?: string }) => grids.updateGridDetailsApi(gridId, updatedFields), - onSuccess: () => { - ReusableToast({ - message: "Grid details updated successfully", - type: "SUCCESS", - }); + onSuccess: (data) => { queryClient.invalidateQueries({ queryKey: ["gridDetails", gridId] }); queryClient.invalidateQueries({ queryKey: ["grids"] }); + options?.onSuccess?.(data); }, onError: (error: AxiosError) => { - ReusableToast({ - message: `Failed to update grid: ${getApiErrorMessage(error)}`, - type: "ERROR", - }); + options?.onError?.(error); }, }); - return { - updateGridDetails, - isLoading, - error, - }; + return { updateGridDetails, isLoading, error }; }; +interface UseCreateGridOptions { + onSuccess?: (data: Grid) => void; + onError?: (error: AxiosError) => void; +} + // Hook to create a new grid -export const useCreateGrid = () => { +export const useCreateGrid = (options?: UseCreateGridOptions) => { const queryClient = useQueryClient(); const { mutate: createGrid, isPending: isLoading, error } = useMutation, CreateGrid>({ mutationFn: async (newGrid: CreateGrid) => await grids.createGridApi(newGrid), onSuccess: (data) => { - ReusableToast({ - message: `New grid added!`, - type: "SUCCESS", - }); queryClient.invalidateQueries({ queryKey: ["grids"] }); + options?.onSuccess?.(data); }, onError: (error: AxiosError) => { - ReusableToast({ - message: `Failed to create grid: ${getApiErrorMessage(error)}`, - type: "ERROR", - }); + options?.onError?.(error); }, }); - return { - createGrid, - isLoading, - error, - }; + return { createGrid, isLoading, error }; }; -export const useCreateAdminLevel = () => { +interface UseCreateAdminLevelOptions { + onSuccess?: (data: AdminLevelResponse) => void; + onError?: (error: AxiosError) => void; +} + +export const useCreateAdminLevel = (options?: UseCreateAdminLevelOptions) => { const queryClient = useQueryClient(); const { mutate: createAdminLevel, isPending: isLoading, error } = useMutation, { name: string }>({ mutationFn: (data: { name: string }) => grids.createAdminLevelApi(data), onSuccess: (data) => { - ReusableToast({ - message: `Admin level '${data.admin_levels.name}' created successfully`, - type: "SUCCESS", - }); queryClient.invalidateQueries({ queryKey: ["admin-levels"] }); + options?.onSuccess?.(data); }, onError: (error: AxiosError) => { - ReusableToast({ - message: `Failed to create admin level: ${getApiErrorMessage(error)}`, - type: "ERROR", - }); + options?.onError?.(error); + }, }); - return { - createAdminLevel, - isLoading, - error, - }; + return { createAdminLevel, isLoading, error }; }; export const useAdminLevels = () => { @@ -196,28 +181,23 @@ export const useAdminLevels = () => { }; }; -export const useUpdateAdminLevel = () => { +interface UseUpdateAdminLevelOptions { + onSuccess?: (data: AdminLevelResponse) => void; + onError?: (error: AxiosError) => void; +} + +export const useUpdateAdminLevel = (options?: UseUpdateAdminLevelOptions) => { const queryClient = useQueryClient(); const { mutate: updateAdminLevel, isPending: isLoading, error } = useMutation, { levelId: string; data: { name: string } }>({ mutationFn: ({ levelId, data }) => grids.updateAdminLevelApi(levelId, data), onSuccess: (data) => { - ReusableToast({ - message: `Admin level updated to '${data.admin_levels.name}' successfully`, - type: "SUCCESS", - }); queryClient.invalidateQueries({ queryKey: ["admin-levels"] }); + options?.onSuccess?.(data); }, onError: (error: AxiosError) => { - ReusableToast({ - message: `Failed to update admin level: ${getApiErrorMessage(error)}`, - type: "ERROR", - }); + options?.onError?.(error); }, }); - return { - updateAdminLevel, - isLoading, - error, - }; + return { updateAdminLevel, isLoading, error }; }; diff --git a/src/vertex/core/utils/getApiErrorMessage.ts b/src/vertex/core/utils/getApiErrorMessage.ts index 1c410e7b0d..e70d94c070 100644 --- a/src/vertex/core/utils/getApiErrorMessage.ts +++ b/src/vertex/core/utils/getApiErrorMessage.ts @@ -13,6 +13,14 @@ interface ErrorWithData extends Error { data?: ApiErrorResponse | string; } +const HTML_PATTERN = /<\/?[a-z][\s\S]*>/i; +const GENERIC_ERROR = 'An unexpected error occurred. Please try again.'; + +const fallbackIfHtml = (msg: string): string => { + const trimmed = msg.trim(); + return !trimmed || HTML_PATTERN.test(trimmed) ? GENERIC_ERROR : trimmed; +}; + const getMessageFromApiData = (data: ApiErrorResponse | string): string | null => { if (typeof data === 'string') { return data; @@ -64,21 +72,21 @@ export const getApiErrorMessage = (error: unknown): string => { if (error.response?.data) { const message = getMessageFromApiData(error.response.data as ApiErrorResponse | string); - if (message) return message; + if (message) return fallbackIfHtml(message); } } // 5. Fetch/custom errors with preserved backend payload: Object.assign(new Error(...), { data }) if (error instanceof Error && 'data' in error) { const message = getMessageFromApiData((error as ErrorWithData).data ?? {}); - if (message) return message; + if (message) return fallbackIfHtml(message); } // 6. Fallback to standard Error message if (error instanceof Error) { - return error.message; + return fallbackIfHtml(error.message); } // 7. Generic fallback - return 'An unexpected error occurred. Please try again.'; + return GENERIC_ERROR; };