Эх сурвалжийг харах

feat: offer known field paths instead of a bare text box

Path-shaped settings (if-condition/switch/filter field, loop/aggregate/
split-out/sort-limit-dedupe inputField) now suggest the fields the
immediate upstream node actually reports, instead of relying on someone
typing a path from memory. That is exactly the bug that let an if-condition
keep pointing at data.result.generate after its upstream node changed from
a code node to a set-fields node - the path silently resolved to nothing
and image generation was skipped with no error.

- New webui/src/utils/fieldPaths.ts flattens the nested field tree used by
  the Available Data panel into full dotted paths (data.result.generate,
  not just data.result), and restricts suggestions to the direct upstream
  node - a bare path can only ever read this node's own "data" input, never
  another node by name.
- ExpressionInput and ConditionBuilder now flag a configured path that does
  not match any known field, and say plainly when the shape can't be
  determined yet (no schema, no run) instead of showing an empty list that
  looks the same as "nothing is wrong".
- Suggestions come from the upstream node's outputSchema before a run, and
  from real execution/pinned data afterwards, so a node like code (whose
  output nests dynamically) only offers data.result once - and offers
  data.result.* only once real data shows what is actually inside.
- Typing a path by hand is still always possible; nothing is enforced.
fszontagh 1 сар өмнө
parent
commit
0755cbff86

+ 50 - 20
webui/src/components/ConditionBuilder.tsx

@@ -134,27 +134,57 @@ export function ConditionBuilder({
                     Field Path
                   </label>
                   <div className="relative">
-                    <input
-                      type="text"
-                      value={condition.field}
-                      onChange={(e) =>
-                        updateCondition(index, { field: e.target.value })
-                      }
-                      placeholder="e.g., data.status or data.user.email"
-                      className="w-full px-3 py-2 border border-gray-200 dark:border-slate-600 rounded-lg text-sm bg-white dark:bg-slate-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
-                      list={`fields-${index}`}
-                    />
-                    {availableFields.length > 0 && (
-                      <datalist id={`fields-${index}`}>
-                        {availableFields.map((field) => (
-                          <option
-                            key={field.path}
-                            value={field.path}
-                            label={`${field.displayName || field.path} (${field.type}) from ${field.nodeName}`}
+                    {(() => {
+                      const trimmed = condition.field.trim()
+                      const isUnknown =
+                        trimmed !== '' &&
+                        availableFields.length > 0 &&
+                        !availableFields.some((f) => f.path === trimmed)
+                      return (
+                        <>
+                          <input
+                            type="text"
+                            value={condition.field}
+                            onChange={(e) =>
+                              updateCondition(index, { field: e.target.value })
+                            }
+                            placeholder="e.g., data.status or data.user.email"
+                            className={`w-full px-3 py-2 border rounded-lg text-sm bg-white dark:bg-slate-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:border-transparent ${
+                              isUnknown
+                                ? 'border-amber-400 dark:border-amber-600 focus:ring-amber-500'
+                                : 'border-gray-200 dark:border-slate-600 focus:ring-primary-500'
+                            }`}
+                            list={`fields-${index}`}
                           />
-                        ))}
-                      </datalist>
-                    )}
+                          {availableFields.length > 0 && (
+                            <datalist id={`fields-${index}`}>
+                              {availableFields.map((field) => (
+                                <option
+                                  key={field.path}
+                                  value={field.path}
+                                  label={`${field.displayName || field.path} (${field.type}) from ${field.nodeName}`}
+                                />
+                              ))}
+                            </datalist>
+                          )}
+                          {isUnknown && (
+                            <p className="mt-1 text-xs text-amber-600 dark:text-amber-400">
+                              "{trimmed}" is not one of the fields the upstream node
+                              reports. It may still be correct if that node's output is
+                              dynamic - otherwise, check it against what actually feeds
+                              this node.
+                            </p>
+                          )}
+                          {availableFields.length === 0 && (
+                            <p className="mt-1 text-xs text-gray-400 dark:text-gray-500">
+                              The shape of the incoming data isn't known yet - connect an
+                              upstream node, or run the workflow once, to see field
+                              suggestions here.
+                            </p>
+                          )}
+                        </>
+                      )
+                    })()}
                   </div>
                 </div>
 

+ 160 - 10
webui/src/components/ExpressionInput.tsx

@@ -132,6 +132,36 @@ export function ExpressionInput({
   const [cursorPosition, setCursorPosition] = useState(0)
   const inputRef = useRef<HTMLTextAreaElement | HTMLInputElement>(null)
   const autocompleteRef = useRef<HTMLDivElement>(null)
+  const pathAutocompleteRef = useRef<HTMLDivElement>(null)
+
+  // A path field is not wrapped in {{ }}, so there is no braces to be "inside
+  // of" - the whole box is the path, and that is what gets suggested against
+  // and replaced. Kept entirely separate from the expression autocomplete
+  // below: same dropdown look, different trigger and different insert.
+  const [showPathAutocomplete, setShowPathAutocomplete] = useState(false)
+  const [pathSelectedIndex, setPathSelectedIndex] = useState(0)
+  const pathFilteredFields = valueKind === 'path'
+    ? availableFields.filter((field) => {
+        const searchText = value.toLowerCase()
+        return searchText === '' || field.path.toLowerCase().includes(searchText)
+      })
+    : []
+  // The field the operator actually typed does not have to be one of the
+  // suggestions - data is dynamic and a list will never cover everything -
+  // but if the shape IS known and this path is not in it, that is worth
+  // saying: it is exactly the state a stale path left behind after an
+  // upstream node changed shape, which is what this whole feature exists to
+  // catch before a run does.
+  const pathIsUnknown =
+    valueKind === 'path' &&
+    value.trim() !== '' &&
+    availableFields.length > 0 &&
+    !availableFields.some((f) => f.path === value.trim())
+  const insertPathField = (field: AvailableField) => {
+    onChange(field.path)
+    setShowPathAutocomplete(false)
+    setTimeout(() => inputRef.current?.focus(), 0)
+  }
 
   // Check if cursor is inside an expression {{ }}
   const isInsideExpression = useCallback((text: string, position: number): { inside: boolean; start: number; filter: string } => {
@@ -163,6 +193,12 @@ export function ExpressionInput({
     onChange(newValue)
     setCursorPosition(newPosition)
 
+    if (valueKind === 'path') {
+      setPathSelectedIndex(0)
+      setShowPathAutocomplete(availableFields.length > 0)
+      return
+    }
+
     const { inside, filter } = isInsideExpression(newValue, newPosition)
     if (inside) {
       setFilterText(filter)
@@ -188,6 +224,8 @@ export function ExpressionInput({
     const newPosition = target.selectionStart || 0
     setCursorPosition(newPosition)
 
+    if (valueKind === 'path') return
+
     const { inside, filter } = isInsideExpression(value, newPosition)
     if (inside && availableFields.length > 0) {
       setFilterText(filter)
@@ -197,6 +235,16 @@ export function ExpressionInput({
     }
   }
 
+  // A path field's dropdown opens on focus too, not only while typing - the
+  // whole point is offering the known fields before anyone has to remember
+  // one, and that has to work the moment the box is clicked into.
+  const handleFocus = () => {
+    if (valueKind === 'path' && availableFields.length > 0) {
+      setPathSelectedIndex(0)
+      setShowPathAutocomplete(true)
+    }
+  }
+
   // Insert selected field
   const insertField = useCallback((field: AvailableField) => {
     const { inside, start } = isInsideExpression(value, cursorPosition)
@@ -233,6 +281,32 @@ export function ExpressionInput({
 
   // Keyboard navigation
   const handleKeyDown = (e: React.KeyboardEvent) => {
+    if (valueKind === 'path') {
+      if (!showPathAutocomplete || pathFilteredFields.length === 0) return
+      switch (e.key) {
+        case 'ArrowDown':
+          e.preventDefault()
+          setPathSelectedIndex((prev) => Math.min(prev + 1, pathFilteredFields.length - 1))
+          break
+        case 'ArrowUp':
+          e.preventDefault()
+          setPathSelectedIndex((prev) => Math.max(prev - 1, 0))
+          break
+        case 'Enter':
+        case 'Tab':
+          if (pathFilteredFields[pathSelectedIndex]) {
+            e.preventDefault()
+            insertPathField(pathFilteredFields[pathSelectedIndex])
+          }
+          break
+        case 'Escape':
+          e.preventDefault()
+          setShowPathAutocomplete(false)
+          break
+      }
+      return
+    }
+
     if (!showAutocomplete) return
 
     switch (e.key) {
@@ -274,6 +348,14 @@ export function ExpressionInput({
       ) {
         setShowAutocomplete(false)
       }
+      if (
+        pathAutocompleteRef.current &&
+        !pathAutocompleteRef.current.contains(e.target as Node) &&
+        inputRef.current &&
+        !inputRef.current.contains(e.target as Node)
+      ) {
+        setShowPathAutocomplete(false)
+      }
     }
 
     document.addEventListener('mousedown', handleClickOutside)
@@ -332,14 +414,19 @@ export function ExpressionInput({
             onSelect={handleSelect}
             onKeyDown={handleKeyDown}
             onDrop={handleDrop}
+            onFocus={handleFocus}
             placeholder={
               placeholder ||
               (valueKind === 'path'
                 ? 'A path such as data.result.items'
                 : 'Enter value or use {{ }} for expressions')
             }
-            className={`w-full px-3 py-2 pr-10 border border-gray-200 dark:border-slate-600 rounded-l-lg text-sm bg-white dark:bg-slate-900 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500 resize-y overflow-y-auto ${
+            className={`w-full px-3 py-2 pr-10 border rounded-l-lg text-sm bg-white dark:bg-slate-900 text-gray-900 dark:text-gray-100 focus:ring-2 focus:border-transparent resize-y overflow-y-auto ${
               isCode ? 'font-mono text-xs' : ''
+            } ${
+              pathIsUnknown
+                ? 'border-amber-400 dark:border-amber-600 focus:ring-amber-500'
+                : 'border-gray-200 dark:border-slate-600 focus:ring-primary-500'
             }`}
             rows={1}
           />
@@ -356,17 +443,80 @@ export function ExpressionInput({
           )}
         </div>
 
-        {/* Expression helper button */}
-        <button
-          onClick={insertExpressionTemplate}
-          className="px-3 border border-l-0 border-gray-200 dark:border-slate-600 rounded-r-lg bg-gray-50 dark:bg-slate-800 hover:bg-gray-100 dark:hover:bg-slate-700 text-gray-600 dark:text-gray-400 flex items-center gap-1"
-          title="Insert expression {{ }}"
-        >
-          <Code className="w-4 h-4" />
-          <ChevronDown className="w-3 h-3" />
-        </button>
+        {/* Expression helper button - a path field takes data.result.items
+            directly, never {{ }}, so this only makes sense for expressions. */}
+        {valueKind !== 'path' && (
+          <button
+            onClick={insertExpressionTemplate}
+            className="px-3 border border-l-0 border-gray-200 dark:border-slate-600 rounded-r-lg bg-gray-50 dark:bg-slate-800 hover:bg-gray-100 dark:hover:bg-slate-700 text-gray-600 dark:text-gray-400 flex items-center gap-1"
+            title="Insert expression {{ }}"
+          >
+            <Code className="w-4 h-4" />
+            <ChevronDown className="w-3 h-3" />
+          </button>
+        )}
       </div>
 
+      {/* Path suggestions - the whole box is filtered against, not just what
+          is inside {{ }}, because a path field has no braces to be inside of. */}
+      {valueKind === 'path' && showPathAutocomplete && pathFilteredFields.length > 0 && (
+        <div
+          ref={pathAutocompleteRef}
+          className="absolute z-50 w-full bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-lg shadow-lg mt-1 max-h-60 overflow-auto"
+        >
+          <div className="p-2 text-xs text-gray-500 dark:text-gray-400 border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-900">
+            Fields from the upstream node
+          </div>
+          {pathFilteredFields.map((field, index) => (
+            <button
+              key={`${field.nodeId}-${field.path}`}
+              onClick={() => insertPathField(field)}
+              className={`w-full px-3 py-2 text-left flex items-center gap-2 hover:bg-gray-50 dark:hover:bg-slate-700 ${
+                index === pathSelectedIndex ? 'bg-primary-50 dark:bg-primary-900/20' : ''
+              }`}
+            >
+              <span
+                className={`px-1.5 py-0.5 rounded text-[10px] font-medium ${
+                  TYPE_COLORS[field.type] || TYPE_COLORS.any
+                }`}
+              >
+                {field.type}
+              </span>
+              <div className="flex-1 min-w-0">
+                <div className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
+                  {field.path}
+                </div>
+                <div className="text-xs text-gray-500 dark:text-gray-400 truncate">
+                  from {field.nodeName}
+                </div>
+              </div>
+            </button>
+          ))}
+        </div>
+      )}
+
+      {/* A path typed in that does not match anything the upstream node
+          reports - the exact state that let a stale path through silently
+          before. Not blocked, since dynamic data can genuinely need a path
+          nothing here knows about - just shown. */}
+      {pathIsUnknown && (
+        <p className="mt-1 text-xs text-amber-600 dark:text-amber-400">
+          "{value.trim()}" is not one of the fields the upstream node reports. It may
+          still be correct if that node's output is dynamic - otherwise, check it
+          against what actually feeds this node.
+        </p>
+      )}
+
+      {/* The shape genuinely could not be worked out - no schema, nothing run
+          yet - said plainly rather than showing an empty list that looks the
+          same as "there is nothing here". */}
+      {valueKind === 'path' && availableFields.length === 0 && (
+        <p className="mt-1 text-xs text-gray-400 dark:text-gray-500">
+          The shape of the incoming data isn't known yet - connect an upstream node,
+          or run the workflow once, to see field suggestions here.
+        </p>
+      )}
+
       {/* Autocomplete dropdown */}
       {showAutocomplete && filteredFields.length > 0 && (
         <div

+ 15 - 14
webui/src/components/workflow/NodeConfigModal.tsx

@@ -19,6 +19,7 @@ import { ConditionBuilder, Condition, AvailableField } from '../ConditionBuilder
 import { ExpressionInput } from '../ExpressionInput'
 import { AvailableDataPanel, FieldInfo } from '../AvailableDataPanel'
 import { IMPORT_SOURCES, ImportChoice } from '../../config/importSources'
+import { directUpstreamFieldPaths } from '../../utils/fieldPaths'
 
 interface SelectedNodeData {
   id: string
@@ -471,13 +472,7 @@ export function NodeConfigModal({
                     }
                     valueKind="path"
                     placeholder="Path to array (e.g., data.items)"
-                    availableFields={getUpstreamOutputFields(selectedNodeData.id).map((f) => ({
-                      path: f.path,
-                      type: f.type,
-                      nodeName: f.nodeName,
-                      nodeId: f.nodeId,
-                      displayName: f.displayName,
-                    }))}
+                    availableFields={directUpstreamFieldPaths(getUpstreamOutputFields(selectedNodeData.id))}
                   />
                   <p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
                     Path to the array to iterate over
@@ -926,13 +921,19 @@ export function NodeConfigModal({
                         }
                         multiline={prop.format === 'textarea'}
                         placeholder={prop.description}
-                        availableFields={getUpstreamOutputFields(selectedNodeData.id).map((f) => ({
-                          path: f.path,
-                          type: f.type,
-                          nodeName: f.nodeName,
-                          nodeId: f.nodeId,
-                          displayName: f.displayName,
-                        }))}
+                        availableFields={
+                          prop.valueKind === 'path'
+                            // A bare path only ever reads this node's own input,
+                            // so only the immediate upstream node's fields apply.
+                            ? directUpstreamFieldPaths(getUpstreamOutputFields(selectedNodeData.id))
+                            : getUpstreamOutputFields(selectedNodeData.id).map((f) => ({
+                                path: f.path,
+                                type: f.type,
+                                nodeName: f.nodeName,
+                                nodeId: f.nodeId,
+                                displayName: f.displayName,
+                              }))
+                        }
                       />
                     )}
                     {prop.description && prop.type !== 'boolean' && (

+ 7 - 3
webui/src/pages/WorkflowEditorPage.tsx

@@ -36,6 +36,7 @@ import { useAutosave } from '../hooks/useAutosave'
 import { diffWorkflows, graphDelta, mergeWorkflows, summariseChanges, type Change } from '../utils/workflowDiff'
 import type { AvailableField } from '../components/ConditionBuilder'
 import type { FieldInfo } from '../components/AvailableDataPanel'
+import { directUpstreamFieldPaths } from '../utils/fieldPaths'
 import { ExecutionListPanel } from '../components/workflow/ExecutionListPanel'
 import { ExecutionViewerBanner } from '../components/workflow/ExecutionViewerBanner'
 import { VersionHistoryPanel } from '../components/workflow/VersionHistoryPanel'
@@ -2730,11 +2731,14 @@ function WorkflowEditorInner() {
     return fields
   }, [nodes, edges, nodeDefsMap, lastExecutionResults, pinnedNodeData, nodeChangeStatus])
 
-  // Convert FieldInfo to AvailableField for ConditionBuilder/ExpressionInput
+  // Fields for a condition's field-path box (if-condition, and anything else
+  // built on ConditionBuilder). A condition field is a bare path, read against
+  // this node's own input - so, same as any other path setting, only the
+  // immediate upstream node's fields are offered. See directUpstreamFieldPaths.
   const getAvailableFieldsForCondition = useCallback((nodeId: string): AvailableField[] => {
     const fieldInfos = getUpstreamOutputFields(nodeId)
-    return fieldInfos.map((f) => ({
-      path: `data.${f.path}`,
+    return directUpstreamFieldPaths(fieldInfos).map((f) => ({
+      path: f.path,
       type: f.type,
       nodeName: f.nodeName,
       displayName: f.displayName,

+ 64 - 0
webui/src/utils/fieldPaths.ts

@@ -0,0 +1,64 @@
+import { FieldInfo } from '../components/AvailableDataPanel'
+
+// A single field, stripped of its nesting - one entry per node in the tree
+// (both the objects and arrays along the way, and their leaves), so a path
+// field can point at any of them: "data.result" as much as
+// "data.result.generate".
+export interface FlatField {
+  path: string
+  type: string
+  nodeName: string
+  nodeId: string
+  displayName?: string
+  isDirectUpstream?: boolean
+  targetInput?: string
+}
+
+// getUpstreamOutputFields only nests deeper fields under .children, for the
+// tree view in AvailableDataPanel. Anything that offers a path to type into -
+// a datalist, an autocomplete dropdown - needs every path as its own entry,
+// not a tree, so this walks it once and hands back a flat list.
+export function flattenFieldInfos(fields: FieldInfo[]): FlatField[] {
+  const out: FlatField[] = []
+  const walk = (list: FieldInfo[]) => {
+    for (const f of list) {
+      out.push({
+        path: f.path,
+        type: f.type,
+        nodeName: f.nodeName,
+        nodeId: f.nodeId,
+        displayName: f.displayName,
+        isDirectUpstream: f.isDirectUpstream,
+        targetInput: f.targetInput,
+      })
+      if (f.children && f.children.length > 0) walk(f.children)
+    }
+  }
+  walk(fields)
+  return out
+}
+
+// A setting that takes a bare path - not a {{ }} expression - can only ever
+// read from this node's own input ("data"), never from another node by name.
+// That input is exactly what the immediate upstream node produced, so those
+// are the only fields worth suggesting here: anything further back on the
+// canvas is not reachable through a plain path, and offering it would be a
+// suggestion that looks right and resolves to nothing - the same shape of bug
+// this whole feature exists to prevent.
+export function directUpstreamFieldPaths(fields: FieldInfo[]): FlatField[] {
+  return flattenFieldInfos(fields)
+    .filter((f) => f.isDirectUpstream)
+    .map((f) => ({
+      ...f,
+      path: `${f.targetInput || 'data'}.${f.path}`,
+    }))
+}
+
+// Whether a configured path matches one of the known fields. An empty path
+// is not "unknown" - there is simply nothing typed yet - so that is left for
+// the caller (usually a required-field check) to flag separately.
+export function isKnownPath(path: string, fields: FlatField[]): boolean {
+  const trimmed = (path || '').trim()
+  if (!trimmed) return true
+  return fields.some((f) => f.path === trimmed)
+}