|
|
@@ -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>
|
|
|
+ )
|
|
|
+}
|