|
|
@@ -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
|