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]