فهرست منبع

feat: edit list settings as rows instead of hand-written JSON

Every setting holding a list fell back to a textarea labelled "Array
configuration: Use JSON format". Switch rules, Filter conditions, sort keys,
Set's fields and its fields-to-remove were all edited by typing JSON - and badly,
because the handler parsed on every keystroke and silently dropped anything that
did not parse. Deleting a character to fix a value made the whole edit vanish.

One editor covers all of them, driven by the schema's items rather than by
knowing what any node does. A list of objects becomes a row per entry with a
control per property - a dropdown for an enum, a checkbox for a boolean, a
number box for a number - and a list of plain strings becomes a list of boxes.
Add and remove per row. An unknown key already in an entry is preserved rather
than dropped, so data written before this survives being edited.

Switch gets a warning it needed: its outputs are named by position, so removing
an entry renames every output after it and the connections follow the name to a
different rule. That was silent before and is now stated where the removing
happens.

An empty number box stays empty rather than becoming 0, so a node still falls
back to its own default instead of being handed a value nobody chose.

Verified in the browser: Switch shows Label / Operator / Value rows with the
port warning, Set shows Name / Value rows plus a plain list for fields to
remove, and the JSON fallback is gone from both. Full suite 57/57.
fszontagh 1 ماه پیش
والد
کامیت
d512c339e4
2فایلهای تغییر یافته به همراه191 افزوده شده و 16 حذف شده
  1. 183 0
      webui/src/components/workflow/ArrayFieldEditor.tsx
  2. 8 16
      webui/src/components/workflow/NodeConfigModal.tsx

+ 183 - 0
webui/src/components/workflow/ArrayFieldEditor.tsx

@@ -0,0 +1,183 @@
+import { Plus, Trash2, AlertTriangle } from 'lucide-react'
+
+interface ArrayFieldEditorProps {
+  fieldKey: string
+  prop: any
+  value: any
+  // The node's whole schema, so this can tell whether the array it is editing
+  // is the one a node derives its output ports from.
+  schema: any
+  onChange: (next: any[]) => void
+}
+
+/**
+ * Editor for a setting that holds a list.
+ *
+ * These used to fall back to a JSON textarea - "Array configuration: Use JSON
+ * format" - which meant Switch rules, Filter conditions, sort keys and Set's
+ * fields were all edited by hand-writing JSON, with a silent failure if you got
+ * a comma wrong: the parse threw and the keystroke was dropped.
+ *
+ * The shape comes from the schema's items, so this covers all of them without
+ * knowing what any node does: a list of objects becomes a row per entry with a
+ * control per property, and a list of plain strings becomes a list of boxes.
+ */
+export function ArrayFieldEditor({ fieldKey, prop, value, schema, onChange }: ArrayFieldEditorProps) {
+  const rows: any[] = Array.isArray(value) ? value : []
+  const itemSchema = prop?.items || {}
+  const itemProps: Record<string, any> =
+    itemSchema.type === 'object' && itemSchema.properties ? itemSchema.properties : {}
+  const isObjectList = Object.keys(itemProps).length > 0
+
+  // Some nodes grow an output port per entry - Switch does. The ports are named
+  // by position, so removing an entry renames every port after it and the edges
+  // that were attached follow the name to a different rule. Worth saying out
+  // loud rather than letting someone discover it.
+  const drivesOutputs = schema?.dynamicOutputs?.from === fieldKey
+
+  const setRow = (index: number, next: any) => {
+    onChange(rows.map((row, i) => (i === index ? next : row)))
+  }
+
+  const addRow = () => {
+    if (!isObjectList) {
+      onChange([...rows, ''])
+      return
+    }
+    const blank: Record<string, any> = {}
+    for (const [name, p] of Object.entries(itemProps)) {
+      if ((p as any)?.default !== undefined) blank[name] = (p as any).default
+    }
+    onChange([...rows, blank])
+  }
+
+  const removeRow = (index: number) => {
+    onChange(rows.filter((_, i) => i !== index))
+  }
+
+  const inputClass =
+    'w-full px-2 py-1.5 border border-gray-300 dark:border-slate-600 rounded bg-white dark:bg-slate-700 text-gray-900 dark:text-gray-100 text-sm'
+
+  const renderControl = (name: string, p: any, rowValue: any, index: number) => {
+    const current = rowValue?.[name]
+    const write = (v: any) => setRow(index, { ...(rowValue || {}), [name]: v })
+
+    if (Array.isArray(p?.enum)) {
+      return (
+        <select className={inputClass} value={current ?? p.default ?? ''} onChange={(e) => write(e.target.value)}>
+          {p.enum.map((option: any, i: number) => (
+            <option key={String(option)} value={option}>
+              {p.enumLabels?.[i] || (option === '' ? '(none)' : String(option))}
+            </option>
+          ))}
+        </select>
+      )
+    }
+
+    if (p?.type === 'boolean') {
+      return (
+        <label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 py-1.5">
+          <input
+            type="checkbox"
+            checked={current === true}
+            onChange={(e) => write(e.target.checked)}
+            className="rounded border-gray-300 dark:border-slate-600"
+          />
+          {current === true ? 'Yes' : 'No'}
+        </label>
+      )
+    }
+
+    if (p?.type === 'number') {
+      return (
+        <input
+          type="number"
+          className={inputClass}
+          value={current ?? ''}
+          // An empty box is not zero: keeping it empty lets the node fall back
+          // to its own default rather than being handed a number nobody chose.
+          onChange={(e) => write(e.target.value === '' ? '' : Number(e.target.value))}
+        />
+      )
+    }
+
+    return (
+      <input
+        type="text"
+        className={inputClass}
+        value={current ?? ''}
+        placeholder={p?.description ? String(p.description).slice(0, 40) : ''}
+        onChange={(e) => write(e.target.value)}
+      />
+    )
+  }
+
+  return (
+    <div className="space-y-2">
+      {drivesOutputs && rows.length > 0 && (
+        <div className="flex items-start gap-1.5 text-[11px] text-amber-600 dark:text-amber-400">
+          <AlertTriangle className="w-3.5 h-3.5 shrink-0 mt-px" />
+          This node has one output per entry, named by position. Removing an
+          entry renames the outputs after it, and any connections follow the
+          name - so they end up on the next rule along. Check the canvas after.
+        </div>
+      )}
+
+      {rows.length === 0 && (
+        <div className="text-sm text-gray-500 dark:text-gray-400">Nothing here yet.</div>
+      )}
+
+      {rows.map((row, index) => (
+        <div
+          key={index}
+          className="flex items-start gap-2 rounded-lg border border-gray-200 dark:border-slate-700 p-2"
+        >
+          <div className="text-[11px] text-gray-400 dark:text-gray-500 pt-2 w-5 shrink-0 text-right">
+            {index + 1}
+          </div>
+
+          <div className="flex-1 min-w-0">
+            {isObjectList ? (
+              <div className="grid gap-2" style={{ gridTemplateColumns: `repeat(${Math.min(Object.keys(itemProps).length, 3)}, minmax(0, 1fr))` }}>
+                {Object.entries(itemProps).map(([name, p]: [string, any]) => (
+                  <div key={name}>
+                    <div className="text-[10px] uppercase tracking-wider text-gray-400 dark:text-gray-500 mb-0.5">
+                      {p?.title || name}
+                    </div>
+                    {renderControl(name, p, row, index)}
+                  </div>
+                ))}
+              </div>
+            ) : (
+              <input
+                type="text"
+                className={inputClass}
+                value={row ?? ''}
+                placeholder={itemSchema?.description || ''}
+                onChange={(e) => onChange(rows.map((r, i) => (i === index ? e.target.value : r)))}
+              />
+            )}
+          </div>
+
+          <button
+            type="button"
+            onClick={() => removeRow(index)}
+            title="Remove"
+            className="p-1.5 text-gray-400 hover:text-red-600 dark:hover:text-red-400 rounded shrink-0"
+          >
+            <Trash2 className="w-4 h-4" />
+          </button>
+        </div>
+      ))}
+
+      <button
+        type="button"
+        onClick={addRow}
+        className="flex items-center gap-2 px-3 py-1.5 text-sm text-primary-700 dark:text-primary-300 hover:bg-primary-50 dark:hover:bg-primary-900/20 rounded-lg border border-primary-200 dark:border-primary-800"
+      >
+        <Plus className="w-4 h-4" />
+        Add
+      </button>
+    </div>
+  )
+}

+ 8 - 16
webui/src/components/workflow/NodeConfigModal.tsx

@@ -6,6 +6,7 @@ import { useTheme } from '../../contexts/ThemeContext'
 import { ConfiguratorFields, ConfigTarget } from './ConfiguratorFields'
 import { NodeOptionsSelect } from './NodeOptionsSelect'
 import { groupFields, isCredentialKey } from './fieldGroups'
+import { ArrayFieldEditor } from './ArrayFieldEditor'
 import { NodeDefinition, workflowsApi } from '../../api/workflows'
 import { credentialsApi, CredentialInfo } from '../../api/credentials'
 import { ConditionBuilder, Condition, AvailableField } from '../ConditionBuilder'
@@ -615,22 +616,13 @@ export function NodeConfigModal({
                         ))}
                       </select>
                     ) : prop.type === 'array' ? (
-                      <div className="text-sm text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-slate-900 px-3 py-2 rounded">
-                        Array configuration: Use JSON format
-                        <textarea
-                          value={JSON.stringify(editingConfig[key] || [], null, 2)}
-                          onChange={(e) => {
-                            try {
-                              const parsed = JSON.parse(e.target.value)
-                              onConfigChange({ ...editingConfig, [key]: parsed })
-                            } catch {
-                              // Invalid JSON, ignore
-                            }
-                          }}
-                          rows={5}
-                          className="w-full mt-2 px-3 py-2 border border-gray-200 dark:border-slate-600 rounded-lg font-mono text-xs bg-white dark:bg-slate-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
-                        />
-                      </div>
+                      <ArrayFieldEditor
+                        fieldKey={key}
+                        prop={prop}
+                        value={editingConfig[key]}
+                        schema={nodeDef}
+                        onChange={(next) => onConfigChange({ ...editingConfig, [key]: next })}
+                      />
                     ) : prop.type === 'object' ? (
                       <div className="text-sm text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-slate-900 px-3 py-2 rounded">
                         Object configuration: Use JSON format