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

---

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

### Import Device Modal — authRequired Field & Error Handling Refactor

Introduced `authRequired` as a first-class field in the bulk device import flow and migrated inline `setErrors` validation patterns to the centralized `showBanner` system inside `ImportDeviceModal`.

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

- **authRequired Field Added**: Added `authRequired` to `EXPECTED_FIELDS` with auto-detection of CSV header aliases (`auth required`, `authrequired`, `requiresauth`, etc.) and intelligent string-to-boolean mapping for values like `yes/no`, `true/false`, `1/0`, `y/n`. Defaults to `true` when the field is unmapped. The field is also exposed as a UI select input in the single-device import form.
- **Error Handling Migration**: Replaced `setErrors({ general: ... })` patterns in mapping validation (missing required fields, duplicate column mappings, unsupported file types, empty CSV) with `showBanner({ severity: 'error', ..., scoped: true })` to keep error feedback consistent with the rest of the dialog system.

</details>

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

- `src/vertex/components/features/devices/import-device-modal.tsx` [MODIFIED]
- `src/vertex/core/hooks/useDevices.ts` [MODIFIED]

</details>

---

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

Expand Down
88 changes: 71 additions & 17 deletions src/vertex/components/features/devices/import-device-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { getApiErrorMessage } from "@/core/utils/getApiErrorMessage";
const EXPECTED_FIELDS = [
{ key: 'long_name', label: 'Device Name', required: true },
{ key: 'serial_number', label: 'Serial Number', required: true },
{ key: 'authRequired', label: 'Authentication Required', required: true },
{ key: 'latitude', label: 'Latitude', required: false },
{ key: 'longitude', label: 'Longitude', required: false },
{ key: 'api_code', label: 'Device Connection URL', required: false },
Expand Down Expand Up @@ -57,6 +58,7 @@ const ImportDeviceModal: React.FC<ImportDeviceModalProps> = ({
writeKey: "",
readKey: "",
api_code: "",
authRequired: true,
tags: [] as string[],
});

Expand All @@ -70,7 +72,7 @@ const ImportDeviceModal: React.FC<ImportDeviceModalProps> = ({
const [fieldMapping, setFieldMapping] = useState<Record<string, string>>({});
const [mappingMode, setMappingMode] = useState(false);
const [previewMode, setPreviewMode] = useState(false);
const [transformedPreview, setTransformedPreview] = useState<Record<string, string | string[] | number | undefined>[]>([]);
const [transformedPreview, setTransformedPreview] = useState<Record<string, string | string[] | number | boolean | undefined>[]>([]);
const { showBanner, hideBanner } = useBanner();
const { showBannerWithDelay } = useBannerWithDelay();
const importDevice = useImportDevice();
Expand Down Expand Up @@ -157,6 +159,9 @@ const ImportDeviceModal: React.FC<ImportDeviceModalProps> = ({
} else if (key === 'serialnumber') {
const aliases = ['serialnumber', 'locationid', 'serial', 'id'];
matchIdx = normalizedHeaders.findIndex(h => aliases.includes(h));
} else if (key === 'authrequired') {
const aliases = ['authrequired', 'authenticationrequired', 'requiresauth', 'requiredauth', 'auth required'];
matchIdx = normalizedHeaders.findIndex(h => aliases.includes(h));
} else {
matchIdx = normalizedHeaders.findIndex(h => h === key);
}
Expand Down Expand Up @@ -197,7 +202,7 @@ const ImportDeviceModal: React.FC<ImportDeviceModalProps> = ({
setParsedData([]);
setFieldMapping({});
setMappingMode(false);
setErrors({ general: "The uploaded CSV does not contain any devices." });
showBanner({ severity: 'error', message: "The uploaded CSV does not contain any devices.", scoped: true });
return;
}

Expand All @@ -207,7 +212,7 @@ const ImportDeviceModal: React.FC<ImportDeviceModalProps> = ({
setMappingMode(true);
},
error: (err: { message: string }) => {
setErrors({ general: `Failed to parse CSV: ${err.message}` });
showBanner({ severity: 'error', message: `Failed to parse CSV: ${err.message}`, scoped: true });
}
});
} else if (fileName.endsWith('.json')) {
Expand All @@ -216,7 +221,7 @@ const ImportDeviceModal: React.FC<ImportDeviceModalProps> = ({
const json = JSON.parse(text);
const devices = Array.isArray(json) ? json : (json.devices || []);
if (!Array.isArray(devices) || devices.length === 0) {
setErrors({ general: 'JSON file must contain an array of devices' });
showBanner({ severity: 'error', message: 'JSON file must contain an array of devices', scoped: true });
return;
}
const headers = Object.keys(devices[0] || {});
Expand All @@ -225,28 +230,28 @@ const ImportDeviceModal: React.FC<ImportDeviceModalProps> = ({
autoMapFields(headers);
setMappingMode(true);
} catch {
setErrors({ general: 'Invalid JSON file format' });
showBanner({ severity: 'error', message: 'Invalid JSON file format', scoped: true });
}
} else {
setErrors({ general: 'Unsupported file type. Please upload a CSV or JSON file.' });
showBanner({ severity: 'error', message: 'Unsupported file type. Please upload a CSV or JSON file.', scoped: true });
}
};

const handleSubmit = async () => {
setErrors({});
if (mappingMode && parsedData.length > 0) {
if (!formData.network) {
setErrors({ general: "Please select a Sensor Manufacturer for this import." });
showBanner({ severity: 'error', message: "Please select a Sensor Manufacturer.", scoped: true });
return;
}
if (!formData.category) {
setErrors({ general: "Please select a Category for this import." });
showBanner({ severity: 'error', message: "Please select a Category for this import.", scoped: true });
return;
}

const missingRequired = EXPECTED_FIELDS.filter(f => f.required && !fieldMapping[f.key]);
if (missingRequired.length > 0) {
setErrors({ general: `Please map the required fields: ${missingRequired.map(f => f.label).join(', ')}` });
showBanner({ severity: 'error', message: `Please map the required fields: ${missingRequired.map(f => f.label).join(', ')}`, scoped: true });
return;
}

Expand All @@ -255,28 +260,56 @@ const ImportDeviceModal: React.FC<ImportDeviceModalProps> = ({
(header, index) => mappedHeaders.indexOf(header) !== index
);
if (duplicateHeaders.length > 0) {
setErrors({ general: "Each file column can only be mapped once." });
showBanner({ severity: 'error', message: "Each file column can only be mapped once.", scoped: true });
return;
}

const transformedDevices = parsedData.map(row => {
const device: Record<string, string | string[] | number | undefined> = {};
const invalidAuthRows: number[] = [];

const transformedDevices = parsedData.map((row, rowIndex) => {
const device: Record<string, string | string[] | number | boolean | undefined> = {};
EXPECTED_FIELDS.forEach(field => {
const mappedHeader = fieldMapping[field.key];
if (mappedHeader && row[mappedHeader] !== undefined && row[mappedHeader] !== "") {
device[field.key] = row[mappedHeader];
if (field.key === 'authRequired') {
const rawValue = String(row[mappedHeader]).trim().toLowerCase();
const TRUTHY_VALUES = ['true', '1', 'yes', 'y'];
const FALSY_VALUES = ['false', '0', 'no', 'n'];

if (TRUTHY_VALUES.includes(rawValue)) {
device.authRequired = true;
} else if (FALSY_VALUES.includes(rawValue)) {
device.authRequired = false;
} else {
invalidAuthRows.push(rowIndex + 1);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} else {
device[field.key] = row[mappedHeader];
}
}
});

device.network = formData.network;
device.category = formData.category;
if (device.authRequired === undefined) {
device.authRequired = true;
}
if (formData.tags && formData.tags.length > 0) {
device.tags = formData.tags;
}

return device;
});

if (invalidAuthRows.length > 0) {
showBanner({
severity: 'error',
message: `Invalid Authentication Required value on row(s): ${invalidAuthRows.join(', ')}. Accepted values are: yes, no, true, false, 1, 0, y, n.`,
scoped: true,
});
return;
}

setTransformedPreview(transformedDevices);
setMappingMode(false);
setPreviewMode(true);
Expand All @@ -290,7 +323,13 @@ const ImportDeviceModal: React.FC<ImportDeviceModalProps> = ({
const deviceDataToSend = { ...formData };

(Object.keys(deviceDataToSend) as Array<keyof typeof deviceDataToSend>).forEach((key) => {
if (!deviceDataToSend[key]) {
const value = deviceDataToSend[key];
if (
value === "" ||
value === undefined ||
value === null ||
(Array.isArray(value) && value.length === 0)
) {
delete deviceDataToSend[key];
}
});
Expand Down Expand Up @@ -332,7 +371,7 @@ const ImportDeviceModal: React.FC<ImportDeviceModalProps> = ({
const userId = userDetails?._id;
if (!userId) {
logger.warn("User ID is missing");
setErrors({ general: "User ID is missing. Please log in again." });
showBanner({ severity: 'error', message: "User ID is missing. Please log in again.", scoped: true });
return;
}
const cohortId = getCohortId();
Expand Down Expand Up @@ -393,7 +432,7 @@ const ImportDeviceModal: React.FC<ImportDeviceModalProps> = ({
);
};

const handleInputChange = (field: string, value: string) => {
const handleInputChange = (field: string, value: string | boolean) => {
setFormData((prev) => ({
...prev,
[field]: value,
Expand Down Expand Up @@ -424,6 +463,7 @@ const ImportDeviceModal: React.FC<ImportDeviceModalProps> = ({
writeKey: "",
readKey: "",
api_code: "",
authRequired: true,
tags: [],
});
setErrors({});
Expand Down Expand Up @@ -552,6 +592,7 @@ const ImportDeviceModal: React.FC<ImportDeviceModalProps> = ({
<th className="px-4 py-3 font-medium border-b">Serial Number</th>
<th className="px-4 py-3 font-medium border-b">Manufacturer</th>
<th className="px-4 py-3 font-medium border-b">Category</th>
<th className="px-4 py-3 font-medium border-b">Authentication Required</th>
<th className="px-4 py-3 font-medium border-b">Latitude</th>
<th className="px-4 py-3 font-medium border-b">Longitude</th>
</tr>
Expand All @@ -563,6 +604,7 @@ const ImportDeviceModal: React.FC<ImportDeviceModalProps> = ({
<td className="px-4 py-3 font-mono text-xs">{device.serial_number || '-'}</td>
<td className="px-4 py-3">{device.network || '-'}</td>
<td className="px-4 py-3">{device.category || '-'}</td>
<td className="px-4 py-3">{device.authRequired === false ? 'No' : 'Yes'}</td>
<td className="px-4 py-3">{device.latitude || '-'}</td>
<td className="px-4 py-3">{device.longitude || '-'}</td>
</tr>
Expand Down Expand Up @@ -758,6 +800,18 @@ const ImportDeviceModal: React.FC<ImportDeviceModalProps> = ({
))}
</ReusableSelectInput>

<ReusableSelectInput
label="Authentication Required"
id="auth_required"
value={String(formData.authRequired)}
onChange={(e) => handleInputChange("authRequired", e.target.value === 'true')}
placeholder="Choose whether authentication is required"
required
>
<option value="true">True</option>
<option value="false">False</option>
</ReusableSelectInput>

<ReusableInputField
label="Serial Number"
id="serial_number"
Expand Down
1 change: 1 addition & 0 deletions src/vertex/core/hooks/useDevices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -523,6 +523,7 @@ export const useImportDevice = () => {
description?: string;
serial_number: string;
api_code?: string;
authRequired: boolean;
cohort_id?: string;
user_id: string;
tags?: string[];
Expand Down
Loading