From a7ef8bb30b5d3d65bde8d3451f6841c35432dd09 Mon Sep 17 00:00:00 2001 From: Guillaume De Saint Martin Date: Fri, 14 Aug 2026 11:00:59 +0200 Subject: [PATCH] [Node] fix multiple template+CSV+manual edit import issues --- .../Tasks/ImportSteps/ColumnMappingStep.tsx | 174 +++++++++++++----- .../src/components/Tasks/ImportTask.tsx | 8 +- .../src/lib/__tests__/column-detector.test.ts | 124 +++++++++++++ .../src/lib/__tests__/meta-templates.test.ts | 40 ++++ .../src/lib/column-detector.ts | 20 ++ .../src/lib/meta-templates.ts | 16 +- 6 files changed, 334 insertions(+), 48 deletions(-) diff --git a/packages/tentacles/Services/Interfaces/node_web_interface/src/components/Tasks/ImportSteps/ColumnMappingStep.tsx b/packages/tentacles/Services/Interfaces/node_web_interface/src/components/Tasks/ImportSteps/ColumnMappingStep.tsx index 0548f163d..88e3c2fd8 100644 --- a/packages/tentacles/Services/Interfaces/node_web_interface/src/components/Tasks/ImportSteps/ColumnMappingStep.tsx +++ b/packages/tentacles/Services/Interfaces/node_web_interface/src/components/Tasks/ImportSteps/ColumnMappingStep.tsx @@ -27,10 +27,10 @@ import { import useCustomToast from "@/hooks/useCustomToast" import { type ActionParamDef, isParamValueValid } from "@/lib/action-templates" import { - buildParamValuesForRow, type ColumnMapping, detectColumnsAndTemplates, detectMappingsForTemplate, + mergeParamValuesOnTemplateChange, } from "@/lib/column-detector" import { getAllTemplates, @@ -45,10 +45,48 @@ import { const SENSITIVE_HEADER_PATTERNS = /\b(key|private|secret|password|mnemonic|seed|pk)\b/i +const ACTION_NAME_CSV_HEADER = "name" + function isSensitiveHeader(header: string): boolean { return SENSITIVE_HEADER_PATTERNS.test(header) } +function findHeaderColumnIndex( + headers: string[], + columnHeader: string, +): number { + const normalized = columnHeader.trim().toLowerCase() + return headers.findIndex( + (header) => header.trim().toLowerCase() === normalized, + ) +} + +function filterFromUnmappedColumns( + headers: string[], + unmappedColumns: number[], + excludedColumnHeader: string, +): number[] { + const excludedColumnIndex = findHeaderColumnIndex(headers, excludedColumnHeader) + if (excludedColumnIndex < 0) return unmappedColumns + return unmappedColumns.filter( + (columnIndex) => columnIndex !== excludedColumnIndex, + ) +} + +function computeUnmappedColumns( + headers: string[], + mappings: ColumnMapping[], +): number[] { + const mappedColumnIndices = new Set(mappings.map((mapping) => mapping.columnIndex)) + return filterFromUnmappedColumns( + headers, + headers + .map((_, columnIndex) => columnIndex) + .filter((columnIndex) => !mappedColumnIndices.has(columnIndex)), + ACTION_NAME_CSV_HEADER, + ) +} + // ── Types ────────────────────────────────────────────────────────────── export interface ActionRow { @@ -64,10 +102,39 @@ export interface ActionRow { export interface ColumnMappingStepProps { headers: string[] rows: string[][] + /** Restored rows when returning from Review (skips re-detection) */ + initialActionRows?: ActionRow[] onConfirm: (actions: ActionRow[]) => void onBack: () => void } +function buildActionRowsFromDetection( + headers: string[], + rows: string[][], + detectionResults: ReturnType, +): ActionRow[] { + const nameColumnIndex = findHeaderColumnIndex(headers, ACTION_NAME_CSV_HEADER) + return detectionResults.map((det, idx) => { + const nameFromCsv = + nameColumnIndex >= 0 + ? (rows[idx]?.[nameColumnIndex] ?? "").trim() + : "" + const unmappedColumns = filterFromUnmappedColumns( + headers, + det.unmappedColumns, + ACTION_NAME_CSV_HEADER, + ) + return { + rowIndex: idx, + templateId: det.templateId, + paramValues: det.paramValues, + mappings: det.mappings, + unmappedColumns, + name: nameFromCsv || `Action ${idx + 1}`, + } + }) +} + interface RowParamsCellProps { actionRow: ActionRow headers: string[] @@ -184,11 +251,15 @@ const columnHelper = createColumnHelper() export default function ColumnMappingStep({ headers, rows, + initialActionRows, onConfirm, onBack, }: ColumnMappingStepProps) { const { showSuccessToast, showErrorToast } = useCustomToast() const fileInputRef = useRef(null) + const lastChangedTemplateIdRef = useRef( + initialActionRows?.[0]?.templateId ?? null, + ) // Increment to force re-render after a user template is imported (getAllTemplates reads localStorage) const [, setUserTemplatesVersion] = useState(0) @@ -204,25 +275,42 @@ export default function ColumnMappingStep({ ) const [actionRows, setActionRows] = useState(() => { - const nameIdx = headers.findIndex((h) => h.trim().toLowerCase() === "name") - return initialDetection.map((det, idx) => { - const nameFromCsv = - nameIdx >= 0 ? (rows[idx]?.[nameIdx] ?? "").trim() : "" - const unmappedColumns = - nameIdx >= 0 && det.unmappedColumns.includes(nameIdx) - ? det.unmappedColumns.filter((i) => i !== nameIdx) - : det.unmappedColumns - return { - rowIndex: idx, - templateId: det.templateId, - paramValues: det.paramValues, - mappings: det.mappings, - unmappedColumns, - name: nameFromCsv || `Action ${idx + 1}`, - } - }) + if (initialActionRows && initialActionRows.length > 0) { + return initialActionRows + } + return buildActionRowsFromDetection(headers, rows, initialDetection) }) + const applyTemplateToRows = useCallback( + (templateId: string, rowIndices?: number[]) => { + const template = getTemplateById(templateId) + if (!template) return + + const mappings = detectMappingsForTemplate(template, headers, rows) + const unmappedColumns = computeUnmappedColumns(headers, mappings) + + setLastUsedImportTemplateId(templateId) + setActionRows((prev) => + prev.map((row) => { + if (rowIndices && !rowIndices.includes(row.rowIndex)) { + return row + } + return { + ...row, + templateId, + mappings, + paramValues: mergeParamValuesOnTemplateChange( + row.paramValues, + template, + ), + unmappedColumns, + } + }), + ) + }, + [headers, rows], + ) + const updateRow = useCallback( (rowIndex: number, update: Partial) => { setActionRows((prev) => @@ -240,34 +328,19 @@ export default function ColumnMappingStep({ const handleTemplateChange = useCallback( (rowIndex: number, newTemplateId: string) => { - const template = getTemplateById(newTemplateId) - if (!template) return - - const newMappings = detectMappingsForTemplate(template, headers, rows) - const csvRow = rows[rowIndex] - if (!csvRow) return - - const newParamValues = buildParamValuesForRow( - csvRow, - newMappings, - template, - ) - const mappedCols = new Set(newMappings.map((m) => m.columnIndex)) - const unmappedColumns = headers - .map((_, i) => i) - .filter((i) => !mappedCols.has(i)) - - setLastUsedImportTemplateId(newTemplateId) - updateRow(rowIndex, { - templateId: newTemplateId, - mappings: newMappings, - paramValues: newParamValues, - unmappedColumns, - }) + lastChangedTemplateIdRef.current = newTemplateId + applyTemplateToRows(newTemplateId, [rowIndex]) }, - [headers, rows, updateRow], + [applyTemplateToRows], ) + const handleApplyTemplateToAllRows = useCallback(() => { + const templateId = + lastChangedTemplateIdRef.current ?? actionRows[0]?.templateId + if (!templateId) return + applyTemplateToRows(templateId) + }, [actionRows, applyTemplateToRows]) + const handleParamChange = useCallback( (rowIndex: number, paramKey: string, value: string) => { setActionRows((prev) => @@ -302,6 +375,9 @@ export default function ColumnMappingStep({ resolveMetaTemplate(def) // validate it resolves without errors saveUserMetaTemplate(def) setUserTemplatesVersion((v) => v + 1) + setLastUsedImportTemplateId(def.id) + lastChangedTemplateIdRef.current = def.id + applyTemplateToRows(def.id) showSuccessToast(`Template "${def.label}" imported`) } catch (err) { showErrorToast( @@ -309,7 +385,7 @@ export default function ColumnMappingStep({ ) } }, - [showSuccessToast, showErrorToast], + [applyTemplateToRows, showSuccessToast, showErrorToast], ) // Build dynamic columns based on the union of all param keys across rows @@ -389,7 +465,15 @@ export default function ColumnMappingStep({ or change templates as needed.

-
+
+ 0 ? actionRows : undefined + } onConfirm={handleMappingConfirm} - onBack={() => setCurrentStep("upload")} + onBack={() => { + setActionRows([]) + setCurrentStep("upload") + }} /> )} diff --git a/packages/tentacles/Services/Interfaces/node_web_interface/src/lib/__tests__/column-detector.test.ts b/packages/tentacles/Services/Interfaces/node_web_interface/src/lib/__tests__/column-detector.test.ts index 0570f53f8..5799488e9 100644 --- a/packages/tentacles/Services/Interfaces/node_web_interface/src/lib/__tests__/column-detector.test.ts +++ b/packages/tentacles/Services/Interfaces/node_web_interface/src/lib/__tests__/column-detector.test.ts @@ -5,6 +5,7 @@ import { detectColumnsAndTemplates, detectMappingsForTemplate, extractColumnValues, + mergeParamValuesOnTemplateChange, } from "../column-detector" describe("column-detector", () => { @@ -273,5 +274,128 @@ describe("column-detector", () => { expect(values.ADDR).toBeUndefined() expect(values.AMT).toBe("1.5") }) + + it("applies template defaultValue when mapped column cell is empty", () => { + const row = ["", "bitcoin"] + const mappings = [ + { + columnIndex: 0, + paramKey: "BLOCKCHAIN_FROM", + confidence: "high" as const, + }, + ] + const template = { + id: "transfer", + label: "Transfer", + description: "", + actionTypes: ["transfer"], + params: [ + { + key: "BLOCKCHAIN_FROM", + label: "Transfer Network", + required: true, + type: "text" as const, + defaultValue: "bitcoin", + }, + ], + } + + const values = buildParamValuesForRow(row, mappings, template) + expect(values.BLOCKCHAIN_FROM).toBe("bitcoin") + }) + }) + + describe("mergeParamValuesOnTemplateChange", () => { + const changenowLikeTemplate = { + id: "changenow_like", + label: "ChangeNOW-like", + description: "", + actionTypes: ["trade"], + params: [ + { + key: "ORDER_AMOUNT", + label: "Order Amount", + required: true, + type: "number" as const, + }, + { + key: "EXCHANGE_TO", + label: "Exchange", + required: false, + type: "text" as const, + defaultValue: "changenow", + }, + { + key: "BLOCKCHAIN_FROM", + label: "Transfer Network", + required: true, + type: "text" as const, + defaultValue: "bitcoin", + }, + ], + } + + it("preserves manual edits when template has no default for that param", () => { + const existing = { + ORDER_AMOUNT: "0.5", + EXCHANGE_TO: "binance", + } + const merged = mergeParamValuesOnTemplateChange( + existing, + changenowLikeTemplate, + ) + expect(merged.ORDER_AMOUNT).toBe("0.5") + }) + + it("applies template defaultValue over existing value", () => { + const existing = { + EXCHANGE_TO: "binance", + ORDER_AMOUNT: "1.0", + } + const merged = mergeParamValuesOnTemplateChange( + existing, + changenowLikeTemplate, + ) + expect(merged.EXCHANGE_TO).toBe("changenow") + }) + + it("applies template default for params not in existing values", () => { + const existing = { ORDER_AMOUNT: "0.5" } + const merged = mergeParamValuesOnTemplateChange( + existing, + changenowLikeTemplate, + ) + expect(merged.BLOCKCHAIN_FROM).toBe("bitcoin") + }) + + it("drops keys not in the new template", () => { + const existing = { + ORDER_AMOUNT: "0.5", + LEGACY_PARAM: "keep-me-out", + } + const merged = mergeParamValuesOnTemplateChange( + existing, + changenowLikeTemplate, + ) + expect(merged.LEGACY_PARAM).toBeUndefined() + }) + + it("keeps empty existing value when template has no default", () => { + const existing = { ORDER_AMOUNT: "" } + const merged = mergeParamValuesOnTemplateChange( + existing, + changenowLikeTemplate, + ) + expect(merged.ORDER_AMOUNT).toBe("") + }) + + it("omits param when existing has no value and template has no default", () => { + const existing = { EXCHANGE_TO: "binance" } + const merged = mergeParamValuesOnTemplateChange( + existing, + changenowLikeTemplate, + ) + expect(merged.ORDER_AMOUNT).toBeUndefined() + }) }) }) diff --git a/packages/tentacles/Services/Interfaces/node_web_interface/src/lib/__tests__/meta-templates.test.ts b/packages/tentacles/Services/Interfaces/node_web_interface/src/lib/__tests__/meta-templates.test.ts index 9da38ccb9..cee2f34fd 100644 --- a/packages/tentacles/Services/Interfaces/node_web_interface/src/lib/__tests__/meta-templates.test.ts +++ b/packages/tentacles/Services/Interfaces/node_web_interface/src/lib/__tests__/meta-templates.test.ts @@ -98,6 +98,46 @@ describe("resolveMetaTemplate", () => { expect(exchangeParam?.defaultValue).toBe("kraken") }) + it("applies later-step override as defaultValue for duplicate param keys", () => { + const def: MetaTemplateDef = { + id: "test", + label: "Test", + description: "", + steps: [ + { templateId: "blockchain_wallet_init" }, + { + templateId: "transfer", + overrides: { BLOCKCHAIN_FROM: "bitcoin" }, + }, + ], + } + const resolved = resolveMetaTemplate(def) + const blockchainFrom = resolved.params.find( + (param) => param.key === "BLOCKCHAIN_FROM", + ) + expect(blockchainFrom?.defaultValue).toBe("bitcoin") + }) + + it("preserves first-step override when later duplicate step has no override", () => { + const def: MetaTemplateDef = { + id: "test", + label: "Test", + description: "", + steps: [ + { + templateId: "blockchain_wallet_init", + overrides: { BLOCKCHAIN_FROM: "bitcoin" }, + }, + { templateId: "transfer" }, + ], + } + const resolved = resolveMetaTemplate(def) + const blockchainFrom = resolved.params.find( + (param) => param.key === "BLOCKCHAIN_FROM", + ) + expect(blockchainFrom?.defaultValue).toBe("bitcoin") + }) + it("sets hidden:true on hiddenParams", () => { const def: MetaTemplateDef = { id: "test", diff --git a/packages/tentacles/Services/Interfaces/node_web_interface/src/lib/column-detector.ts b/packages/tentacles/Services/Interfaces/node_web_interface/src/lib/column-detector.ts index c97b5ae1a..58e879e6d 100644 --- a/packages/tentacles/Services/Interfaces/node_web_interface/src/lib/column-detector.ts +++ b/packages/tentacles/Services/Interfaces/node_web_interface/src/lib/column-detector.ts @@ -343,3 +343,23 @@ export function buildParamValuesForRow( } return values } + +/** + * Merge existing param values when the user switches templates. + * Template defaultValue wins; all other params keep current UI values. + * Does not read CSV — avoids overwriting manual edits on template change. + */ +export function mergeParamValuesOnTemplateChange( + existingValues: Record, + template: ActionTemplate, +): Record { + const merged: Record = {} + for (const param of template.params) { + if (param.defaultValue !== undefined) { + merged[param.key] = param.defaultValue + } else if (param.key in existingValues) { + merged[param.key] = existingValues[param.key] ?? "" + } + } + return merged +} diff --git a/packages/tentacles/Services/Interfaces/node_web_interface/src/lib/meta-templates.ts b/packages/tentacles/Services/Interfaces/node_web_interface/src/lib/meta-templates.ts index 02c535592..b826d1709 100644 --- a/packages/tentacles/Services/Interfaces/node_web_interface/src/lib/meta-templates.ts +++ b/packages/tentacles/Services/Interfaces/node_web_interface/src/lib/meta-templates.ts @@ -35,7 +35,8 @@ export interface MetaTemplateDef { /** * Resolve a MetaTemplateDef into a flat ActionTemplate. * - * - Params are merged in step order; first-occurrence-wins on duplicate keys. + * - Params are merged in step order; first-occurrence-wins on duplicate keys, + * but later-step overrides still update defaultValue (last-wins). * - overrides are applied as defaultValue on the matching param. * - hiddenParams sets hidden:true; a hidden+required param without a * defaultValue/override is an error (it would silently block submission). @@ -60,7 +61,18 @@ export function resolveMetaTemplate(def: MetaTemplateDef): ActionTemplate { } for (const param of base.params) { - if (seenKeys.has(param.key)) continue + if (seenKeys.has(param.key)) { + const duplicateOverride = step.overrides?.[param.key] + if (duplicateOverride !== undefined) { + const existingParam = mergedParams.find( + (mergedParam) => mergedParam.key === param.key, + ) + if (existingParam) { + existingParam.defaultValue = duplicateOverride + } + } + continue + } seenKeys.add(param.key) const override = step.overrides?.[param.key]