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
1 change: 1 addition & 0 deletions src/vertex/app/(authenticated)/devices/my-devices/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,7 @@ const MyDevicesPage = () => {
devices={filteredDevices}
isLoading={isLoading}
error={error}
multiSelect={true}
/>

{/* Modals */}
Expand Down
34 changes: 34 additions & 0 deletions src/vertex/app/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,40 @@

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

---

## Version 1.23.55
**Released:** May 28, 2026

### Bulk Edit & Multi-Select Actions for Devices

Added comprehensive bulk editing capabilities across device management tables, enabling users to efficiently update multiple devices at once. This includes multi-select functionality, a new bulk edit modal, and backend support for bulk updates.

<details>
<summary><strong>Changes (4)</strong></summary>

- **Bulk Edit Modal**: Implemented `BulkEditDevicesModal` allowing users to update fields like Category, Network, Visibility, Auth Required, and Tags across multiple selected devices in a two-step flow (field selection → confirmation).
- **Multi-Select Support**: Enhanced `ReusableTable`, `DevicesTable`, `ClientPaginatedDevicesTable`, and `NetworkDevicesTable` with multi-select functionality and bulk action handling.
- **Bulk Update Hook & API**: Refactored device update logic and added `useUpdateDeviceBulk` hook to support efficient bulk operations in a single API call with proper success/error notifications.
- **UI Integration**: Integrated bulk edit action into device list tables and improved action handling for better user experience.

</details>

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

- `src/vertex/components/features/devices/bulk-edit-device-details-modal.tsx` [NEW]
- `src/vertex/components/features/devices/device-list-table.tsx` [MODIFIED]
- `src/vertex/core/hooks/useDevices.ts` [MODIFIED]
- `src/vertex/core/apis/devices.ts` [MODIFIED]
- `src/vertex/app/(authenticated)/devices/my-devices/page.tsx` [MODIFIED]
- `src/vertex/components/features/devices/client-paginated-devices-table.tsx` [MODIFIED]
- `src/vertex/components/shared/table/ReusableTable.tsx` [MODIFIED]
- `src/vertex/components/features/networks/network-device-list-table.tsx` [MODIFIED]

</details>


---

## Version 1.23.54
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
import React, { useMemo, useState } from "react";
import ReusableDialog from "@/components/shared/dialog/ReusableDialog";
import ReusableSelectInput from "@/components/shared/select/ReusableSelectInput";
import ReusableInputField from "@/components/shared/inputfield/ReusableInputField";
import { DEVICE_CATEGORIES } from "@/core/constants/devices";
import { useUpdateDeviceBulk } from "@/core/hooks/useDevices";

type EditableDeviceField =
| "category"
| "network"
| "visibility"
| "authRequired"
| "tags";

type FieldValue = string | string[] | boolean | null;

interface BulkEditDevicesModalProps {
open: boolean;
onClose: () => void;
deviceIds: string[];
}

export default function BulkEditDevicesModal({
open,
onClose,
deviceIds,
}: BulkEditDevicesModalProps) {
const bulkUpdate = useUpdateDeviceBulk();

const [step, setStep] = useState<"choose_field" | "confirm">("choose_field");
const [selectedField, setSelectedField] = useState<EditableDeviceField | null>(null);
const [value, setValue] = useState<FieldValue>(null);

const fieldOptions = useMemo(
() => [
{ label: "Category", value: "category" },
{ label: "Network", value: "network" },
{ label: "Auth Required", value: "authRequired" },
],
[]
);

const hasValidValue = useMemo(() => {
if (!selectedField) return false;
if (value === null) return false;

if (selectedField === "network") {
return typeof value === "string" && value.trim().length > 0;
}

if (selectedField === "tags") {
return Array.isArray(value);
}

return true;
}, [selectedField, value]);

const reset = () => {
setStep("choose_field");
setSelectedField(null);
setValue(null);
};

const handleClose = () => {
reset();
onClose();
};

const handleProceed = () => {
if (!selectedField || !hasValidValue) return;
setStep("confirm");
};

const handleSubmit = () => {
if (!selectedField || !hasValidValue || deviceIds.length === 0) return;

const updateData = { [selectedField]: value };

bulkUpdate.mutate(
{ deviceIds, updateData },
{
onSuccess: () => handleClose(),
}
);
};

const renderFieldInput = () => {
switch (selectedField) {
case "category":
return (
<ReusableSelectInput
label="Category"
value={(value as string) || ""}
onChange={(e) => setValue(e.target.value)}
>
{DEVICE_CATEGORIES.map((c) => (
<option key={c.value} value={c.value}>
{c.label}
</option>
))}
</ReusableSelectInput>
);

case "network":
return (
<ReusableInputField
label="Network"
value={(value as string) || ""}
onChange={(e) => setValue(e.target.value)}
/>
);


case "authRequired":
return (
<ReusableSelectInput
label={"Auth Required"}
value={String(value ?? "")}
onChange={(e) => setValue(e.target.value === "true")}
>
<option value="true">True</option>
<option value="false">False</option>
</ReusableSelectInput>
);

default:
return null;
}
};

return (
<ReusableDialog
isOpen={open}
onClose={handleClose}
title="Bulk Edit Devices"
size="lg"
secondaryAction={
step === "choose_field"
? { label: "Cancel", onClick: handleClose }
: { label: "Back", onClick: () => setStep("choose_field") }
}
primaryAction={{
label: step === "choose_field" ? "Continue" : "Confirm Update",
onClick: step === "choose_field" ? handleProceed : handleSubmit,
disabled:
step === "choose_field"
? !selectedField || !hasValidValue
: bulkUpdate.isPending || !hasValidValue,
className: step === "confirm" ? "min-w-[140px]" : undefined,
}}
>
{step === "choose_field" && (
<div className="space-y-4 py-2">
<ReusableSelectInput
label="Select field to update"
value={selectedField || ""}
onChange={(e) => {
const newField = e.target.value as EditableDeviceField;
setSelectedField(newField);
setValue(null);
}}
>
<option value="">Choose field</option>
{fieldOptions.map((f) => (
<option key={f.value} value={f.value}>
{f.label}
</option>
))}
</ReusableSelectInput>

{selectedField && (
<div className="pt-2">{renderFieldInput()}</div>
)}
</div>
)}

{step === "confirm" && (
<div className="space-y-4 py-2">
<div className="p-4 border rounded-md bg-muted">
<p className="text-sm font-medium">You are about to update:</p>
<p className="text-sm mt-1">
<b>{selectedField}</b> →{" "}
<span className="text-blue-600 font-medium">
{JSON.stringify(value)}
</span>
</p>
<p className="text-xs text-muted-foreground mt-3">
This will affect <strong>{deviceIds.length}</strong> devices.
</p>
</div>
</div>
)}
</ReusableDialog>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,14 @@
import { ExclamationTriangleIcon } from "@radix-ui/react-icons";
import { Device } from "@/app/types/devices";
import { useRouter } from "next/navigation";
import ReusableTable from "@/components/shared/table/ReusableTable";
import { useMemo } from "react";
import ReusableTable, { TableAction } from "@/components/shared/table/ReusableTable";
import { useCallback, useMemo, useState } from "react";
import { useUserContext } from "@/core/hooks/useUserContext";
import { getColumns, type TableDevice } from "./utils/table-columns";
import { Edit, Plus, Trash2 } from "lucide-react";
import BulkEditDevicesModal from "./bulk-edit-device-details-modal";
import { AssignCohortDevicesDialog } from "../cohorts/assign-cohort-devices";
import { UnassignCohortDevicesDialog } from "../cohorts/unassign-cohort-devices";

interface ClientPaginatedDevicesTableProps {
devices: Device[];
Expand All @@ -33,6 +37,43 @@ export default function ClientPaginatedDevicesTable({
const { userContext, activeGroup } = useUserContext();
const isInternalView = userContext === "personal" && activeGroup?.grp_title?.toLowerCase() === "airqo";

const [selectedDeviceObjects, setSelectedDeviceObjects] = useState<TableDevice[]>([]);
const [showAssignDialog, setShowAssignDialog] = useState(false);
const [showUnassignDialog, setShowUnassignDialog] = useState(false);
const [showBulkEditModal, setShowBulkEditModal] = useState(false);
const [bulkEditDeviceIds, setBulkEditDeviceIds] = useState<string[]>([]);

const handleAssignSuccess = () => {
setSelectedDeviceObjects([]);
setShowAssignDialog(false);
};

const handleUnassignSuccess = () => {
setSelectedDeviceObjects([]);
setShowUnassignDialog(false);
};

const handleAddCohortDeviceActionSubmit = useCallback(
(selectedIds: (string | number)[]) => {
if (!selectedIds.length) return;
setShowAssignDialog(true);
},
[]
);

const handleUnassignActionSubmit = useCallback(
(selectedIds: (string | number)[]) => {
if (!selectedIds.length) return;
setShowUnassignDialog(true);
},
[]
);

const handleBulkEditClose = () => {
setShowBulkEditModal(false);
setBulkEditDeviceIds([]);
};

const handleDeviceClick = (item: unknown) => {
const device = item as Device;
if (onDeviceClick) onDeviceClick(device);
Expand All @@ -58,6 +99,43 @@ export default function ClientPaginatedDevicesTable({
);
}, [isInternalView, hiddenColumns]);

const actions = useMemo(() => {
if (!multiSelect) return [];

const baseActions: TableAction[] = [
{
label: "Add to Cohort",
value: "assign_cohort",
handler: handleAddCohortDeviceActionSubmit,
icon: Plus,
},
{
label: "Bulk Edit Devices",
value: "bulk_edit",
handler: (ids) => {
if (!ids.length) return;

const safeIds = ids.map(String);
setBulkEditDeviceIds(safeIds);
setShowBulkEditModal(true);
},
icon: Edit,
},
{
label: "Remove from Cohort",
value: "unassign_cohort",
handler: handleUnassignActionSubmit,
icon: Trash2,
}
];

return baseActions;
}, [
multiSelect,
handleAddCohortDeviceActionSubmit,
handleUnassignActionSubmit,
]);

return (
<div className={`space-y-4 ${className}`}>
<ReusableTable
Expand All @@ -68,6 +146,8 @@ export default function ClientPaginatedDevicesTable({
pageSize={itemsPerPage}
onRowClick={handleDeviceClick}
multiSelect={multiSelect}
onSelectedItemsChange={(items) => setSelectedDeviceObjects(items as TableDevice[])}
actions={actions}
emptyState={
error ? (
<div className="flex flex-col items-center gap-2">
Expand All @@ -82,6 +162,26 @@ export default function ClientPaginatedDevicesTable({
searchable
searchableColumns={["long_name", "name", "description", "site.name"]}
/>
{/* Assign to Cohort Dialog */}
<AssignCohortDevicesDialog
open={showAssignDialog}
onOpenChange={setShowAssignDialog}
selectedDevices={selectedDeviceObjects}
onSuccess={handleAssignSuccess}
/>

{/* Unassign from Cohort Dialog */}
<UnassignCohortDevicesDialog
open={showUnassignDialog}
onOpenChange={setShowUnassignDialog}
selectedDevices={selectedDeviceObjects}
onSuccess={handleUnassignSuccess}
/>
<BulkEditDevicesModal
open={showBulkEditModal}
onClose={handleBulkEditClose}
deviceIds={bulkEditDeviceIds}
/>
</div>
);
}
Loading
Loading