diff --git a/src/vertex/app/changelog.md b/src/vertex/app/changelog.md index f8a51098a8..25bfd67c6b 100644 --- a/src/vertex/app/changelog.md +++ b/src/vertex/app/changelog.md @@ -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`. + +
+Changes (2) + +- **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. + +
+ +
+Files Updated (2) + +- `src/vertex/components/features/devices/import-device-modal.tsx` [MODIFIED] +- `src/vertex/core/hooks/useDevices.ts` [MODIFIED] + +
+ +--- + ## Version 1.23.52 **Released:** May 27, 2026 diff --git a/src/vertex/components/features/devices/import-device-modal.tsx b/src/vertex/components/features/devices/import-device-modal.tsx index b6764a978c..3cd833b59a 100644 --- a/src/vertex/components/features/devices/import-device-modal.tsx +++ b/src/vertex/components/features/devices/import-device-modal.tsx @@ -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 }, @@ -57,6 +58,7 @@ const ImportDeviceModal: React.FC = ({ writeKey: "", readKey: "", api_code: "", + authRequired: true, tags: [] as string[], }); @@ -70,7 +72,7 @@ const ImportDeviceModal: React.FC = ({ const [fieldMapping, setFieldMapping] = useState>({}); const [mappingMode, setMappingMode] = useState(false); const [previewMode, setPreviewMode] = useState(false); - const [transformedPreview, setTransformedPreview] = useState[]>([]); + const [transformedPreview, setTransformedPreview] = useState[]>([]); const { showBanner, hideBanner } = useBanner(); const { showBannerWithDelay } = useBannerWithDelay(); const importDevice = useImportDevice(); @@ -157,6 +159,9 @@ const ImportDeviceModal: React.FC = ({ } 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); } @@ -197,7 +202,7 @@ const ImportDeviceModal: React.FC = ({ 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; } @@ -207,7 +212,7 @@ const ImportDeviceModal: React.FC = ({ 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')) { @@ -216,7 +221,7 @@ const ImportDeviceModal: React.FC = ({ 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] || {}); @@ -225,10 +230,10 @@ const ImportDeviceModal: React.FC = ({ 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 }); } }; @@ -236,17 +241,17 @@ const ImportDeviceModal: React.FC = ({ 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; } @@ -255,21 +260,40 @@ const ImportDeviceModal: React.FC = ({ (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 = {}; + const invalidAuthRows: number[] = []; + + const transformedDevices = parsedData.map((row, rowIndex) => { + const device: Record = {}; 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); + } + } 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; } @@ -277,6 +301,15 @@ const ImportDeviceModal: React.FC = ({ 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); @@ -290,7 +323,13 @@ const ImportDeviceModal: React.FC = ({ const deviceDataToSend = { ...formData }; (Object.keys(deviceDataToSend) as Array).forEach((key) => { - if (!deviceDataToSend[key]) { + const value = deviceDataToSend[key]; + if ( + value === "" || + value === undefined || + value === null || + (Array.isArray(value) && value.length === 0) + ) { delete deviceDataToSend[key]; } }); @@ -332,7 +371,7 @@ const ImportDeviceModal: React.FC = ({ 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(); @@ -393,7 +432,7 @@ const ImportDeviceModal: React.FC = ({ ); }; - const handleInputChange = (field: string, value: string) => { + const handleInputChange = (field: string, value: string | boolean) => { setFormData((prev) => ({ ...prev, [field]: value, @@ -424,6 +463,7 @@ const ImportDeviceModal: React.FC = ({ writeKey: "", readKey: "", api_code: "", + authRequired: true, tags: [], }); setErrors({}); @@ -552,6 +592,7 @@ const ImportDeviceModal: React.FC = ({ Serial Number Manufacturer Category + Authentication Required Latitude Longitude @@ -563,6 +604,7 @@ const ImportDeviceModal: React.FC = ({ {device.serial_number || '-'} {device.network || '-'} {device.category || '-'} + {device.authRequired === false ? 'No' : 'Yes'} {device.latitude || '-'} {device.longitude || '-'} @@ -758,6 +800,18 @@ const ImportDeviceModal: React.FC = ({ ))} + handleInputChange("authRequired", e.target.value === 'true')} + placeholder="Choose whether authentication is required" + required + > + + + + { description?: string; serial_number: string; api_code?: string; + authRequired: boolean; cohort_id?: string; user_id: string; tags?: string[];