|
@@ -0,0 +1,195 @@
|
|
|
|
|
+import { useState } from 'react'
|
|
|
|
|
+import { Loader2, RefreshCw, Search, AlertTriangle, List } from 'lucide-react'
|
|
|
|
|
+import { nodesApi } from '../../api/workflows'
|
|
|
|
|
+
|
|
|
|
|
+interface DynamicNodeOptions {
|
|
|
|
|
+ source: 'node'
|
|
|
|
|
+ // Which node type answers the question. Defaults to the node being edited,
|
|
|
|
|
+ // which is the usual case - a node that knows its own service.
|
|
|
|
|
+ node?: string
|
|
|
|
|
+ // Merged over the effective config when asking, to tell the node it is being
|
|
|
|
|
+ // asked rather than run: { listOnly: true }.
|
|
|
|
|
+ config?: Record<string, any>
|
|
|
|
|
+ // Where the list sits in the node's output, and which keys to read from each
|
|
|
|
|
+ // entry.
|
|
|
|
|
+ itemsPath?: string
|
|
|
|
|
+ valueKey?: string
|
|
|
|
|
+ labelKey?: string
|
|
|
|
|
+ // Settings the node cannot answer without. Named so the reason for not
|
|
|
|
|
+ // loading can say which one is missing instead of failing obscurely.
|
|
|
|
|
+ needs?: string[]
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+interface NodeOptionsSelectProps {
|
|
|
|
|
+ fieldKey: string
|
|
|
|
|
+ prop: any
|
|
|
|
|
+ value: any
|
|
|
|
|
+ nodeType: string
|
|
|
|
|
+ workflowId: string
|
|
|
|
|
+ // The node's own config plus anything a connected Configurator supplies, so
|
|
|
|
|
+ // a server address and credential that live on a Configurator still reach
|
|
|
|
|
+ // the node being asked.
|
|
|
|
|
+ effectiveConfig: Record<string, any>
|
|
|
|
|
+ onChange: (value: string) => void
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+interface Choice {
|
|
|
|
|
+ value: string
|
|
|
|
|
+ label: string
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export function NodeOptionsSelect({
|
|
|
|
|
+ prop,
|
|
|
|
|
+ value,
|
|
|
|
|
+ nodeType,
|
|
|
|
|
+ workflowId,
|
|
|
|
|
+ effectiveConfig,
|
|
|
|
|
+ onChange,
|
|
|
|
|
+}: NodeOptionsSelectProps) {
|
|
|
|
|
+ const dynamic = (prop?.dynamicOptions || {}) as DynamicNodeOptions
|
|
|
|
|
+ const [choices, setChoices] = useState<Choice[] | null>(null)
|
|
|
|
|
+ const [loading, setLoading] = useState(false)
|
|
|
|
|
+ const [error, setError] = useState('')
|
|
|
|
|
+ const [filter, setFilter] = useState('')
|
|
|
|
|
+
|
|
|
|
|
+ const missing = (dynamic.needs || []).filter((key) => {
|
|
|
|
|
+ const v = effectiveConfig[key]
|
|
|
|
|
+ return v === undefined || v === null || v === ''
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ const load = async () => {
|
|
|
|
|
+ setLoading(true)
|
|
|
|
|
+ setError('')
|
|
|
|
|
+ try {
|
|
|
|
|
+ const askConfig = { ...effectiveConfig, ...(dynamic.config || {}) }
|
|
|
|
|
+ const output = await nodesApi.options(dynamic.node || nodeType, workflowId, askConfig)
|
|
|
|
|
+
|
|
|
|
|
+ const items = output?.[dynamic.itemsPath || 'items']
|
|
|
|
|
+ if (!Array.isArray(items)) {
|
|
|
|
|
+ setChoices([])
|
|
|
|
|
+ setError('The node did not return a list')
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const valueKey = dynamic.valueKey || 'value'
|
|
|
|
|
+ const labelKey = dynamic.labelKey || valueKey
|
|
|
|
|
+ setChoices(
|
|
|
|
|
+ items.map((item: any) =>
|
|
|
|
|
+ item && typeof item === 'object'
|
|
|
|
|
+ ? { value: String(item[valueKey] ?? ''), label: String(item[labelKey] ?? item[valueKey] ?? '') }
|
|
|
|
|
+ : { value: String(item), label: String(item) }
|
|
|
|
|
+ ).filter((c: Choice) => c.value !== '')
|
|
|
|
|
+ )
|
|
|
|
|
+ } catch (e: any) {
|
|
|
|
|
+ // The node's own error is the useful one - "no credential", "server
|
|
|
|
|
+ // refused" - and the endpoint passes it through as the response body.
|
|
|
|
|
+ const detail = e?.response?.data?.error || e?.message || 'Could not read the list'
|
|
|
|
|
+ setError(detail)
|
|
|
|
|
+ setChoices([])
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ setLoading(false)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const shown = (choices || []).filter((c) =>
|
|
|
|
|
+ filter ? c.label.toLowerCase().includes(filter.toLowerCase()) : true
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ const inputClass =
|
|
|
|
|
+ 'w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-gray-100 text-sm'
|
|
|
|
|
+
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div className="space-y-2">
|
|
|
|
|
+ <div className="flex gap-2">
|
|
|
|
|
+ {/* The value stays typeable. A model can be added to the server after
|
|
|
|
|
+ this list was read, and an expression is a legitimate value here. */}
|
|
|
|
|
+ <input
|
|
|
|
|
+ type="text"
|
|
|
|
|
+ value={value ?? ''}
|
|
|
|
|
+ onChange={(e) => onChange(e.target.value)}
|
|
|
|
|
+ placeholder={prop?.placeholder || 'Choose from the list, or type a name'}
|
|
|
|
|
+ className={inputClass}
|
|
|
|
|
+ />
|
|
|
|
|
+ <button
|
|
|
|
|
+ type="button"
|
|
|
|
|
+ onClick={load}
|
|
|
|
|
+ disabled={loading || missing.length > 0}
|
|
|
|
|
+ title={
|
|
|
|
|
+ missing.length > 0
|
|
|
|
|
+ ? `Fill in ${missing.join(' and ')} first - the list is read from there`
|
|
|
|
|
+ : 'Read the list from the server'
|
|
|
|
|
+ }
|
|
|
|
|
+ className="shrink-0 flex items-center gap-1.5 px-3 py-2 text-sm rounded-lg border border-violet-200 dark:border-violet-800 text-violet-700 dark:text-violet-300 hover:bg-violet-50 dark:hover:bg-violet-900/20 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
|
|
|
+ >
|
|
|
|
|
+ {loading ? (
|
|
|
|
|
+ <Loader2 className="w-4 h-4 animate-spin" />
|
|
|
|
|
+ ) : choices ? (
|
|
|
|
|
+ <RefreshCw className="w-4 h-4" />
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ <List className="w-4 h-4" />
|
|
|
|
|
+ )}
|
|
|
|
|
+ {choices ? 'Refresh' : 'Browse'}
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {missing.length > 0 && (
|
|
|
|
|
+ <div className="flex items-start gap-1.5 text-[11px] text-gray-500 dark:text-gray-400">
|
|
|
|
|
+ <AlertTriangle className="w-3.5 h-3.5 shrink-0 mt-px" />
|
|
|
|
|
+ Fill in {missing.join(' and ')} first - the list is read from there. A
|
|
|
|
|
+ Configurator supplying them counts.
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+
|
|
|
|
|
+ {error && (
|
|
|
|
|
+ <div className="flex items-start gap-1.5 text-[11px] text-red-600 dark:text-red-400">
|
|
|
|
|
+ <AlertTriangle className="w-3.5 h-3.5 shrink-0 mt-px" />
|
|
|
|
|
+ <span className="break-words">{error}</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+
|
|
|
|
|
+ {choices && choices.length > 0 && (
|
|
|
|
|
+ <div className="rounded-lg border border-gray-200 dark:border-slate-700">
|
|
|
|
|
+ <div className="flex items-center gap-2 px-2 py-1.5 border-b border-gray-200 dark:border-slate-700">
|
|
|
|
|
+ <Search className="w-3.5 h-3.5 text-gray-400" />
|
|
|
|
|
+ <input
|
|
|
|
|
+ type="text"
|
|
|
|
|
+ value={filter}
|
|
|
|
|
+ onChange={(e) => setFilter(e.target.value)}
|
|
|
|
|
+ placeholder={`Filter ${choices.length}`}
|
|
|
|
|
+ className="flex-1 bg-transparent text-sm text-gray-900 dark:text-gray-100 outline-none"
|
|
|
|
|
+ />
|
|
|
|
|
+ <span className="text-[11px] text-gray-400">{shown.length}</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div className="max-h-56 overflow-auto">
|
|
|
|
|
+ {shown.length === 0 ? (
|
|
|
|
|
+ <div className="px-3 py-2 text-sm text-gray-500 dark:text-gray-400">
|
|
|
|
|
+ Nothing matches "{filter}"
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ shown.map((choice) => (
|
|
|
|
|
+ <button
|
|
|
|
|
+ key={choice.value}
|
|
|
|
|
+ type="button"
|
|
|
|
|
+ onClick={() => onChange(choice.value)}
|
|
|
|
|
+ className={`w-full text-left px-3 py-1.5 text-sm hover:bg-gray-50 dark:hover:bg-slate-700 ${
|
|
|
|
|
+ choice.value === value
|
|
|
|
|
+ ? 'bg-violet-50 dark:bg-violet-900/20 text-violet-800 dark:text-violet-200'
|
|
|
|
|
+ : 'text-gray-800 dark:text-gray-200'
|
|
|
|
|
+ }`}
|
|
|
|
|
+ >
|
|
|
|
|
+ {choice.label}
|
|
|
|
|
+ </button>
|
|
|
|
|
+ ))
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+
|
|
|
|
|
+ {choices && choices.length === 0 && !error && (
|
|
|
|
|
+ <div className="text-[11px] text-gray-500 dark:text-gray-400">
|
|
|
|
|
+ The server has nothing of this kind.
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )
|
|
|
|
|
+}
|