Skip to content
Open
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
13 changes: 11 additions & 2 deletions src/components/App/CreateTd.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,15 +85,24 @@ const CreateTd: React.FC<CreateTdProps> = ({
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) {
throw new Error("No valid properties found in the CSV file.");
}

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({
Expand Down
135 changes: 95 additions & 40 deletions src/utils/parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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", () => {
Expand All @@ -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", () => {
Expand All @@ -64,101 +71,149 @@ 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", () => {
const csv = `name,type,modbus:address,modbus:entity,href
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"',
},
]);
});
});

Expand Down
101 changes: 76 additions & 25 deletions src/utils/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -34,6 +55,9 @@ export type CsvData = {
"modbus:timeout"?: string;
};

/**
* Parse CSV and collect warnings
*/
type PropertyForm = {
op: string | string[];
href: string;
Expand Down Expand Up @@ -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<CsvData>(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,
};
};

/**
Expand Down
Loading