diff --git a/src/components/App/CreateTd.tsx b/src/components/App/CreateTd.tsx index e3c7f180..363b95bf 100644 --- a/src/components/App/CreateTd.tsx +++ b/src/components/App/CreateTd.tsx @@ -85,7 +85,7 @@ const CreateTd: React.FC = ({ throw new Error("CSV file is empty."); } - const data = parseCsv(csvContent, true); + const { data, warnings } = parseCsv(csvContent, true); const parsed = mapCsvToProperties(data); if (!parsed || Object.keys(parsed).length === 0) { @@ -93,7 +93,16 @@ const CreateTd: React.FC = ({ } setProperties(parsed); - setError({ open: false, message: "" }); + + // Display warnings if any + if (warnings.length > 0) { + const warningMessage = warnings + .map((w) => `Row ${w.row}, column "${w.column}": ${w.message}`) + .join("; "); + setError({ open: true, message: `Warnings: ${warningMessage}` }); + } else { + setError({ open: false, message: "" }); + } } catch (err) { setProperties({}); setError({ diff --git a/src/utils/parser.test.ts b/src/utils/parser.test.ts index bf2857b2..182035ec 100644 --- a/src/utils/parser.test.ts +++ b/src/utils/parser.test.ts @@ -19,9 +19,9 @@ describe("parseCsv", () => { const csvContent = `name,type,modbus:address,modbus:entity,modbus:unitID,modbus:quantity,modbus:zeroBasedAddressing,modbus:function,modbus:mostSignificantByte,modbus:mostSignificantWord,href temperature,number,40001,coil,1,2,false,03,true,true,/temperature`; - const result = parseCsv(csvContent, true); + const { data, warnings } = parseCsv(csvContent, true); - expect(result).toEqual([ + expect(data).toEqual([ { name: "temperature", type: "number", @@ -36,6 +36,13 @@ temperature,number,40001,coil,1,2,false,03,true,true,/temperature`; href: "/temperature", }, ]); + expect(warnings).toEqual([ + { + row: 2, + column: "modbus:entity", + message: 'Modbus entity "coil" has incorrect casing; expected "Coil"', + }, + ]); }); test("should handle empty CSV content", () => { @@ -45,14 +52,14 @@ temperature,number,40001,coil,1,2,false,03,true,true,/temperature`; test("should trim header names and values", () => { const csv = ` name , type , modbus:address , modbus:entity , href temperature , number , 40001 , coil , /temperature `; - const result = parseCsv(csv, true); - expect(result[0].name).toBe("temperature"); - expect(result[0].type).toBe("number"); - expect(result[0]["modbus:address"]).toBe("40001"); - expect(result[0]["modbus:entity"]).toBe("coil"); - expect(result[0].href).toBe("/temperature"); + const { data } = parseCsv(csv, true); + expect(data[0].name).toBe("temperature"); + expect(data[0].type).toBe("number"); + expect(data[0]["modbus:address"]).toBe("40001"); + expect(data[0]["modbus:entity"]).toBe("coil"); + expect(data[0].href).toBe("/temperature"); // Header keys trimmed (no spaces around) - expect(Object.keys(result[0])).toContain("name"); + expect(Object.keys(data[0])).toContain("name"); }); test("should remove rows that are entirely empty or whitespace-only", () => { @@ -64,65 +71,65 @@ temperature,number,40001,coil,/temperature humidity,number,40003,holding,/humidity `; - const result = parseCsv(csv, true); - expect(result.length).toBe(2); - expect(result[0].name).toBe("temperature"); - expect(result[1].name).toBe("humidity"); + const { data } = parseCsv(csv, true); + expect(data.length).toBe(2); + expect(data[0].name).toBe("temperature"); + expect(data[1].name).toBe("humidity"); }); test("should keep empty cells as empty strings", () => { const csv = `name,type,modbus:address,modbus:entity,href,unit,minimum,maximum temperature,number,40001,coil,/temperature,,,`; - const result = parseCsv(csv, true); - expect(result[0].unit).toBe(""); // transform sets null/undefined -> "" - expect(result[0].minimum).toBe(""); - expect(result[0].maximum).toBe(""); + const { data } = parseCsv(csv, true); + expect(data[0].unit).toBe(""); // transform sets null/undefined -> "" + expect(data[0].minimum).toBe(""); + expect(data[0].maximum).toBe(""); }); test("should ignore completely blank trailing row", () => { const csv = `name,type,modbus:address,modbus:entity,href temperature,number,40001,coil,/temperature `; - const result = parseCsv(csv, true); - expect(result.length).toBe(1); + const { data } = parseCsv(csv, true); + expect(data.length).toBe(1); }); test("should parse multiple rows preserving string types (dynamicTyping=false)", () => { const csv = `name,type,modbus:address,modbus:entity,href temperature,number,40001,coil,/temperature pressure,number,40002,coil,/pressure`; - const result = parseCsv(csv, true); - expect(result[0]["modbus:address"]).toBe("40001"); - expect(typeof result[0]["modbus:address"]).toBe("string"); + const { data } = parseCsv(csv, true); + expect(data[0]["modbus:address"]).toBe("40001"); + expect(typeof data[0]["modbus:address"]).toBe("string"); }); test("should throw a descriptive error for malformed quoted fields", () => { // Unmatched quote will trigger Papa parse error of type "Quotes" const csv = `name,type,modbus:address,modbus:entity,href "temperature,number,40001,coil,/temperature`; - expect(() => parseCsv(csv, true)).toThrow(/CSV parse failed:/); + expect(() => parseCsv(csv, true)).toThrow(/Row/); }); test("should throw error on parsing a row with missing columns", () => { const csv = `name,type,modbus:address,modbus:entity,href,unit temperature,number,40001,coil,/temperature`; - expect(() => parseCsv(csv, true)).toThrow(/CSV parse failed:/); // absent header cell => undefined key + expect(() => parseCsv(csv, true)).toThrow(/Row/); // absent header cell => undefined key }); test("should filter out rows where all values become empty after trim", () => { const csv = `name,type,modbus:address,modbus:entity,href temperature,number,40001,coil,/temperature - , , , , + , , , , humidity,number,40003,holding,/humidity`; - const result = parseCsv(csv, true); - expect(result.map((r) => r.name)).toEqual(["temperature", "humidity"]); + const { data } = parseCsv(csv, true); + expect(data.map((r) => r.name)).toEqual(["temperature", "humidity"]); }); test("should handle values consisting only of whitespace and convert them to empty strings", () => { const csv = `name,type,modbus:address,modbus:entity,href,unit temperature,number,40001,coil,/temperature, `; - const result = parseCsv(csv, true); - expect(result[0].unit).toBe(""); + const { data } = parseCsv(csv, true); + expect(data[0].unit).toBe(""); }); test("should not include a row where every field resolves to empty string", () => { @@ -130,35 +137,83 @@ temperature,number,40001,coil,/temperature, `; temperature,number,40001,coil,/temperature ,,,, `; - const result = parseCsv(csv, true); - expect(result.length).toBe(1); - expect(result[0].name).toBe("temperature"); + const { data } = parseCsv(csv, true); + expect(data.length).toBe(1); + expect(data[0].name).toBe("temperature"); }); test("should preserve empty href and still keep the row", () => { const csv = `name,type,modbus:address,modbus:entity,href temperature,number,40001,coil,`; - const result = parseCsv(csv, true); - expect(result[0].href).toBe(""); + const { data } = parseCsv(csv, true); + expect(data[0].href).toBe(""); }); test("should parse header with trailing delimiter producing empty last column", () => { const csv = `name,type,modbus:address,modbus:entity,href, temperature,number,40001,coil,/temperature,`; - const result = parseCsv(csv, true); + const { data } = parseCsv(csv, true); // Last header trimmed to "" becomes ignored by Papa (no field name) or blank key // Ensure primary fields still parsed - expect(result[0].name).toBe("temperature"); + expect(data[0].name).toBe("temperature"); }); test("should handle mixture of populated and partially empty rows", () => { const csv = `name,type,modbus:address,modbus:entity,href,unit temperature,number,40001,coil,/temperature,celsius humidity,number,40003,holding,/humidity,`; - const result = parseCsv(csv, true); - expect(result.length).toBe(2); - expect(result[0].unit).toBe("celsius"); - expect(result[1].unit).toBe(""); + const { data } = parseCsv(csv, true); + expect(data.length).toBe(2); + expect(data[0].unit).toBe("celsius"); + expect(data[1].unit).toBe(""); + }); + + test("should collect warnings for invalid types", () => { + const csv = `name,type,modbus:address,modbus:entity,href +temperature,invalid_type,40001,HoldingRegister,/temperature`; + const { data, warnings } = parseCsv(csv, true); + expect(data.length).toBe(1); + expect(warnings).toEqual([ + { + row: 2, + column: "type", + message: 'Invalid type "invalid_type"', + }, + ]); + }); + + test("should collect warnings for invalid modbus entities", () => { + const csv = `name,type,modbus:address,modbus:entity,href +temperature,number,40001,InvalidEntity,/temperature`; + const { data, warnings } = parseCsv(csv, true); + expect(data.length).toBe(1); + expect(warnings).toEqual([ + { + row: 2, + column: "modbus:entity", + message: 'Invalid modbus entity "InvalidEntity"', + }, + ]); + }); + + test("should collect multiple warnings", () => { + const csv = `name,type,modbus:address,modbus:entity,href +temperature,invalid_type,40001,InvalidEntity,/temperature +humidity,string,40002,HoldingRegister,/humidity`; + const { data, warnings } = parseCsv(csv, true); + expect(data.length).toBe(2); + expect(warnings).toEqual([ + { + row: 2, + column: "type", + message: 'Invalid type "invalid_type"', + }, + { + row: 2, + column: "modbus:entity", + message: 'Invalid modbus entity "InvalidEntity"', + }, + ]); }); }); diff --git a/src/utils/parser.ts b/src/utils/parser.ts index a3a3055d..02ca62bb 100644 --- a/src/utils/parser.ts +++ b/src/utils/parser.ts @@ -12,6 +12,27 @@ ********************************************************************************/ import Papa from "papaparse"; +/** ================= CSV WARNING SUPPORT ================= */ + +export type CsvWarning = { + row: number; + column: string; + message: string; +}; + +const VALID_TYPES = ["number", "string", "boolean"]; +const VALID_MODBUS_ENTITIES = [ + "HoldingRegister", + "InputRegister", + "Coil", + "DiscreteInput", +]; + +const VALID_MODBUS_ENTITIES_LOWER = VALID_MODBUS_ENTITIES.map((e) => + e.toLowerCase() +); +/** ====================================================== */ + export type CsvData = { name: string; title?: string; @@ -34,6 +55,9 @@ export type CsvData = { "modbus:timeout"?: string; }; +/** + * Parse CSV and collect warnings + */ type PropertyForm = { op: string | string[]; href: string; @@ -66,47 +90,74 @@ type Properties = { }; /** - * Parses a CSV string into an array of objects of type CsvData. - * @param csvContent - The CSV content as a string. - * @param hasHeaders - Whether the CSV has headers (default: true). - * @param character - The character used to separate values (default: ","). - * @returns An array of objects (if headers are present) or arrays (if no headers). + * Parse CSV and collect warnings */ export const parseCsv = ( csvContent: string, hasHeaders: boolean = true -): CsvData[] => { - if (csvContent === "") throw new Error("CSV content is empty"); +): { data: CsvData[]; warnings: CsvWarning[] } => { + if (!csvContent) throw new Error("CSV content is empty"); + + const warnings: CsvWarning[] = []; const res = Papa.parse(csvContent, { - header: true, + header: hasHeaders, quoteChar: '"', skipEmptyLines: true, dynamicTyping: false, transformHeader: (h) => h.trim(), transform: (value) => (typeof value === "string" ? value.trim() : value), - complete: (results) => { - console.log(results.data, results.errors, results.meta); - }, }); if (res.errors.length) { - // Gather first few errors for context - const msg = res.errors - .slice(0, 3) - .map( - (e) => - `Row ${e.row ?? "?"}: ${e.message}${ - e.code ? ` (code=${e.code})` : "" - }` - ) - .join("; "); - throw new Error(`CSV parse failed: ${msg}`); + throw new Error( + res.errors.map((e) => `Row ${e.row}: ${e.message}`).join("; ") + ); } - return res.data.filter((row) => - Object.values(row).some((v) => v !== "" && v != null) - ); + res.data.forEach((row, index) => { + const rowNum = index + 2; + + if (row.type && !VALID_TYPES.includes(row.type)) { + warnings.push({ + row: rowNum, + column: "type", + message: `Invalid type "${row.type}"`, + }); + } + + if (row["modbus:entity"]) { + const entityValue = row["modbus:entity"]; + const entityLower = entityValue.toLowerCase(); + + const matchedIndex = VALID_MODBUS_ENTITIES_LOWER.indexOf(entityLower); + + if (matchedIndex === -1) { + warnings.push({ + row: rowNum, + column: "modbus:entity", + message: `Invalid modbus entity "${entityValue}"`, + }); + } else { + const canonical = VALID_MODBUS_ENTITIES[matchedIndex]; + + if (entityValue !== canonical) { + warnings.push({ + row: rowNum, + column: "modbus:entity", + message: `Modbus entity "${entityValue}" has incorrect casing; expected "${canonical}"`, + }); + } + } + } + }); + + return { + data: res.data.filter((row) => + Object.values(row).some((v) => v !== "" && v != null) + ), + warnings, + }; }; /** diff --git a/src/utils/test/csv-examples.test.ts b/src/utils/test/csv-examples.test.ts new file mode 100644 index 00000000..b11a5cd7 --- /dev/null +++ b/src/utils/test/csv-examples.test.ts @@ -0,0 +1,26 @@ +import { describe, it, expect } from "vitest"; +import { parseCsv } from "../parser"; +import fs from "fs"; +import path from "path"; + +describe("CSV example files", () => { + const validCsv = fs.readFileSync( + path.join(__dirname, "fixtures", "valid.csv"), + "utf-8" + ); + + const invalidCsv = fs.readFileSync( + path.join(__dirname, "fixtures", "invalid.csv"), + "utf-8" + ); + + it("valid.csv should produce no warnings", () => { + const result = parseCsv(validCsv, true); + expect(result.warnings.length).toBe(0); + }); + + it("invalid.csv should produce warnings", () => { + const result = parseCsv(invalidCsv, true); + expect(result.warnings.length).toBeGreaterThan(0); + }); +}); diff --git a/src/utils/test/fixtures/invalid.csv b/src/utils/test/fixtures/invalid.csv new file mode 100644 index 00000000..ddcabf28 --- /dev/null +++ b/src/utils/test/fixtures/invalid.csv @@ -0,0 +1,6 @@ +name,type,modbus:entity,modbus:address,modbus:unitID,modbus:quantity,modbus:zeroBasedAddressing,modbus:function,modbus:mostSignificantByte,modbus:mostSignificantWord,href +temp1,number123,holdingregister,40001,1,1,false,03,true,false,/temp +humidity,number,InvalidRegister,40002,1,1,false,04,false,false,/humidity +pressure,invalid_type,HoldingRegister,40003,1,2,true,03,true,true,/pressure +status,string,coil,00001,1,1,false,01,false,false,/status +alarm,boolean,DiscreteInput,10001,1,1,false,02,false,false,/alarm diff --git a/src/utils/test/fixtures/valid.csv b/src/utils/test/fixtures/valid.csv new file mode 100644 index 00000000..ac331fd4 --- /dev/null +++ b/src/utils/test/fixtures/valid.csv @@ -0,0 +1,6 @@ +name,type,modbus:entity,modbus:address,modbus:unitID,modbus:quantity,modbus:zeroBasedAddressing,modbus:function,modbus:mostSignificantByte,modbus:mostSignificantWord,href +temperature,number,HoldingRegister,40001,1,1,false,03,true,false,/temp +humidity,number,InputRegister,40002,1,1,false,04,false,false,/humidity +pressure,number,HoldingRegister,40003,1,2,true,03,true,true,/pressure +status,boolean,Coil,00001,1,1,false,01,false,false,/status +alarm,boolean,DiscreteInput,10001,1,1,false,02,false,false,/alarm