From 9369f198787b4144277424bef36724fd32e3b9f9 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Thu, 8 Jan 2026 15:40:36 +0100 Subject: [PATCH] fix: Handle flat column keys with dots in testcase table grouping and CSV parsing - Prevent grouping of flat columns with dots in their names (e.g., "agents.md") - Only group columns that came from object expansion (have parentKey property) - Add proper CSV parser to handle quoted fields with embedded newlines and commas - Fix cell value access to prioritize direct key lookup before nested path parsing - Add documentation for expanded column detection logic --- .../TestcasesTableNew/utils/groupColumns.ts | 22 +++++ .../pages/testset/modals/CreateTestset.tsx | 85 +++++++++++++++++-- .../state/entities/testcase/testcaseEntity.ts | 9 +- 3 files changed, 110 insertions(+), 6 deletions(-) diff --git a/web/oss/src/components/TestcasesTableNew/utils/groupColumns.ts b/web/oss/src/components/TestcasesTableNew/utils/groupColumns.ts index d3e94360e..c92737292 100644 --- a/web/oss/src/components/TestcasesTableNew/utils/groupColumns.ts +++ b/web/oss/src/components/TestcasesTableNew/utils/groupColumns.ts @@ -72,10 +72,21 @@ export function getLeafColumnName(key: string): string { return key.substring(lastDotIndex + 1) } +/** + * Check if a column is an expanded column (came from object expansion) + * Expanded columns have parentKey property set + */ +function isExpandedColumn(col: Column): boolean { + return "parentKey" in col && typeof (col as any).parentKey === "string" +} + /** * Recursively group columns into nested structure * This handles deeply nested paths like "a.b.c.d" by creating nested group headers * Respects maxDepth to limit nesting for performance + * + * IMPORTANT: Only groups columns that came from object expansion (have parentKey). + * Columns with dots in their flat key names (e.g., "agents.md") are NOT grouped. */ function groupColumnsRecursive( columns: Column[], @@ -98,6 +109,17 @@ function groupColumnsRecursive( // First pass: categorize columns into groups or standalone (leaf columns) columns.forEach((col) => { + // Only group columns that came from object expansion (have parentKey) + // Flat columns with dots in their names (e.g., "agents.md") should NOT be grouped + if (!isExpandedColumn(col) && currentDepth === 0) { + // Top-level flat column - render as-is, even if it has dots + result.push({ + ...createColumnDef(col, col.name), + __order: orderCounter++, + } as ColumnType & {__order: number}) + return + } + // Get the relative key (remove parent path prefix if present) const relativeKey = parentPath ? col.key.substring(parentPath.length + 1) : col.key const parsed = parseGroupedColumnKey(relativeKey) diff --git a/web/oss/src/components/pages/testset/modals/CreateTestset.tsx b/web/oss/src/components/pages/testset/modals/CreateTestset.tsx index 8fdfd844a..1a37a3126 100644 --- a/web/oss/src/components/pages/testset/modals/CreateTestset.tsx +++ b/web/oss/src/components/pages/testset/modals/CreateTestset.tsx @@ -119,6 +119,81 @@ const CreateTestset: React.FC = ({setCurrent, onCancel}) => { const [previewData, setPreviewData] = useState([]) const setRefreshTrigger = useSetAtom(testsetsRefreshTriggerAtom) + /** + * Parse CSV text properly handling quoted fields with embedded newlines and commas + */ + const parseCSVRows = (text: string): string[][] => { + const rows: string[][] = [] + let currentRow: string[] = [] + let currentField = "" + let inQuotes = false + let i = 0 + + while (i < text.length) { + const char = text[i] + + if (inQuotes) { + if (char === '"') { + // Check for escaped quote ("") + if (i + 1 < text.length && text[i + 1] === '"') { + currentField += '"' + i += 2 + continue + } + // End of quoted field + inQuotes = false + i++ + continue + } + // Inside quotes - add character as-is (including newlines) + currentField += char + i++ + } else { + if (char === '"') { + // Start of quoted field + inQuotes = true + i++ + } else if (char === ",") { + // Field separator + currentRow.push(currentField.trim()) + currentField = "" + i++ + } else if (char === "\n" || (char === "\r" && text[i + 1] === "\n")) { + // Row separator + currentRow.push(currentField.trim()) + if (currentRow.some((field) => field !== "")) { + rows.push(currentRow) + } + currentRow = [] + currentField = "" + i += char === "\r" ? 2 : 1 + } else if (char === "\r") { + // Handle standalone \r as row separator + currentRow.push(currentField.trim()) + if (currentRow.some((field) => field !== "")) { + rows.push(currentRow) + } + currentRow = [] + currentField = "" + i++ + } else { + currentField += char + i++ + } + } + } + + // Handle last field and row + if (currentField || currentRow.length > 0) { + currentRow.push(currentField.trim()) + if (currentRow.some((field) => field !== "")) { + rows.push(currentRow) + } + } + + return rows + } + const parseFileForPreview = async ( file: File, fileType: "CSV" | "JSON", @@ -132,12 +207,12 @@ const CreateTestset: React.FC = ({setCurrent, onCancel}) => { return parsed.slice(0, maxPreviewRows) } } else { - const lines = text.split("\n").filter((line) => line.trim()) - if (lines.length > 0) { - const headers = lines[0].split(",").map((h) => h.trim()) + const csvRows = parseCSVRows(text) + if (csvRows.length > 0) { + const headers = csvRows[0] const rows: GenericObject[] = [] - for (let i = 1; i < Math.min(lines.length, maxPreviewRows + 1); i++) { - const values = lines[i].split(",").map((v) => v.trim()) + for (let i = 1; i < Math.min(csvRows.length, maxPreviewRows + 1); i++) { + const values = csvRows[i] const row: GenericObject = {} headers.forEach((header, idx) => { row[header] = values[idx] || "" diff --git a/web/oss/src/state/entities/testcase/testcaseEntity.ts b/web/oss/src/state/entities/testcase/testcaseEntity.ts index 2c5e467d6..98afa2829 100644 --- a/web/oss/src/state/entities/testcase/testcaseEntity.ts +++ b/web/oss/src/state/entities/testcase/testcaseEntity.ts @@ -976,12 +976,19 @@ export const testcaseCellAtomFamily = atomFamily( return undefined } + // First, try direct key access (handles flat keys with dots like "agents.md") + // This is important because column names can legitimately contain dots + const directValue = (entity as Record)[column] + if (directValue !== undefined) { + return directValue + } + // Handle nested paths (e.g., "VMs_previous_RFP.event") // We need to parse JSON strings for nested access const parts = column.split(".") if (parts.length === 1) { - // Simple top-level access + // Simple top-level access - already tried above, return undefined return get(entity, column) }