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
47 changes: 47 additions & 0 deletions src/vertex/app/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,53 @@

---

## Version 1.23.52
**Released:** May 27, 2026

### Cohort Management Banner Migration & Banner Delay Standardization

Migrated toast-based cohort notifications and raw `setTimeout` banner patterns in device dialogs to the centralized `InfoBanner` system and the shared `useBannerWithDelay` hook. Errors inside dialogs use `scoped: true` to stay inline while the dialog remains open; post-dialog success feedback uses `scoped: false` so the delayed banner appears only after the dialog has fully unmounted.

<details>
<summary><strong>Banner Delay Standardization (7)</strong></summary>

- **Shared Delay Hook Rename**: Renamed the former `useDeferredBanner` hook to `useBannerWithDelay` across cohort, grid, and device consumers to make the hook name reflect the delayed display behavior.
- **Hook-Level Callback Pattern**: All mutation hooks in `useCohorts.ts` now accept optional `onSuccess`/`onError` callbacks at initialization rather than per-call `mutate()` arguments, aligning with the reliable pattern established in the grid module.
- **Dialog Error Feedback**: `create-cohort.tsx`, `edit-cohort-details-modal.tsx`, `device-name-parser.tsx`, and `assign/unassign-cohort-devices.tsx` use `scoped: true` so validation and mutation errors remain visible inside the active dialog without closing it.
- **Post-Dialog Success Feedback**: `create-cohort.tsx`, `assign-cohort-devices.tsx`, and `unassign-cohort-devices.tsx` use the shared `useBannerWithDelay` hook (`scoped: false`) to show global success banners only after the dialog has unmounted.
- **Device Banner Refactor**: Device modals and dialogs now use `useBannerWithDelay` instead of raw `setTimeout` timer patterns to display delayed success feedback after unmounting.
- **Detail Card & API Card Feedback**: `cohort-detail-card.tsx` and `cohort-measurements-api-card.tsx` replace toast calls with `useBanner` for copy feedback and action outcomes, keeping feedback in context.
- **Shared Utilities Adopted**: `useBannerWithDelay` hook and `AFTER_DIALOG_CLOSE_MS` constant (merged from the grid branch) are now consumed across the cohort module, removing all local `bannerTimerRef + setTimeout` patterns.

</details>

<details>
<summary><strong>Files Updated (19)</strong></summary>

- `src/vertex/core/hooks/useCohorts.ts` [MODIFIED]
- `src/vertex/components/features/cohorts/create-cohort.tsx` [MODIFIED]
- `src/vertex/components/features/cohorts/edit-cohort-details-modal.tsx` [MODIFIED]
- `src/vertex/components/features/cohorts/cohort-detail-card.tsx` [MODIFIED]
- `src/vertex/components/features/cohorts/cohort-measurements-api-card.tsx` [MODIFIED]
- `src/vertex/components/features/cohorts/device-name-parser.tsx` [MODIFIED]
- `src/vertex/components/features/cohorts/assign-cohort-devices.tsx` [MODIFIED]
- `src/vertex/components/features/cohorts/unassign-cohort-devices.tsx` [MODIFIED]
- `src/vertex/components/features/grids/create-admin-level.tsx` [MODIFIED]
- `src/vertex/components/features/grids/create-grid.tsx` [MODIFIED]
- `src/vertex/components/features/grids/edit-grid-details-dialog.tsx` [MODIFIED]
- `src/vertex/components/features/devices/add-maintenance-log-modal.tsx` [MODIFIED]
- `src/vertex/components/features/devices/create-device-modal.tsx` [MODIFIED]
- `src/vertex/components/features/devices/deploy-device-component.tsx` [MODIFIED]
- `src/vertex/components/features/devices/device-assignment-modal.tsx` [MODIFIED]
- `src/vertex/components/features/devices/import-device-modal.tsx` [MODIFIED]
- `src/vertex/components/features/devices/recall-device-dialog.tsx` [MODIFIED]
- `src/vertex/core/hooks/useBannerWithDelay.ts` [ADDED]
- `src/vertex/app/changelog.md` [MODIFIED]

</details>

---

## Version 1.23.51
**Released:** May 27, 2026

Expand Down
22 changes: 21 additions & 1 deletion src/vertex/components/features/cohorts/assign-cohort-devices.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ import { Cohort } from "@/app/types/cohorts";
import ReusableDialog from "@/components/shared/dialog/ReusableDialog";
import { Device } from "@/app/types/devices";
import { useUserContext } from "@/core/hooks/useUserContext";
import { useBanner } from "@/context/banner-context";
import { getApiErrorMessage } from "@/core/utils/getApiErrorMessage";
import { useBannerWithDelay } from "@/core/hooks/useBannerWithDelay";

interface AssignCohortDevicesDialogProps {
open: boolean;
Expand All @@ -48,6 +51,8 @@ export function AssignCohortDevicesDialog({
cohortId,
}: AssignCohortDevicesDialogProps) {
const { isExternalOrg, activeGroup } = useUserContext();
const { showBanner } = useBanner();
const { showBannerWithDelay } = useBannerWithDelay();
const [cohortSearch, setCohortSearch] = useState("");
const [debouncedCohortSearch, setDebouncedCohortSearch] = useState("");
const [deviceSearch, setDeviceSearch] = useState("");
Expand Down Expand Up @@ -101,7 +106,22 @@ export function AssignCohortDevicesDialog({
enabled: open,
search: debouncedDeviceSearch,
});
const { mutate: assignDevices, isPending: isAssigning } = useAssignDevicesToCohort();
const { mutate: assignDevices, isPending: isAssigning } = useAssignDevicesToCohort({
onSuccess: (variables) => {
showBannerWithDelay({
severity: 'success',
message: `${variables.deviceIds.length} device(s) assigned to cohort successfully`,
scoped: false,
});
},

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I should have asked this in the previous PR. What prompted you to create showDeferredBanner yet showBanner covered both global and "local" ie within the dialog, banner instances? @BwanikaRobert

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fix is a 100 ms delay wait for the dialog to fully unmount, then show the banner. Without that delay, the success message silently disappears on every action that closes a dialog.

useDeferredBanner just encapsulates that pattern the useRef to hold the timer, the useEffect to cancel it on unmount, and the 100 ms delay so we don't copy and paste the same bannerTimerRef + setTimeout + cleanup block into every component that needs post-dialog feedback. showBanner alone can't express this.

It was one of the changes @copilot requested in the prevoius PR @Codebmk

@Codebmk Codebmk May 28, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, that was a great change! Let's rename it to showBannerWithDelay and useBannerWithDelay where it is much easier to remember what they are for

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kindly replace all parts in the codebase that use the new banner but apply the timer approach manually, they are few. @BwanikaRobert

onError: (error) => {
showBanner({
severity: 'error',
message: `Failed to assign devices: ${getApiErrorMessage(error)}`,
scoped: true,
});
},
});

const [createCohortModalOpen, setCreateCohortModalOpen] = useState(false);
const [preselectedForCreate, setPreselectedForCreate] = useState<PreselectedDevice[]>([]);
Expand Down
69 changes: 45 additions & 24 deletions src/vertex/components/features/cohorts/cohort-detail-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ import { Card } from "@/components/ui/card";
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 { Switch } from "@/components/ui/switch";
import { useBanner } from "@/context/banner-context";
import { getApiErrorMessage } from "@/core/utils/getApiErrorMessage";
import { useBannerWithDelay } from "@/core/hooks/useBannerWithDelay";
import { useEffect, useState } from "react";
import { useUpdateCohortDetails, useOriginalCohort } from "@/core/hooks/useCohorts";
import ReusableDialog from "@/components/shared/dialog/ReusableDialog";
Expand Down Expand Up @@ -41,7 +43,9 @@ const CohortDetailsCard: React.FC<CohortDetailsCardProps> = ({
(tag) => tag.toLowerCase() === "duplicate"
);

const { mutate: updateCohort, isPending } = useUpdateCohortDetails();
const { showBanner } = useBanner();
const { showBannerWithDelay } = useBannerWithDelay();
const { mutateAsync: updateCohort, isPending } = useUpdateCohortDetails();
const { data: originalData } = useOriginalCohort(id, { enabled: !!isDuplicate });
const originalCohort = isDuplicate && originalData?.success ? originalData.original_cohort : null;

Expand All @@ -54,26 +58,40 @@ const CohortDetailsCard: React.FC<CohortDetailsCardProps> = ({
setIsVisibilityDialogOpen(true);
};

const handleConfirmVisibilityUpdate = () => {
updateCohort(
{ cohortId: id, data: { visibility: targetVisibility } },
{
onSuccess: () => {
setIsVisibilityDialogOpen(false);
},
}
);
const handleConfirmVisibilityUpdate = async () => {
try {
await updateCohort({ cohortId: id, data: { visibility: targetVisibility } });
setIsVisibilityDialogOpen(false);
showBannerWithDelay({
severity: 'success',
message: `Cohort is now ${targetVisibility ? 'public' : 'private'}`,
scoped: false,
});
} catch (error) {
showBanner({
severity: 'error',
message: `Failed to update visibility: ${getApiErrorMessage(error)}`,
scoped: true,
});
}
};

const handleConfirmTagsUpdate = () => {
updateCohort(
{ cohortId: id, data: { cohort_tags: selectedTags } },
{
onSuccess: () => {
setIsTagsDialogOpen(false);
},
}
);
const handleConfirmTagsUpdate = async () => {
try {
await updateCohort({ cohortId: id, data: { cohort_tags: selectedTags } });
setIsTagsDialogOpen(false);
showBannerWithDelay({
severity: 'success',
message: 'Cohort tags updated successfully',
scoped: false,
});
} catch (error) {
showBanner({
severity: 'error',
message: `Failed to update tags: ${getApiErrorMessage(error)}`,
scoped: true,
});
}
};

if (loading) {
Expand Down Expand Up @@ -179,10 +197,13 @@ const CohortDetailsCard: React.FC<CohortDetailsCardProps> = ({
</div>
<ReusableButton
variant="text"
onClick={() => {
if (id) {
navigator.clipboard.writeText(id);
ReusableToast({ message: "Copied", type: "SUCCESS" });
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 });
}
}}
className="p-1"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,24 @@ 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 { useBanner } from "@/context/banner-context";

interface CohortMeasurementsApiCardProps {
cohortId: string;
}

const CohortMeasurementsApiCard: React.FC<CohortMeasurementsApiCardProps> = ({ 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 });
}
};

return (
<Card className="w-full rounded-lg flex flex-col gap-4 px-3 py-2">
<h2 className="text-lg font-semibold mb-2">Cohort Measurements API</h2>
Expand All @@ -26,14 +37,7 @@ const CohortMeasurementsApiCard: React.FC<CohortMeasurementsApiCardProps> = ({ c
size="icon"
className="hover:bg-transparent"
aria-label="Copy recent cohort measurements API URL"
onClick={async () => {
try {
await navigator.clipboard.writeText(`https://api.airqo.net/api/v2/devices/measurements/cohorts/${cohortId}/recent?token=YOUR_TOKEN`);
ReusableToast({ message: "Copied", type: "SUCCESS" });
} catch {
ReusableToast({ message: "Copy failed", type: "ERROR" });
}
}}
onClick={() => handleCopy(`https://api.airqo.net/api/v2/devices/measurements/cohorts/${cohortId}/recent?token=YOUR_TOKEN`)}
>
<Copy className="w-4 h-4" />
</Button>
Expand All @@ -51,14 +55,7 @@ const CohortMeasurementsApiCard: React.FC<CohortMeasurementsApiCardProps> = ({ c
size="icon"
className="hover:bg-transparent"
aria-label="Copy historical cohort measurements API URL"
onClick={async () => {
try {
await navigator.clipboard.writeText(`https://api.airqo.net/api/v2/devices/measurements/cohorts/${cohortId}/historical?token=YOUR_TOKEN`);
ReusableToast({ message: "Copied", type: "SUCCESS" });
} catch {
ReusableToast({ message: "Copy failed", type: "ERROR" });
}
}}
onClick={() => handleCopy(`https://api.airqo.net/api/v2/devices/measurements/cohorts/${cohortId}/historical?token=YOUR_TOKEN`)}
>
<Copy className="w-4 h-4" />
</Button>
Expand Down
56 changes: 31 additions & 25 deletions src/vertex/components/features/cohorts/create-cohort.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@ import { useCreateCohortWithDevices } from "@/core/hooks/useCohorts";
import { useNetworks } from "@/core/hooks/useNetworks";
import ReusableInputField from "@/components/shared/inputfield/ReusableInputField";
import ReusableSelectInput from "@/components/shared/select/ReusableSelectInput";
import ReusableToast from "@/components/shared/toast/ReusableToast";
import { DeviceNameParser } from "./device-name-parser";
import { useBanner } from "@/context/banner-context";
import { getApiErrorMessage } from "@/core/utils/getApiErrorMessage";
import {
Form,
FormControl,
Expand Down Expand Up @@ -75,6 +76,8 @@ export function CreateCohortDialog({
onError,
preselectedDevices = EMPTY_PRESELECTED_DEVICES,
}: CreateCohortDialogProps) {
const { showBanner } = useBanner();

const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
Expand Down Expand Up @@ -154,7 +157,26 @@ export function CreateCohortDialog({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, preselectedDevices]);

const { mutate: createCohort, isPending } = useCreateCohortWithDevices();
const { mutate: createCohort, isPending } = useCreateCohortWithDevices({
onSuccess: (response) => {
if (response?.cohort) {
setCreatedCohort(response.cohort);
setStep("success");
onSuccess?.(response);
} else {
onSuccess?.(response);
onOpenChange(false);
}
},
onError: (error) => {
showBanner({
severity: 'error',
message: `Failed to create cohort: ${getApiErrorMessage(error)}`,
scoped: true,
});
onError?.(error);
},
});

const handleCancel = () => {
if (step === 'form') {
Expand Down Expand Up @@ -191,9 +213,10 @@ export function CreateCohortDialog({
.filter(Boolean) as string[];

if (matchedIds.length === 0) {
ReusableToast({
showBanner({
severity: 'warning',
message: 'No matching devices found. Please ensure the devices exist in the selected network.',
type: 'WARNING',
scoped: true,
});
return;
}
Expand All @@ -207,9 +230,10 @@ export function CreateCohortDialog({
const notFoundCount = deviceNames.length - importedCount;

if (notFoundCount > 0) {
ReusableToast({
showBanner({
severity: 'warning',
message: `Imported ${importedCount} device${importedCount !== 1 ? 's' : ''}. ${notFoundCount} not found.`,
type: 'WARNING',
scoped: true,
});
}
};
Expand All @@ -226,25 +250,7 @@ export function CreateCohortDialog({
const derivedName = isOrganizational
? buildCohortName(values.city || "", values.projectName || "", values.funder)
: (values.name || "").trim();
createCohort(
{ name: derivedName, network: values.network, deviceIds: values.devices, cohort_tags: values.cohort_tags },
{
onSuccess: (response) => {
if (response?.cohort) {
setCreatedCohort(response.cohort);
setStep("success");
onSuccess?.(response);
} else {
// Fallback if response structure is unexpected
onSuccess?.(response);
onOpenChange(false);
}
},
onError: (error) => {
onError?.(error);
},
},
);
createCohort({ name: derivedName, network: values.network, deviceIds: values.devices, cohort_tags: values.cohort_tags });
};

const getDialogConfig = () => {
Expand Down
Loading
Loading