Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions src/vertex/app/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,48 @@

> **Note**: This changelog consolidates all recent improvements, features, and fixes to the AirQo Vertex frontend.

## Version 2.0.7
**Released:** June 17, 2026

### Toast-to-Banner Migration & Clipboard Hook Consolidation

Completed the migration of all remaining non-copy action notifications from floating `ReusableToast` calls to the context-aware `InfoBanner` system. Consolidated all clipboard copy logic under the shared `useClipboard` hook.

<details>
<summary><strong>BannerProvider Scope Fix (1)</strong></summary>

- **Provider Hierarchy Correction**: Moved `BannerProvider` above `AuthProvider` in `providers.tsx`. Previously, core auth components (`UserDataFetcher`, `AuthWrapper`) lived outside the `BannerProvider` boundary and could not call `useBanner`. The provider now wraps the full application tree, making banners available everywhere including auth-level components.

</details>

<details>
<summary><strong>Global Banner Migration — Auth & Core (3)</strong></summary>

- **Auth Provider (`authProvider.tsx`)**: Replaced all 4 `ReusableToast` calls with `showBanner({ scoped: false })` global banners. Affected messages: "Connection restored" (success), "Could not load user details" (error), "Your account has been deleted" (error), and "Your session has expired" (error ×2). These alerts now render in the `GlobalBannerContainer` regardless of the user's current page.
- **Recently Visited Hook (`useRecentlyVisited.ts`)**: Migrated the `localStorage` read failure toast ("Failed to load recently visited pages") to a global banner so it surfaces in the main layout instead of a floating overlay.
- **Clipboard Hook (`useClipboard.ts`)**: Updated the hook to use `toast.success` for successful copies (consistent with the intentional clipboard toast pattern) and `showBanner` for copy errors, aligning it with the dual-system convention.

</details>

<details>
<summary><strong>Scoped Banner Migration — Feature Components (2)</strong></summary>

- **Network Visibility Card (`network-visibility-card.tsx`)**: Replaced the "Failed to update network visibility" toast with a scoped banner (`scoped: true`). The error now renders inside the confirmation dialog via the existing `<BannerSlot />` in `ReusableDialog`, keeping the error contextually placed without requiring structural changes.
- **File Upload Parser (`FileUploadParser.tsx`)**: Replaced all 6 `ReusableToast` error calls with scoped banners. Added a `<BannerSlot />` inside the `BulkClaimColumnMapper` modal header and threaded `showBanner` down as a prop. Affected messages: invalid file format, file too large, CSV parse error, Excel read error, general import error, and no valid devices found.

</details>

<details>
<summary><strong>Clipboard Hook Consolidation (3)</strong></summary>

- **Cohort Detail Card (`cohort-detail-card.tsx`)**: Replaced inline `try/catch` clipboard logic with `useClipboard({ successMessage: 'Cohort ID copied to clipboard' })`.
- **Cohort Organizations Card (`cohort-organizations-card.tsx`)**: Removed the manual `handleCopyId` function and `ReusableToast` import. Now uses `useClipboard({ successMessage: 'Group ID copied to clipboard!' })`.
- **Networks Table (`client-paginated-networks-table.tsx`)**: Removed inline `useBanner` clipboard pattern. Now delegates to `useClipboard({ successMessage: 'Sensor Manufacturer ID copied' })` while preserving `event.stopPropagation()` at the call site.

</details>

---

## Version 2.0.6
**Released:** June 14, 2026

Expand Down
30 changes: 15 additions & 15 deletions src/vertex/app/providers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,22 +40,22 @@ export default function Providers({ children, session }: { children: React.React
<Provider store={store}>
<PersistGate loading={<SessionLoadingState />} persistor={persistor}>
<QueryProvider scopeKey={cacheScope}>
<AuthProvider session={session}>
<ThemeProvider
attribute="class"
defaultTheme="light"
enableSystem={false}
disableTransitionOnChange
>
<BannerProvider>
<BannerProvider>
<AuthProvider session={session}>
<ThemeProvider
attribute="class"
defaultTheme="light"
enableSystem={false}
disableTransitionOnChange
>
{children}
</BannerProvider>
</ThemeProvider>
{process.env.NODE_ENV !== "production" && (
<ReactQueryDevtools initialIsOpen={false} />
)}
<NetworkStatusBanner />
</AuthProvider>
</ThemeProvider>
{process.env.NODE_ENV !== "production" && (
<ReactQueryDevtools initialIsOpen={false} />
)}
<NetworkStatusBanner />
</AuthProvider>
</BannerProvider>
</QueryProvider>
</PersistGate>
</Provider>
Expand Down
52 changes: 22 additions & 30 deletions src/vertex/components/features/claim/FileUploadParser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import React, { useRef, useState } from 'react';
import { AqUploadCloud02 } from '@airqo/icons-react';
import ReusableButton from '@/components/shared/button/ReusableButton';
import ReusableToast from '@/components/shared/toast/ReusableToast';
import { useBanner, BannerSlot } from '@/context/banner-context';

export interface ParsedFileData {
headers: string[];
Expand All @@ -15,12 +15,14 @@ interface BulkClaimColumnMapperProps {
filePreview: ParsedFileData;
onConfirm: (deviceNameColumn: number, claimTokenColumn: number) => void;
onCancel: () => void;
showBanner: ReturnType<typeof useBanner>['showBanner'];
}

export const BulkClaimColumnMapper: React.FC<BulkClaimColumnMapperProps> = ({
filePreview,
onConfirm,
onCancel,
showBanner,
}) => {
const [deviceNameColumn, setDeviceNameColumn] = useState<number>(() => {
// Auto-detect device name column
Expand All @@ -40,10 +42,7 @@ export const BulkClaimColumnMapper: React.FC<BulkClaimColumnMapperProps> = ({

const handleConfirm = () => {
if (deviceNameColumn === claimTokenColumn) {
ReusableToast({
message: 'Device name and claim token must be in different columns',
type: 'ERROR',
});
showBanner({ severity: 'error', message: 'Device name and claim token must be in different columns', scoped: true });
return;
}
onConfirm(deviceNameColumn, claimTokenColumn);
Expand All @@ -52,13 +51,16 @@ export const BulkClaimColumnMapper: React.FC<BulkClaimColumnMapperProps> = ({
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-6xl w-full max-h-[90vh] overflow-hidden flex flex-col">
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
Map Columns for Device Claiming
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
File: {filePreview.fileName}
</p>
<div className="border-b border-gray-200 dark:border-gray-700">
<div className="p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
Map Columns for Device Claiming
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
File: {filePreview.fileName}
</p>
</div>
<BannerSlot />
</div>
Comment on lines +60 to 64

<div className="p-6 overflow-auto flex-1">
Expand Down Expand Up @@ -178,23 +180,21 @@ export const FileUploadParser: React.FC<FileUploadParserProps> = ({ onFilesParse
const [isImporting, setIsImporting] = useState(false);
const [filePreview, setFilePreview] = useState<ParsedFileData | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const { showBanner } = useBanner();

const handleFileImport = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;

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;
}
Expand All @@ -221,10 +221,7 @@ export const FileUploadParser: React.FC<FileUploadParserProps> = ({ onFilesParse
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);
},
});
Expand All @@ -247,16 +244,13 @@ export const FileUploadParser: React.FC<FileUploadParserProps> = ({ onFilesParse
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);
}

Expand Down Expand Up @@ -285,10 +279,7 @@ export const FileUploadParser: React.FC<FileUploadParserProps> = ({ onFilesParse
.filter((device) => device.device_name.length > 0 && device.claim_token.length > 0);

if (devices.length === 0) {
ReusableToast({
message: 'No valid devices found in the selected columns',
type: 'ERROR',
});
showBanner({ severity: 'error', message: 'No valid devices found in the selected columns', scoped: true });
return;
}

Expand All @@ -307,6 +298,7 @@ export const FileUploadParser: React.FC<FileUploadParserProps> = ({ onFilesParse
filePreview={filePreview}
onConfirm={handleConfirmImport}
onCancel={handleCancelImport}
showBanner={showBanner}
/>
)}

Expand Down
12 changes: 3 additions & 9 deletions src/vertex/components/features/cohorts/cohort-detail-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import ReusableButton from "@/components/shared/button/ReusableButton";
import { AqCopy01, AqEdit01 } from "@airqo/icons-react";
import { Switch } from "@/components/ui/switch";
import { useBanner } from "@/context/banner-context";
import { useClipboard } from "@/core/hooks/useClipboard";
import { getApiErrorMessage } from "@/core/utils/getApiErrorMessage";
import { useBannerWithDelay } from "@/core/hooks/useBannerWithDelay";
import { useEffect, useState } from "react";
Expand Down Expand Up @@ -44,6 +45,7 @@ const CohortDetailsCard: React.FC<CohortDetailsCardProps> = ({
);

const { showBanner } = useBanner();
const { handleCopy } = useClipboard({ successMessage: 'Cohort ID copied to clipboard' });
const { showBannerWithDelay } = useBannerWithDelay();
const { mutateAsync: updateCohort, isPending } = useUpdateCohortDetails();
const { data: originalData } = useOriginalCohort(id, { enabled: !!isDuplicate });
Expand Down Expand Up @@ -197,15 +199,7 @@ const CohortDetailsCard: React.FC<CohortDetailsCardProps> = ({
</div>
<ReusableButton
variant="text"
onClick={async () => {
if (!id) return;
try {
await navigator.clipboard.writeText(id);
showBanner({ severity: 'success', message: 'Cohort ID copied to clipboard', scoped: false });
} catch {
showBanner({ severity: 'error', message: 'Failed to copy to clipboard', scoped: false });
}
}}
onClick={() => { if (id) handleCopy(id); }}
className="p-1"
Icon={AqCopy01}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { UnassignCohortFromGroupDialog } from "./unassign-cohort-from-group";
import ReusableButton from "@/components/shared/button/ReusableButton";
import ReusableDialog from "@/components/shared/dialog/ReusableDialog";
import ReusableTable, { TableColumn, TableItem } from "@/components/shared/table/ReusableTable";
import ReusableToast from "@/components/shared/toast/ReusableToast";
import { useClipboard } from "@/core/hooks/useClipboard";
import { AqCopy01 } from "@airqo/icons-react";
import { formatTitle } from "../org-picker/organization-picker";

Expand All @@ -25,7 +25,7 @@ export function CohortOrganizationsCard({
canUnassign = false,
}: CohortOrganizationsCardProps) {
const { groups: organizations, isLoading, refetch } = useGroupsByCohort(cohortId);

const { handleCopy } = useClipboard({ successMessage: 'Group ID copied to clipboard!' });
const [selectedOrg, setSelectedOrg] = useState<Group | null>(null);
const [showUnassignDialog, setShowUnassignDialog] = useState(false);
const [showAllDialog, setShowAllDialog] = useState(false);
Expand All @@ -41,10 +41,6 @@ export function CohortOrganizationsCard({
refetch(); // Refetch the organizations assigned to the cohort
};

const handleCopyId = (id: string) => {
navigator.clipboard.writeText(id);
ReusableToast({ message: "Group ID copied to clipboard!", type: "SUCCESS" });
};

const tableData = useMemo(() => {
return organizations.map((org) => ({ ...org, id: org._id }));
Expand All @@ -68,7 +64,7 @@ export function CohortOrganizationsCard({
className="p-1 h-auto text-muted-foreground hover:text-primary"
onClick={(e) => {
e.stopPropagation();
handleCopyId(item._id);
handleCopy(item._id);
}}
Icon={AqCopy01}
aria-label="Copy Group ID"
Expand Down Expand Up @@ -152,7 +148,7 @@ export function CohortOrganizationsCard({
</span>
<ReusableButton
variant="text"
onClick={() => handleCopyId(org._id)}
onClick={() => handleCopy(org._id)}
className="p-1 h-auto text-muted-foreground hover:text-primary"
Icon={AqCopy01}
aria-label="Copy Organization ID"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { cohorts as cohortsApi } from '@/core/apis/cohorts';
import ReusableDialog from '@/components/shared/dialog/ReusableDialog';
import ReusableButton from '@/components/shared/button/ReusableButton';
import { PERMISSIONS } from '@/core/permissions/constants';
import ReusableToast from '@/components/shared/toast/ReusableToast';
import { useBanner } from '@/context/banner-context';
import { useQueryClient } from '@tanstack/react-query';
import { usePermissions } from '@/core/hooks/usePermissions';
import { cn } from '@/lib/utils';
Expand All @@ -30,6 +30,7 @@ const NetworkVisibilityCard = ({ onVisibilityChanged, showCoachMark }: NetworkVi
const router = useRouter();
const { data: session } = useSession();
const user = useAppSelector(state => state.user.userDetails);
const { showBanner } = useBanner();

const [isDialogOpen, setIsDialogOpen] = useState(false);
const [targetVisibility, setTargetVisibility] = useState<boolean>(false);
Expand Down Expand Up @@ -129,10 +130,7 @@ const NetworkVisibilityCard = ({ onVisibilityChanged, showCoachMark }: NetworkVi
}
} catch (error) {
console.error(error);
ReusableToast({
message: 'Failed to update network visibility',
type: 'ERROR',
});
showBanner({ severity: 'error', message: 'Failed to update network visibility', scoped: true });
} finally {
setIsUpdating(false);
setIsDialogOpen(false);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import ReusableTable, {
import { useRouter } from "next/navigation";
import { Network } from "@/core/apis/networks";
import { AqCopy01, AqLinkExternal01 } from "@airqo/icons-react";
import { useBanner } from "@/context/banner-context";
import { useClipboard } from "@/core/hooks/useClipboard";

interface NetworksTableProps {
networks: Network[];
Expand All @@ -26,24 +26,17 @@ export default function ClientPaginatedNetworksTable({
onNetworkClick,
}: NetworksTableProps) {
const router = useRouter();
const { showBanner } = useBanner();
const { handleCopy: copyToClipboard } = useClipboard({ successMessage: 'Sensor Manufacturer ID copied' });

const handleNetworkClick = (item: unknown) => {
const network = item as Network;
if (onNetworkClick) onNetworkClick(network);
else if (network._id) router.push(`/admin/networks/${network._id}`);
};

const handleCopy = async (text: string, event: React.MouseEvent) => {
const handleCopy = (text: string, event: React.MouseEvent) => {
event.stopPropagation();
if (text) {
try {
await navigator.clipboard.writeText(text);
showBanner({ message: "Sensor Manufacturer ID copied", severity: "success", scoped: false });
} catch {
showBanner({ message: "Failed to copy ID", severity: "error", scoped: false });
}
}
if (text) copyToClipboard(text);
};

const networksWithId: TableNetwork[] = networks
Expand Down
Loading
Loading