|
|
@@ -1,5 +1,5 @@
|
|
|
import { useEffect, useState } from 'react'
|
|
|
-import { X, Database, Maximize2, Minimize2, ExternalLink } from 'lucide-react'
|
|
|
+import { X, Database, Maximize2, Minimize2, ExternalLink, Download, Loader2 } from 'lucide-react'
|
|
|
import { useQuery } from '@tanstack/react-query'
|
|
|
import Editor from '@monaco-editor/react'
|
|
|
import { useTheme } from '../../contexts/ThemeContext'
|
|
|
@@ -8,6 +8,7 @@ import { credentialsApi, CredentialInfo } from '../../api/credentials'
|
|
|
import { ConditionBuilder, Condition, AvailableField } from '../ConditionBuilder'
|
|
|
import { ExpressionInput } from '../ExpressionInput'
|
|
|
import { AvailableDataPanel, FieldInfo } from '../AvailableDataPanel'
|
|
|
+import { IMPORT_SOURCES, ImportChoice } from '../../config/importSources'
|
|
|
|
|
|
interface SelectedNodeData {
|
|
|
id: string
|
|
|
@@ -68,6 +69,57 @@ export function NodeConfigModal({
|
|
|
localStorage.setItem('nodeConfigMaximized', String(maximized))
|
|
|
}, [maximized])
|
|
|
|
|
|
+ // Which field is being imported into, and what the service offered.
|
|
|
+ const [importing, setImporting] = useState<{
|
|
|
+ field: string
|
|
|
+ source: string
|
|
|
+ choices: ImportChoice[] | null
|
|
|
+ error: string
|
|
|
+ } | null>(null)
|
|
|
+
|
|
|
+ const openImport = async (field: string, sourceName: string) => {
|
|
|
+ const source = IMPORT_SOURCES[sourceName]
|
|
|
+ if (!source) return
|
|
|
+
|
|
|
+ const address = editingConfig[source.addressField]
|
|
|
+ if (!address) {
|
|
|
+ setImporting({
|
|
|
+ field,
|
|
|
+ source: sourceName,
|
|
|
+ choices: [],
|
|
|
+ error: `Fill in ${source.addressField} first - that is where this reads from.`,
|
|
|
+ })
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ setImporting({ field, source: sourceName, choices: null, error: '' })
|
|
|
+ try {
|
|
|
+ const choices = await source.list(address)
|
|
|
+ setImporting({ field, source: sourceName, choices, error: '' })
|
|
|
+ } catch (error: any) {
|
|
|
+ setImporting({
|
|
|
+ field,
|
|
|
+ source: sourceName,
|
|
|
+ choices: [],
|
|
|
+ error: error?.message || 'Could not read from the service',
|
|
|
+ })
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ const applyImport = async (choice: ImportChoice) => {
|
|
|
+ if (!importing) return
|
|
|
+ const source = IMPORT_SOURCES[importing.source]
|
|
|
+ const address = editingConfig[source.addressField]
|
|
|
+
|
|
|
+ try {
|
|
|
+ const value = await source.fetch(address, choice.id)
|
|
|
+ onConfigChange({ ...editingConfig, [importing.field]: value })
|
|
|
+ setImporting(null)
|
|
|
+ } catch (error: any) {
|
|
|
+ setImporting({ ...importing, error: error?.message || 'Could not read that one' })
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
const sizeClasses = maximized
|
|
|
? 'w-[96vw] h-[92vh] max-w-none'
|
|
|
: showDataPanel
|
|
|
@@ -81,7 +133,7 @@ export function NodeConfigModal({
|
|
|
style={{ minWidth: '32rem', minHeight: '20rem' }}
|
|
|
>
|
|
|
{/* Main configuration area */}
|
|
|
- <div className="flex-1 flex flex-col overflow-hidden">
|
|
|
+ <div className="relative flex-1 flex flex-col overflow-hidden">
|
|
|
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-slate-700">
|
|
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Edit Node: {selectedNodeData.name}</h2>
|
|
|
<div className="flex items-center gap-2">
|
|
|
@@ -459,16 +511,28 @@ export function NodeConfigModal({
|
|
|
<div className="border border-gray-200 dark:border-slate-600 rounded-lg overflow-hidden">
|
|
|
{/* Where the value was authored elsewhere, a way back to
|
|
|
it beats asking the reader to remember the address. */}
|
|
|
- {prop.openUrlFrom && editingConfig[prop.openUrlFrom] && (
|
|
|
- <a
|
|
|
- href={editingConfig[prop.openUrlFrom]}
|
|
|
- target="_blank"
|
|
|
- rel="noreferrer"
|
|
|
- className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-primary-600 dark:text-primary-400 hover:underline border-b border-gray-200 dark:border-slate-600 bg-gray-50 dark:bg-slate-900"
|
|
|
- >
|
|
|
- <ExternalLink className="w-3.5 h-3.5" />
|
|
|
- Open {editingConfig[prop.openUrlFrom]}
|
|
|
- </a>
|
|
|
+ {(prop.importFrom || prop.openUrlFrom) && (
|
|
|
+ <div className="flex items-center gap-3 px-3 py-1.5 text-xs border-b border-gray-200 dark:border-slate-600 bg-gray-50 dark:bg-slate-900">
|
|
|
+ {prop.importFrom && IMPORT_SOURCES[prop.importFrom] && (
|
|
|
+ <button
|
|
|
+ onClick={() => openImport(key, prop.importFrom)}
|
|
|
+ className="flex items-center gap-1.5 text-primary-600 dark:text-primary-400 hover:underline"
|
|
|
+ >
|
|
|
+ <Download className="w-3.5 h-3.5" />
|
|
|
+ {IMPORT_SOURCES[prop.importFrom].actionLabel}
|
|
|
+ </button>
|
|
|
+ )}
|
|
|
+ {prop.openUrlFrom && editingConfig[prop.openUrlFrom] && (
|
|
|
+ <span className="flex items-center gap-1.5 text-gray-500 dark:text-gray-400">
|
|
|
+ <ExternalLink className="w-3.5 h-3.5" />
|
|
|
+ {/* Not a link: ComfyUI answers 403 to anything a
|
|
|
+ browser sends from another origin, so a click
|
|
|
+ would fail. The address is here to be copied
|
|
|
+ into the address bar, where it works. */}
|
|
|
+ <code className="select-all">{editingConfig[prop.openUrlFrom]}</code>
|
|
|
+ </span>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
)}
|
|
|
<Editor
|
|
|
height={maximized ? '60vh' : '360px'}
|
|
|
@@ -514,6 +578,54 @@ export function NodeConfigModal({
|
|
|
})()
|
|
|
)}
|
|
|
</div>
|
|
|
+ {importing && (
|
|
|
+ <div className="absolute inset-0 bg-black/40 flex items-center justify-center z-10">
|
|
|
+ <div className="bg-white dark:bg-slate-800 rounded-lg shadow-xl w-full max-w-lg max-h-[70vh] flex flex-col">
|
|
|
+ <div className="flex items-center justify-between p-3 border-b border-gray-200 dark:border-slate-700">
|
|
|
+ <h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
|
|
+ {IMPORT_SOURCES[importing.source]?.actionLabel}
|
|
|
+ </h3>
|
|
|
+ <button
|
|
|
+ onClick={() => setImporting(null)}
|
|
|
+ className="p-1 hover:bg-gray-100 dark:hover:bg-slate-700 rounded"
|
|
|
+ >
|
|
|
+ <X className="w-4 h-4 text-gray-500 dark:text-gray-400" />
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div className="flex-1 overflow-y-auto p-3 space-y-1">
|
|
|
+ {importing.error && (
|
|
|
+ <p className="text-sm text-red-600 dark:text-red-400">{importing.error}</p>
|
|
|
+ )}
|
|
|
+
|
|
|
+ {!importing.error && importing.choices === null && (
|
|
|
+ <div className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
|
|
|
+ <Loader2 className="w-4 h-4 animate-spin" />
|
|
|
+ Reading from the service
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+
|
|
|
+ {!importing.error && importing.choices?.length === 0 && (
|
|
|
+ <p className="text-sm text-gray-500 dark:text-gray-400">
|
|
|
+ {IMPORT_SOURCES[importing.source]?.emptyMessage}
|
|
|
+ </p>
|
|
|
+ )}
|
|
|
+
|
|
|
+ {importing.choices?.map((choice) => (
|
|
|
+ <button
|
|
|
+ key={choice.id}
|
|
|
+ onClick={() => applyImport(choice)}
|
|
|
+ className="w-full text-left px-3 py-2 rounded hover:bg-gray-100 dark:hover:bg-slate-700"
|
|
|
+ >
|
|
|
+ <div className="text-sm text-gray-900 dark:text-gray-100">{choice.label}</div>
|
|
|
+ <div className="text-xs text-gray-500 dark:text-gray-400">{choice.detail}</div>
|
|
|
+ </button>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+
|
|
|
<div className="flex justify-end gap-2 p-4 border-t border-gray-200 dark:border-slate-700">
|
|
|
<button
|
|
|
onClick={onClose}
|