From 7b5c2333588cdb76119b4a0a867f99a8d103db13 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Feb 2026 20:25:02 +0000 Subject: [PATCH 1/7] feat: make flowfile_wasm embeddable as a Vue component library Add library build mode so flowfile_wasm can be used as a drop-in `` component in any Vue 3 app, similar to VueFlow. New files: - src/lib/types.ts: Public API types (props, events, exposed methods) - src/lib/FlowfileEditor.vue: Main wrapper component with prop/event bridge - src/lib/plugin.ts: Vue plugin installer (auto-installs Pinia if needed) - src/lib/index.ts: Library entry point exporting component and types - src/styles/editor.css: Scoped CSS (vars under .flowfile-editor-root) - src/utils/iconUrls.ts: Explicit icon imports for library build Modified files: - Canvas.vue: Accept toolbar/node config props, emit execution events, replace Material Icons with inline SVGs, use iconUrls utility - DemoButton.vue, PreviewSettings.vue: Replace Material Icons with SVGs - flow-store.ts: Add output callback mechanism for embeddable mode - theme-store.ts: Support scoped theming (embedded mode skips global DOM) - vite.config.ts: Dual build (app + library via BUILD_MODE=lib env var) - package.json: Add exports, peerDependencies, build:lib script All 192 existing tests pass. Both app and library builds succeed. https://claude.ai/code/session_01SztvepLRjChvuh634qxFnu --- flowfile_wasm/package-lock.json | 24 +- flowfile_wasm/package.json | 35 +- flowfile_wasm/src/components/Canvas.vue | 74 +- flowfile_wasm/src/components/DemoButton.vue | 11 +- .../src/components/nodes/PreviewSettings.vue | 25 +- flowfile_wasm/src/lib/FlowfileEditor.vue | 240 +++ flowfile_wasm/src/lib/index.ts | 33 + flowfile_wasm/src/lib/plugin.ts | 23 + flowfile_wasm/src/lib/types.ts | 113 ++ flowfile_wasm/src/stores/flow-store.ts | 17 +- flowfile_wasm/src/stores/theme-store.ts | 21 +- flowfile_wasm/src/styles/editor.css | 1367 +++++++++++++++++ flowfile_wasm/src/utils/iconUrls.ts | 36 + flowfile_wasm/vite.config.ts | 24 +- 14 files changed, 1996 insertions(+), 47 deletions(-) create mode 100644 flowfile_wasm/src/lib/FlowfileEditor.vue create mode 100644 flowfile_wasm/src/lib/index.ts create mode 100644 flowfile_wasm/src/lib/plugin.ts create mode 100644 flowfile_wasm/src/lib/types.ts create mode 100644 flowfile_wasm/src/styles/editor.css create mode 100644 flowfile_wasm/src/utils/iconUrls.ts diff --git a/flowfile_wasm/package-lock.json b/flowfile_wasm/package-lock.json index 04aedb238..e04233744 100644 --- a/flowfile_wasm/package-lock.json +++ b/flowfile_wasm/package-lock.json @@ -1,11 +1,11 @@ { - "name": "flowfile-wasm", + "name": "flowfile-editor", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "flowfile-wasm", + "name": "flowfile-editor", "version": "0.1.0", "dependencies": { "@ag-grid-community/client-side-row-model": "^31.1.1", @@ -24,10 +24,7 @@ "@vue-flow/core": "^1.42.1", "@vue-flow/minimap": "^1.5.2", "js-yaml": "^4.1.1", - "pinia": "^2.0.16", - "vue": "^3.5.13", - "vue-codemirror": "^6.1.1", - "vue-router": "^4.4.0" + "vue-codemirror": "^6.1.1" }, "devDependencies": { "@vitejs/plugin-vue": "^4.4.1", @@ -35,10 +32,22 @@ "@vue/test-utils": "^2.4.6", "fake-indexeddb": "^6.0.0", "happy-dom": "^15.11.7", + "pinia": "^2.0.16", "typescript": "~5.6.3", "vite": "^4.5.3", "vitest": "^2.1.8", + "vue": "^3.5.13", + "vue-router": "^4.4.0", "vue-tsc": "^2.0.19" + }, + "peerDependencies": { + "pinia": "^2.0.0", + "vue": "^3.3.0" + }, + "peerDependenciesMeta": { + "pinia": { + "optional": true + } } }, "node_modules/@ag-grid-community/client-side-row-model": { @@ -1317,6 +1326,7 @@ "version": "6.6.4", "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "dev": true, "license": "MIT" }, "node_modules/@vue/language-core": { @@ -2250,6 +2260,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/pinia/-/pinia-2.3.1.tgz", "integrity": "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==", + "dev": true, "license": "MIT", "dependencies": { "@vue/devtools-api": "^6.6.3", @@ -3872,6 +3883,7 @@ "version": "4.6.4", "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "dev": true, "license": "MIT", "dependencies": { "@vue/devtools-api": "^6.6.4" diff --git a/flowfile_wasm/package.json b/flowfile_wasm/package.json index 517cda988..709ff137c 100644 --- a/flowfile_wasm/package.json +++ b/flowfile_wasm/package.json @@ -1,17 +1,42 @@ { - "name": "flowfile-wasm", + "name": "flowfile-editor", "version": "0.1.0", - "description": "Minimal WASM-based data flow designer using Pyodide and Polars", + "description": "Embeddable browser-based data flow designer using Pyodide and Polars", "type": "module", + "main": "./dist/flowfile-editor.js", + "module": "./dist/flowfile-editor.js", + "types": "./dist/lib/index.d.ts", + "exports": { + ".": { + "import": "./dist/flowfile-editor.js", + "types": "./dist/lib/index.d.ts" + }, + "./style.css": "./dist/style.css" + }, + "files": [ + "dist", + "README.md" + ], "scripts": { "dev": "vite", "build": "vue-tsc --noEmit && vite build", + "build:lib": "BUILD_MODE=lib vue-tsc --noEmit && BUILD_MODE=lib vite build", + "build:all": "npm run build && npm run build:lib", "preview": "vite preview", "test": "vitest", "test:run": "vitest run", "test:ui": "vitest --ui", "test:coverage": "vitest run --coverage" }, + "peerDependencies": { + "vue": "^3.3.0", + "pinia": "^2.0.0" + }, + "peerDependenciesMeta": { + "pinia": { + "optional": true + } + }, "dependencies": { "@ag-grid-community/client-side-row-model": "^31.1.1", "@ag-grid-community/core": "^31.1.1", @@ -29,9 +54,6 @@ "@vue-flow/core": "^1.42.1", "@vue-flow/minimap": "^1.5.2", "js-yaml": "^4.1.1", - "pinia": "^2.0.16", - "vue": "^3.5.13", - "vue-router": "^4.4.0", "vue-codemirror": "^6.1.1" }, "devDependencies": { @@ -40,9 +62,12 @@ "@vue/test-utils": "^2.4.6", "fake-indexeddb": "^6.0.0", "happy-dom": "^15.11.7", + "pinia": "^2.0.16", "typescript": "~5.6.3", "vite": "^4.5.3", "vitest": "^2.1.8", + "vue": "^3.5.13", + "vue-router": "^4.4.0", "vue-tsc": "^2.0.19" } } diff --git a/flowfile_wasm/src/components/Canvas.vue b/flowfile_wasm/src/components/Canvas.vue index aa952761d..08ef9cbc6 100644 --- a/flowfile_wasm/src/components/Canvas.vue +++ b/flowfile_wasm/src/components/Canvas.vue @@ -46,21 +46,22 @@
-
- - - + -
-
@@ -169,7 +171,7 @@ {{ selectedNodeResult.data.total_rows }} rows {{ selectedNodeResult.data.columns?.length }} columns
@@ -229,7 +231,9 @@ import { MiniMap } from '@vue-flow/minimap' import { Controls } from '@vue-flow/controls' import { useFlowStore } from '../stores/flow-store' import { storeToRefs } from 'pinia' -import type { NodeSettings, FlowEdge, ColumnSchema } from '../types' +import type { NodeSettings, FlowEdge, ColumnSchema, NodeResult } from '../types' +import type { ToolbarConfig, NodeCategoryConfig } from '../lib/types' +import { iconUrls } from '../utils/iconUrls' // AG Grid imports import { AgGridVue } from '@ag-grid-community/vue3' @@ -266,6 +270,29 @@ import DemoButton from './DemoButton.vue' import LayoutControls from './common/LayoutControls.vue' import { useDemo } from '../composables/useDemo' +// Props for embeddable configuration +const props = withDefaults(defineProps<{ + toolbarConfig?: ToolbarConfig + nodeCategoriesConfig?: NodeCategoryConfig[] + readonly?: boolean +}>(), { + readonly: false +}) + +const emit = defineEmits<{ + (e: 'execution-complete', results: Map): void + (e: 'output', data: { nodeId: number; content: string; fileName: string; mimeType: string }): void +}>() + +// Merge toolbar config with defaults (all visible by default) +const effectiveToolbar = computed>(() => ({ + showRun: props.toolbarConfig?.showRun !== false, + showSaveLoad: props.toolbarConfig?.showSaveLoad !== false, + showClear: props.toolbarConfig?.showClear !== false, + showCodeGen: props.toolbarConfig?.showCodeGen !== false, + showDemo: props.toolbarConfig?.showDemo ?? true +})) + const flowStore = useFlowStore() const { nodes: flowNodes, edges: flowEdges, selectedNodeId, nodeResults, isExecuting } = storeToRefs(flowStore) @@ -287,9 +314,9 @@ const nodeTypes: Record = { 'flow-node': markRaw(FlowNode) } -// Get icon URL +// Get icon URL - uses explicit imports for library build compatibility function getIconUrl(iconFile: string): string { - return new URL(`../assets/icons/${iconFile}`, import.meta.url).href + return iconUrls[iconFile] || new URL(`../assets/icons/${iconFile}`, import.meta.url).href } // Node definition interface @@ -669,6 +696,7 @@ function autoSizeColumns() { // Toolbar handlers async function handleRunFlow() { await flowStore.executeFlow() + emit('execution-complete', nodeResults.value) } function handleSaveFlow() { @@ -723,10 +751,20 @@ function handleKeyDown(event: KeyboardEvent) { } } -// Register keyboard shortcuts +// Output callback for embeddable mode +function handleOutputCallback(data: { nodeId: number; content: string; fileName: string; mimeType: string }) { + emit('output', data) +} + +// Register keyboard shortcuts and output callbacks onMounted(async () => { window.addEventListener('keydown', handleKeyDown) + // Register output callback if the store supports it + if (flowStore.onOutput) { + flowStore.onOutput(handleOutputCallback) + } + // Wait for DOM to be fully rendered await nextTick() // Calculate toolbar bottom position for panel positioning @@ -738,6 +776,10 @@ onMounted(async () => { onUnmounted(() => { window.removeEventListener('keydown', handleKeyDown) + // Unregister output callback + if (flowStore.offOutput) { + flowStore.offOutput(handleOutputCallback) + } }) diff --git a/flowfile_wasm/src/components/DemoButton.vue b/flowfile_wasm/src/components/DemoButton.vue index 873031685..54ecb0302 100644 --- a/flowfile_wasm/src/components/DemoButton.vue +++ b/flowfile_wasm/src/components/DemoButton.vue @@ -7,10 +7,10 @@ title="Dismiss" aria-label="Dismiss demo button" > - close +
- play_circle + Try Demo
See it in action!
@@ -26,7 +26,7 @@ :disabled="isLoading" title="Load demo flow" > - play_circle + {{ isLoading ? 'Loading...' : 'Demo' }} @@ -128,8 +128,9 @@ function handleDismiss() { color: var(--color-danger); } -.demo-dismiss-btn .material-icons { - font-size: 16px; +.demo-dismiss-btn svg { + width: 16px; + height: 16px; } /* Tooltip that appears on hover */ diff --git a/flowfile_wasm/src/components/nodes/PreviewSettings.vue b/flowfile_wasm/src/components/nodes/PreviewSettings.vue index 58186c91f..6338c7499 100644 --- a/flowfile_wasm/src/components/nodes/PreviewSettings.vue +++ b/flowfile_wasm/src/components/nodes/PreviewSettings.vue @@ -19,7 +19,7 @@
@@ -59,7 +59,7 @@
- table_chart + Data Preview
@@ -68,13 +68,13 @@
@@ -327,8 +327,9 @@ onUnmounted(() => { color: var(--color-accent); } -.expand-btn .material-icons { - font-size: 18px; +.expand-btn svg { + width: 18px; + height: 18px; } .table-container { @@ -429,8 +430,9 @@ onUnmounted(() => { color: var(--color-text-primary); } -.expanded-title .material-icons { - font-size: 20px; +.expanded-title svg { + width: 20px; + height: 20px; color: var(--color-accent); } @@ -478,8 +480,9 @@ onUnmounted(() => { color: var(--color-danger); } -.action-btn .material-icons { - font-size: 18px; +.action-btn svg { + width: 18px; + height: 18px; } .expanded-content { diff --git a/flowfile_wasm/src/lib/FlowfileEditor.vue b/flowfile_wasm/src/lib/FlowfileEditor.vue new file mode 100644 index 000000000..038079195 --- /dev/null +++ b/flowfile_wasm/src/lib/FlowfileEditor.vue @@ -0,0 +1,240 @@ + + + + + diff --git a/flowfile_wasm/src/lib/index.ts b/flowfile_wasm/src/lib/index.ts new file mode 100644 index 000000000..36aaa8a58 --- /dev/null +++ b/flowfile_wasm/src/lib/index.ts @@ -0,0 +1,33 @@ +// Main component +export { default as FlowfileEditor } from './FlowfileEditor.vue' + +// Plugin for app.use() registration +export { FlowfileEditorPlugin } from './plugin' +export type { FlowfilePluginOptions } from './plugin' + +// Public API types +export type { + FlowfileEditorProps, + FlowfileEditorAPI, + PyodideConfig, + InputDataMap, + InputDataItem, + ThemeConfig, + ToolbarConfig, + NodeCategoryConfig, + OutputData, + EditorError +} from './types' + +// Re-export core types consumers may need +export type { + FlowfileData, + FlowfileNode, + FlowNode, + FlowEdge, + NodeResult, + DataPreview, + ColumnSchema, + NodeSettings, + NodeType +} from '../types' diff --git a/flowfile_wasm/src/lib/plugin.ts b/flowfile_wasm/src/lib/plugin.ts new file mode 100644 index 000000000..efa847085 --- /dev/null +++ b/flowfile_wasm/src/lib/plugin.ts @@ -0,0 +1,23 @@ +import type { App, Plugin } from 'vue' +import { createPinia } from 'pinia' +import FlowfileEditor from './FlowfileEditor.vue' + +export interface FlowfilePluginOptions { + /** Provide an existing Pinia instance if the host app doesn't have one */ + pinia?: ReturnType +} + +export const FlowfileEditorPlugin: Plugin = { + install(app: App, options?: FlowfilePluginOptions) { + // Check if Pinia is already installed + const hasPinia = app.config.globalProperties.$pinia !== undefined + + if (!hasPinia) { + const pinia = options?.pinia ?? createPinia() + app.use(pinia) + } + + // Register the component globally + app.component('FlowfileEditor', FlowfileEditor) + } +} diff --git a/flowfile_wasm/src/lib/types.ts b/flowfile_wasm/src/lib/types.ts new file mode 100644 index 000000000..f47281717 --- /dev/null +++ b/flowfile_wasm/src/lib/types.ts @@ -0,0 +1,113 @@ +/** + * Public API types for the FlowfileEditor embeddable component + */ +import type { FlowfileData, NodeResult } from '../types' + +/** Configuration for Pyodide initialization */ +export interface PyodideConfig { + /** Whether to auto-initialize Pyodide on mount (default: true) */ + autoInit?: boolean + /** Custom Pyodide CDN URL (default: jsdelivr v0.27.7) */ + pyodideUrl?: string +} + +/** Input data that can be passed programmatically to the editor */ +export interface InputDataItem { + /** The data content (CSV or JSON string) */ + content: string + /** Data format (default: 'csv') */ + format?: 'csv' | 'json' + /** CSV delimiter (default: ',') */ + delimiter?: string + /** Whether the CSV has headers (default: true) */ + hasHeaders?: boolean +} + +export type InputDataMap = Record + +/** Theme configuration */ +export interface ThemeConfig { + /** Initial theme mode */ + mode?: 'light' | 'dark' | 'system' +} + +/** Toolbar configuration */ +export interface ToolbarConfig { + /** Show the Run button (default: true) */ + showRun?: boolean + /** Show Save/Load buttons (default: true) */ + showSaveLoad?: boolean + /** Show Clear button (default: true) */ + showClear?: boolean + /** Show Code Generator button (default: true) */ + showCodeGen?: boolean + /** Show Demo button (default: false for embedded) */ + showDemo?: boolean +} + +/** Configuration for which node types are available in the editor */ +export interface NodeCategoryConfig { + name: string + enabled?: boolean + nodes?: string[] +} + +/** Props for the FlowfileEditor component */ +export interface FlowfileEditorProps { + /** Initial flow state to load */ + initialFlow?: FlowfileData + /** Input datasets available to the editor */ + inputData?: InputDataMap + /** Pyodide initialization configuration */ + pyodide?: PyodideConfig + /** Theme configuration */ + theme?: ThemeConfig + /** Toolbar visibility configuration */ + toolbar?: ToolbarConfig + /** Available node categories (null = all) */ + nodeCategories?: NodeCategoryConfig[] + /** Whether the editor is read-only */ + readonly?: boolean + /** Height of the editor (default: '100%') */ + height?: string + /** Width of the editor (default: '100%') */ + width?: string +} + +/** Output data emitted by output nodes */ +export interface OutputData { + nodeId: number + content: string + fileName: string + mimeType: string +} + +/** Error data emitted by the editor */ +export interface EditorError { + type: 'pyodide' | 'execution' | 'load' + message: string +} + +/** Return type for the exposed programmatic API (via template ref) */ +export interface FlowfileEditorAPI { + /** Whether Pyodide is initialized and ready */ + readonly isReady: boolean + /** Whether a flow execution is in progress */ + readonly isExecuting: boolean + /** Programmatically run the entire flow */ + executeFlow: () => Promise + /** Execute a single node */ + executeNode: (nodeId: number) => Promise + /** Export the current flow as FlowfileData */ + exportFlow: () => FlowfileData + /** Import a flow from FlowfileData */ + importFlow: (data: FlowfileData) => boolean + /** Set input data for a named dataset (matched by node_reference) */ + setInputData: (name: string, content: string, options?: { format?: string; delimiter?: string }) => void + /** Get the result/preview for a specific node */ + getNodeResult: (nodeId: number) => NodeResult | undefined + /** Clear the entire flow */ + clearFlow: () => void + /** Initialize Pyodide manually (when autoInit is false) */ + initializePyodide: () => Promise +} diff --git a/flowfile_wasm/src/stores/flow-store.ts b/flowfile_wasm/src/stores/flow-store.ts index 768a2a2e1..5b3a9f404 100644 --- a/flowfile_wasm/src/stores/flow-store.ts +++ b/flowfile_wasm/src/stores/flow-store.ts @@ -103,6 +103,10 @@ export const useFlowStore = defineStore('flow', () => { // File content storage for CSV nodes const fileContents = ref>(new Map()) + // Output callbacks for embeddable mode + type OutputCallback = (data: { nodeId: number; content: string; fileName: string; mimeType: string }) => void + const outputCallbacks = new Set() + // Preview cache state (for lazy loading) const previewCache = ref cb({ + nodeId, + content, + fileName: file_name, + mimeType: mime_type + })) // Create result without content - just metadata result = { success: outputResult.success, @@ -2282,6 +2293,10 @@ result importFromFlowfile, downloadFlowfile, loadFlowfile, - validateFlowfileData + validateFlowfileData, + + // Output callbacks (for embeddable mode) + onOutput: (cb: OutputCallback) => { outputCallbacks.add(cb) }, + offOutput: (cb: OutputCallback) => { outputCallbacks.delete(cb) } } }) \ No newline at end of file diff --git a/flowfile_wasm/src/stores/theme-store.ts b/flowfile_wasm/src/stores/theme-store.ts index 3af77c3eb..837e8cfc4 100644 --- a/flowfile_wasm/src/stores/theme-store.ts +++ b/flowfile_wasm/src/stores/theme-store.ts @@ -5,6 +5,8 @@ export type ThemeMode = "light" | "dark" | "system"; interface ThemeState { mode: ThemeMode; systemPreference: "light" | "dark"; + /** When true, theme is applied only to store state (not to document.documentElement) */ + embedded: boolean; } const THEME_STORAGE_KEY = "flowfile-theme-preference"; @@ -30,6 +32,7 @@ export const useThemeStore = defineStore("theme", { state: (): ThemeState => ({ mode: getSavedTheme(), systemPreference: getSystemPreference(), + embedded: false, }), getters: { @@ -52,6 +55,15 @@ export const useThemeStore = defineStore("theme", { }, actions: { + /** + * Mark this store as operating in embedded mode. + * In embedded mode, applyTheme only updates store state + * and does NOT modify document.documentElement. + */ + setEmbedded(value: boolean) { + this.embedded = value; + }, + /** * Sets the theme mode and persists to localStorage */ @@ -70,11 +82,16 @@ export const useThemeStore = defineStore("theme", { }, /** - * Applies the current theme to the document + * Applies the current theme to the document (or just updates state in embedded mode) */ applyTheme() { const theme = this.effectiveTheme; - document.documentElement.setAttribute("data-theme", theme); + // In embedded mode, the FlowfileEditor wrapper component + // reads effectiveTheme and applies data-theme to its own root div. + // We skip modifying the global document element. + if (!this.embedded) { + document.documentElement.setAttribute("data-theme", theme); + } }, /** diff --git a/flowfile_wasm/src/styles/editor.css b/flowfile_wasm/src/styles/editor.css new file mode 100644 index 000000000..836e36393 --- /dev/null +++ b/flowfile_wasm/src/styles/editor.css @@ -0,0 +1,1367 @@ +/* Vue Flow Styles - must be at the top */ +@import '@vue-flow/core/dist/style.css'; +@import '@vue-flow/core/dist/theme-default.css'; +@import '@vue-flow/minimap/dist/style.css'; +@import '@vue-flow/controls/dist/style.css'; + +/* AG Grid Styles */ +@import '@ag-grid-community/styles/ag-grid.css'; +@import '@ag-grid-community/styles/ag-theme-balham.css'; + +/* ========================================================================== + CSS Variables - Design Token System (aligned with flowfile_frontend) + ========================================================================== */ +.flowfile-editor-root { + /* ========== Typography Tokens ========== */ + --font-family-base: "Roboto", "Source Sans Pro", -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; + --font-family-mono: "SF Mono", "Monaco", "Consolas", "Liberation Mono", "Courier New", monospace; + + /* Font sizes */ + --font-size-2xs: 10px; + --font-size-xs: 11px; + --font-size-sm: 12px; + --font-size-md: 13px; + --font-size-base: 14px; + --font-size-lg: 15px; + --font-size-xl: 16px; + --font-size-2xl: 18px; + --font-size-3xl: 20px; + --font-size-4xl: 24px; + + --font-weight-normal: 400; + --font-weight-medium: 500; + --font-weight-semibold: 600; + --font-weight-bold: 700; + + --line-height-tight: 1.25; + --line-height-normal: 1.5; + --line-height-relaxed: 1.625; + + /* ========== Brand Color Tokens ========== */ + --primary-blue: #0f172a; + --primary-blue-hover: #1e293b; + --primary-blue-light: #334155; + + /* Accent Color: Teal/Cyan */ + --color-accent: #0891b2; + --color-accent-hover: #0e7490; + --color-accent-light: #22d3ee; + --color-accent-subtle: #ecfeff; + + /* Legacy alias */ + --light-blue: #0891b2; + --light-blue-hover: #0e7490; + + /* ========== Semantic Color Tokens ========== */ + /* Background colors */ + --color-background-primary: #ffffff; + --color-background-secondary: #f8f9fa; + --color-background-tertiary: #f1f3f5; + --color-background-hover: #f0f7ff; + --color-background-selected: #e7f3ff; + --color-background-muted: #fafafa; + + /* Text colors */ + --color-text-primary: #1a1a2e; + --color-text-secondary: #4a5568; + --color-text-tertiary: #718096; + --color-text-muted: #a0aec0; + --color-text-inverse: #ffffff; + --color-text-selected: #0c254a; + + /* Border colors */ + --color-border-primary: #e2e8f0; + --color-border-secondary: #cbd5e0; + --color-border-light: #edf2f7; + --color-border-focus: var(--light-blue); + + /* Status colors */ + --color-success: #10b981; + --color-success-light: #d1fae5; + --color-success-hover: #059669; + --color-danger: #ef4444; + --color-danger-light: #fee2e2; + --color-danger-hover: #dc2626; + --color-danger-dark: #b91c1c; + --color-warning: #f59e0b; + --color-warning-light: #fef3c7; + --color-warning-hover: #d97706; + --color-warning-dark: #92400e; + --color-warning-darker: #78350f; + --color-info: #3b82f6; + --color-info-light: #dbeafe; + --color-info-hover: #2563eb; + + /* Button colors */ + --color-button-primary: #4a6cf7; + --color-button-primary-hover: #3d5bd9; + --color-button-secondary: #3498db; + --color-button-secondary-hover: #2980b9; + --color-button-secondary-light: #93c5fd; + + /* Code editor colors */ + --color-code-bg: #282c34; + --color-code-text: #abb2bf; + + /* Overlay/Modal colors */ + --color-overlay: rgba(0, 0, 0, 0.5); + --color-overlay-dark: rgba(0, 0, 0, 0.7); + + /* Canvas background color */ + --color-canvas-background: #f0f4f8; + + /* Focus ring colors */ + --color-focus-ring: rgba(44, 116, 179, 0.1); + --color-focus-ring-accent: rgba(8, 145, 178, 0.15); + --color-focus-ring-accent-strong: rgba(8, 145, 178, 0.25); + + /* Utility transparent colors */ + --color-white-alpha-20: rgba(255, 255, 255, 0.2); + --color-white-alpha-40: rgba(255, 255, 255, 0.4); + --color-black-alpha-10: rgba(0, 0, 0, 0.1); + --color-black-alpha-20: rgba(0, 0, 0, 0.2); + + /* Gray scale */ + --color-gray-50: #f9fafb; + --color-gray-100: #f3f4f6; + --color-gray-200: #e5e7eb; + --color-gray-300: #d1d5db; + --color-gray-400: #9ca3af; + --color-gray-500: #6b7280; + --color-gray-600: #4b5563; + --color-gray-700: #374151; + --color-gray-800: #1f2937; + --color-gray-900: #111827; + + /* ========== Legacy Mappings (backward compatibility) ========== */ + --bg-primary: var(--color-background-secondary); + --bg-secondary: var(--color-background-primary); + --bg-tertiary: var(--color-background-tertiary); + --bg-hover: var(--color-background-hover); + --bg-muted: var(--color-background-muted); + --bg-selected: var(--color-background-selected); + + --text-primary: var(--color-text-primary); + --text-secondary: var(--color-text-secondary); + --text-tertiary: var(--color-text-tertiary); + --text-muted: var(--color-text-muted); + --text-selected: var(--color-text-selected); + + --border-color: var(--color-border-primary); + --border-light: var(--color-border-light); + --border-secondary: var(--color-border-secondary); + + --accent-color: var(--color-accent); + --accent-hover: var(--color-accent-hover); + --accent-light: var(--color-accent-subtle); + + --success-color: var(--color-success); + --success-light: var(--color-success-light); + --warning-color: var(--color-warning); + --warning-light: var(--color-warning-light); + --error-color: var(--color-danger); + --error-light: var(--color-danger-light); + + /* ========== Spacing Tokens ========== */ + --spacing-0: 0; + --spacing-px: 1px; + --spacing-0-5: 2px; + --spacing-1: 4px; + --spacing-1-5: 6px; + --spacing-2: 8px; + --spacing-2-5: 10px; + --spacing-3: 12px; + --spacing-4: 16px; + --spacing-5: 20px; + --spacing-6: 24px; + --spacing-8: 32px; + --spacing-10: 40px; + --spacing-12: 48px; + + /* Legacy spacing */ + --spacing-xs: var(--spacing-1); + --spacing-sm: var(--spacing-2); + --spacing-md: var(--spacing-3); + --spacing-lg: var(--spacing-4); + --spacing-xl: var(--spacing-6); + + /* ========== Shadow Tokens ========== */ + --shadow-xs: 0 1px 2px 0 rgba(0, 0, 0, 0.05); + --shadow-sm: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1); + --shadow-default: 0 2px 4px -1px rgba(0, 0, 0, 0.06), 0 4px 6px -1px rgba(0, 0, 0, 0.1); + --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1); + --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1); + --shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1); + --shadow-inner: inset 0 2px 4px 0 rgba(0, 0, 0, 0.05); + + /* ========== Border Radius Tokens ========== */ + --border-radius-none: 0; + --border-radius-sm: 4px; + --border-radius-md: 6px; + --border-radius-lg: 8px; + --border-radius-xl: 12px; + --border-radius-2xl: 16px; + --border-radius-full: 9999px; + + /* Legacy radius */ + --radius-sm: var(--border-radius-sm); + --radius-md: var(--border-radius-md); + --radius-lg: var(--border-radius-lg); + --radius-full: var(--border-radius-full); + + /* ========== Animation Tokens ========== */ + --transition-fast: 100ms; + --transition-base: 150ms; + --transition-normal: 200ms; + --transition-slow: 300ms; + --transition-timing: cubic-bezier(0.4, 0, 0.2, 1); + + /* ========== Z-Index Tokens ========== */ + --z-index-base: 1; + --z-index-sticky: 100; + --z-index-dropdown: 1000; + --z-index-modal: 1050; + --z-index-popover: 1075; + --z-index-tooltip: 1100; + --z-index-notification: 1200; + --z-index-context-menu: 10000; + --z-index-canvas-dropdown: 100001; + --z-index-canvas-context-menu: 100002; + + /* ========== Component-Specific Tokens ========== */ + /* Table */ + --table-header-bg: var(--color-background-muted); + --table-row-hover: var(--color-background-tertiary); + --table-border: var(--color-border-light); + + /* Input */ + --input-border: var(--color-border-primary); + --input-border-focus: var(--light-blue); + --input-bg: var(--color-background-primary); + --input-placeholder: var(--color-text-muted); + + /* Card */ + --card-bg: var(--color-background-primary); + --card-border: var(--color-border-primary); + --card-shadow: var(--shadow-sm); + + /* Node */ + --node-bg: var(--color-background-primary); + --node-border: var(--color-border-primary); + --node-selected-border: var(--color-gray-500); +} + +/* ========== Dark Theme ========== */ +.flowfile-editor-root[data-theme="dark"] { + --color-background-primary: #1a1a2e; + --color-background-secondary: #16213e; + --color-background-tertiary: #0f3460; + --color-background-hover: #1e3a5f; + --color-background-selected: #0f3460; + --color-background-muted: #1f1f3a; + + --color-text-primary: #f1f5f9; + --color-text-secondary: #cbd5e1; + --color-text-tertiary: #94a3b8; + --color-text-muted: #64748b; + --color-text-inverse: #1a1a2e; + + --color-border-primary: #334155; + --color-border-secondary: #475569; + --color-border-light: #1e293b; + + /* Gray scale adjustments for dark mode */ + --color-gray-50: #1f2937; + --color-gray-100: #374151; + --color-gray-200: #4b5563; + --color-gray-300: #6b7280; + --color-gray-400: #9ca3af; + --color-gray-500: #d1d5db; + --color-gray-600: #e5e7eb; + --color-gray-700: #f3f4f6; + --color-gray-800: #f9fafb; + --color-gray-900: #ffffff; + + /* Shadow adjustments for dark mode */ + --shadow-xs: 0 1px 2px 0 rgba(0, 0, 0, 0.3); + --shadow-sm: 0 1px 3px 0 rgba(0, 0, 0, 0.4), 0 1px 2px -1px rgba(0, 0, 0, 0.3); + --shadow-default: 0 2px 4px -1px rgba(0, 0, 0, 0.3), 0 4px 6px -1px rgba(0, 0, 0, 0.4); + --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 2px 4px -2px rgba(0, 0, 0, 0.3); + --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.4), 0 4px 6px -4px rgba(0, 0, 0, 0.3); + --shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.4), 0 8px 10px -6px rgba(0, 0, 0, 0.3); + + /* Component overrides */ + --card-bg: var(--color-background-secondary); + --node-bg: var(--color-background-secondary); + + /* Loading overlay in dark mode */ + --loading-bg: rgba(26, 26, 46, 0.8); + + /* Dark mode canvas background */ + --color-canvas-background: #1e2530; +} + +/* Reset */ +.flowfile-editor-root, .flowfile-editor-root *, .flowfile-editor-root *::before, .flowfile-editor-root *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +.flowfile-editor-root { + font-family: var(--font-family-base); + font-size: var(--font-size-base); + color: var(--color-text-primary); + background: var(--color-background-secondary); + transition: background-color var(--transition-normal), color var(--transition-normal); +} + +/* Custom Vue Flow overrides */ +.vue-flow__minimap { + transform: scale(75%); + transform-origin: bottom right; +} + +/* Hide Vue Flow background pattern (dots) */ +.vue-flow__background { + display: none; +} + +/* Invert edge colors for better visibility */ +.custom-node-flow .vue-flow__edges { + filter: invert(100%); +} + +/* ========================================================================== + Canvas Background + ========================================================================== */ +.animated-bg-gradient { + background: var(--color-canvas-background); +} + +/* ========================================================================== + AG Grid Theme Customization (matching flowfile_frontend) + ========================================================================== */ + +.ag-theme-balham { + max-width: 100%; + position: relative; + /* Prevent scroll chaining to parent containers */ + overscroll-behavior: contain; + --ag-background-color: var(--color-background-primary); + --ag-odd-row-background-color: var(--color-background-primary); + --ag-row-background-color: var(--color-background-primary); + --ag-header-background-color: var(--color-background-secondary); + --ag-header-foreground-color: var(--color-text-primary); + --ag-foreground-color: var(--color-text-primary); + --ag-border-color: var(--color-border-primary); + --ag-secondary-foreground-color: var(--color-text-secondary); + --ag-row-hover-color: var(--color-background-hover); + --ag-selected-row-background-color: var(--color-background-selected); + --ag-font-family: var(--font-family-base); + --ag-font-size: var(--font-size-sm); + --ag-cell-horizontal-padding: 12px; + --ag-header-height: 36px; + --ag-row-height: 32px; + --ag-grid-size: 4px; + --ag-list-item-height: 28px; + --ag-input-focus-border-color: var(--color-accent); + --ag-range-selection-border-color: var(--color-accent); + --ag-checkbox-checked-color: var(--color-accent); + --ag-input-border-color: var(--color-border-primary); + /* Pagination and panel colors */ + --ag-control-panel-background-color: var(--color-background-secondary); + --ag-subheader-background-color: var(--color-background-tertiary); + --ag-invalid-background-color: var(--color-danger-light); + --ag-input-disabled-background-color: var(--color-background-tertiary); + --ag-modal-overlay-background-color: var(--color-overlay); + --ag-popup-background-color: var(--color-background-primary); +} + +/* AG Grid header styling */ +.ag-theme-balham .ag-header-cell { + font-weight: var(--font-weight-semibold); +} + +/* AG Grid cell styling */ +.ag-theme-balham .ag-cell { + line-height: 30px; +} + +/* AG Grid viewport - prevent scroll chaining to parent containers */ +.ag-theme-balham .ag-body-viewport { + overscroll-behavior: contain; +} + +/* AG Grid filter styling */ +.ag-theme-balham .ag-filter-toolpanel-header, +.ag-theme-balham .ag-filter-toolpanel-search, +.ag-theme-balham .ag-status-bar, +.ag-theme-balham .ag-header-row { + font-weight: var(--font-weight-medium); +} + +/* AG Grid pagination panel - ensure dark theme colors */ +.ag-theme-balham .ag-paging-panel { + background-color: var(--color-background-secondary); + border-top: 1px solid var(--color-border-primary); + color: var(--color-text-primary); +} + +.ag-theme-balham .ag-paging-button { + color: var(--color-text-primary); +} + +.ag-theme-balham .ag-paging-description { + color: var(--color-text-secondary); +} + +/* AG Grid filter popup - ensure dark theme colors */ +.ag-theme-balham .ag-popup { + background-color: var(--color-background-primary); + border: 1px solid var(--color-border-primary); +} + +.ag-theme-balham .ag-filter { + background-color: var(--color-background-primary); +} + +.ag-theme-balham .ag-menu { + background-color: var(--color-background-primary); + border: 1px solid var(--color-border-primary); +} + +.ag-theme-balham .ag-menu-option { + color: var(--color-text-primary); +} + +.ag-theme-balham .ag-menu-option:hover { + background-color: var(--color-background-hover); +} + +/* AG Grid input fields in filter */ +.ag-theme-balham .ag-text-field-input, +.ag-theme-balham .ag-number-field-input { + background-color: var(--color-background-primary); + color: var(--color-text-primary); + border-color: var(--color-border-primary); +} + +.ag-theme-balham .ag-select .ag-picker-field-wrapper { + background-color: var(--color-background-primary); + border-color: var(--color-border-primary); +} + +.ag-theme-balham .ag-picker-field-display { + color: var(--color-text-primary); +} + +/* ========================================================================== + LISTBOX STYLES (from flowfile_frontend) + ========================================================================== */ + +.listbox-wrapper { + position: relative; + overflow: visible; + box-shadow: var(--shadow-sm); + border-radius: var(--radius-lg); + margin: var(--spacing-1); + background-color: var(--color-background-primary); + padding: var(--spacing-2); + z-index: 1; +} + +.listbox-subtitle { + display: flex; + align-items: center; + padding: var(--spacing-1) var(--spacing-4); + font-weight: var(--font-weight-normal); + font-size: var(--font-size-xs); + color: var(--color-text-primary); + background-color: var(--color-background-muted); + border-bottom: 1px solid var(--color-border-light); + width: 100%; + border-top-left-radius: var(--radius-sm); + border-top-right-radius: var(--radius-sm); + height: 32px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.listbox { + border: none; + padding: 0; + margin: 0; + list-style-type: none; + border-radius: var(--radius-lg); + overflow: hidden; + overflow-y: auto; + box-shadow: var(--shadow-sm); + max-height: 300px; + background-color: var(--color-background-primary); +} + +.listbox li { + padding: var(--spacing-2) var(--spacing-4); + cursor: pointer; + background-color: var(--color-background-primary); + user-select: none; + border-bottom: 1px solid var(--color-border-light); + transition: background-color var(--transition-fast); + font-size: var(--font-size-sm); +} + +.listbox li:last-child { + border-bottom: none; +} + +.listbox li:hover { + background-color: var(--color-background-tertiary); +} + +.listbox li.is-selected { + background-color: var(--color-background-selected); + color: var(--color-text-selected); +} + +/* Listbox scrollbar */ +.listbox::-webkit-scrollbar { + width: 6px; +} + +.listbox::-webkit-scrollbar-track { + background: transparent; +} + +.listbox::-webkit-scrollbar-thumb { + background-color: var(--color-border-primary); + border-radius: var(--border-radius-full); +} + +.listbox::-webkit-scrollbar-thumb:hover { + background-color: var(--color-text-muted); +} + +/* ========================================================================== + STYLED TABLE (from flowfile_frontend) + ========================================================================== */ + +.styled-table { + width: 100%; + border-collapse: separate; + border-spacing: 0; + table-layout: fixed; + line-height: 1.5; +} + +.styled-table th, +.styled-table td { + text-align: left; + padding: var(--spacing-1) var(--spacing-4); + border-bottom: 1px solid var(--table-border); + background-color: var(--color-background-primary); + font-size: var(--font-size-sm); + line-height: 1.5; + height: 32px; + vertical-align: middle; +} + +.styled-table th:first-child, +.styled-table td:first-child { + width: 35%; +} + +.styled-table th:nth-child(2), +.styled-table td:nth-child(2) { + width: 30%; +} + +.styled-table th:last-child, +.styled-table td:last-child { + width: 35%; + border-right: none; +} + +.styled-table thead th { + position: sticky; + top: 0; + z-index: 2; + box-shadow: var(--shadow-xs); + background-color: var(--table-header-bg); + font-weight: var(--font-weight-semibold); + color: var(--color-text-primary); + height: 40px; + padding: var(--spacing-2) var(--spacing-4); +} + +.styled-table td:not(:last-child) { + border-right: 1px solid var(--color-border-light); +} + +.styled-table tr { + height: 32px; + transition: background-color var(--transition-fast); +} + +.styled-table tbody tr:hover { + background-color: var(--table-row-hover); +} + +.styled-table tbody tr:hover td { + background-color: transparent; +} + +.table-wrapper { + max-height: 300px; + box-shadow: var(--shadow-sm); + border-radius: var(--radius-lg); + overflow: auto; + margin: var(--spacing-1); +} + +/* ========================================================================== + CONTEXT MENU (from flowfile_frontend) + ========================================================================== */ + +.context-menu { + position: fixed; + min-width: 120px; + background-color: var(--color-background-primary); + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-md); + box-shadow: var(--shadow-lg); + z-index: var(--z-index-context-menu); + overflow: hidden; + user-select: none; +} + +.context-menu-header { + padding: var(--spacing-2) var(--spacing-3); + font-weight: var(--font-weight-semibold); + background-color: var(--color-background-tertiary); + border-bottom: 1px solid var(--color-border-primary); + font-size: var(--font-size-sm); + color: var(--color-text-primary); +} + +.context-menu ul { + list-style: none; + padding: 0; + margin: 0; +} + +.context-menu li { + padding: var(--spacing-2) var(--spacing-4); + cursor: pointer; + font-size: var(--font-size-sm); + color: var(--color-text-primary); + transition: background-color var(--transition-fast); +} + +.context-menu li:hover:not(.disabled) { + background-color: var(--color-background-tertiary); +} + +.context-menu button { + display: block; + width: 100%; + padding: var(--spacing-2) var(--spacing-3); + background: none; + border: none; + text-align: left; + cursor: pointer; + font-size: var(--font-size-sm); + color: var(--color-text-primary); + transition: background-color var(--transition-fast); +} + +.context-menu button:hover { + background-color: var(--color-background-tertiary); +} + +.context-menu-item { + display: flex; + align-items: center; + padding: var(--spacing-2) var(--spacing-3); + cursor: pointer; + font-size: var(--font-size-sm); + color: var(--color-text-primary); + transition: background-color var(--transition-fast); +} + +.context-menu-item:hover { + background-color: var(--color-background-tertiary); +} + +.context-menu-divider { + height: 1px; + margin: var(--spacing-1) 0; + background-color: var(--color-border-primary); +} + +.context-menu-item.danger { + color: var(--color-danger); +} + +.context-menu-item.danger:hover { + background-color: var(--color-danger-light); +} + +/* ========================================================================== + COMMON COMPONENTS + ========================================================================== */ + +/* Buttons */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: var(--spacing-2) var(--spacing-4); + font-size: var(--font-size-md); + font-weight: var(--font-weight-medium); + border: none; + border-radius: var(--radius-sm); + cursor: pointer; + transition: all var(--transition-base); +} + +.btn-primary { + background: var(--color-accent); + color: white; +} + +.btn-primary:hover:not(:disabled) { + background: var(--color-accent-hover); +} + +.btn-secondary { + background: var(--color-background-tertiary); + color: var(--color-text-primary); +} + +.btn-secondary:hover:not(:disabled) { + background: var(--color-border-primary); +} + +.btn-small { + padding: var(--spacing-1) var(--spacing-2); + font-size: var(--font-size-xs); +} + +.btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +/* Action buttons (for tables, lists) */ +.action-button { + cursor: pointer; + width: 24px; + height: 24px; + border-radius: var(--radius-sm); + border: 1px solid var(--color-border-primary); + background-color: var(--color-background-primary); + display: flex; + align-items: center; + justify-content: center; + font-size: var(--font-size-base); + transition: all var(--transition-base); +} + +.action-button.add-button { + color: var(--color-success); + border-color: var(--color-success); +} + +.action-button.add-button:hover { + background-color: var(--color-success); + color: white; +} + +.action-button.remove-button { + color: var(--color-danger); + border-color: var(--color-danger); +} + +.action-button.remove-button:hover { + background-color: var(--color-danger); + color: white; +} + +/* Input styles */ +.input { + width: 100%; + padding: var(--spacing-2) var(--spacing-3); + font-size: var(--font-size-md); + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-sm); + background: var(--color-background-primary); + color: var(--color-text-primary); + transition: border-color var(--transition-base), box-shadow var(--transition-base); +} + +.input:focus { + outline: none; + border-color: var(--color-accent); + box-shadow: 0 0 0 2px var(--color-focus-ring-accent); +} + +.input::placeholder { + color: var(--color-text-muted); +} + +.input-sm { + padding: var(--spacing-1) var(--spacing-2); + font-size: var(--font-size-sm); +} + +/* Select styles */ +.select { + width: 100%; + padding: var(--spacing-2) var(--spacing-3); + font-size: var(--font-size-md); + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-sm); + background: var(--color-background-primary); + color: var(--color-text-primary); + cursor: pointer; +} + +.select:focus { + outline: none; + border-color: var(--color-accent); +} + +.select-sm { + padding: var(--spacing-1) var(--spacing-2); + font-size: var(--font-size-sm); +} + +/* Form groups */ +.form-group { + display: flex; + flex-direction: column; + gap: var(--spacing-1); +} + +.form-group label { + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + color: var(--color-text-secondary); +} + +.form-row { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-3); + align-items: flex-end; +} + +.form-field { + flex: 1; + min-width: 150px; + max-width: 250px; +} + +/* Filter section styles */ +.filter-section { + padding: var(--spacing-2) 0; +} + +.filter-row { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-3); + align-items: flex-end; +} + +.filter-field { + flex: 1; + min-width: 150px; + max-width: 250px; +} + +.filter-label { + display: block; + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + color: var(--color-text-secondary); + margin-bottom: var(--spacing-1); +} + +/* Card styles */ +.card { + background: var(--card-bg); + border-radius: var(--radius-md); + box-shadow: var(--shadow-sm); + border: 1px solid var(--color-border-light); +} + +/* Panel styles */ +.panel { + background: var(--color-background-primary); + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-md); + box-shadow: var(--shadow-md); +} + +.panel-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--spacing-3) var(--spacing-4); + border-bottom: 1px solid var(--color-border-light); + font-weight: var(--font-weight-medium); +} + +.panel-body { + padding: var(--spacing-4); +} + +/* Help text */ +.help-text { + font-size: var(--font-size-sm); + color: var(--color-text-secondary); + font-style: italic; +} + +/* Settings form container */ +.settings-form { + display: flex; + flex-direction: column; + gap: var(--spacing-4); +} + +/* No data states */ +.no-columns, +.no-data, +.no-aggs { + padding: var(--spacing-4); + text-align: center; + color: var(--color-text-secondary); + background: var(--color-background-tertiary); + border-radius: var(--radius-md); + font-size: var(--font-size-md); +} + +/* Column list for selectors */ +.column-list { + display: flex; + flex-direction: column; + gap: 2px; + max-height: 250px; + overflow-y: auto; + border: 1px solid var(--color-border-light); + border-radius: var(--radius-sm); +} + +.column-item { + display: flex; + align-items: center; + gap: var(--spacing-2); + padding: var(--spacing-2) var(--spacing-3); + cursor: pointer; + transition: background-color var(--transition-fast); +} + +.column-item:hover { + background-color: var(--color-background-hover); +} + +.column-item.selected, +.column-item.is-selected { + background-color: var(--color-background-selected); + color: var(--color-text-selected); +} + +.column-name { + flex: 1; + font-size: var(--font-size-md); + font-weight: var(--font-weight-medium); +} + +.column-type { + font-size: var(--font-size-xs); + color: var(--color-text-secondary); +} + +/* Checkbox label */ +.checkbox-label { + display: flex; + align-items: center; + gap: var(--spacing-2); + cursor: pointer; +} + +.checkbox-label input[type="checkbox"] { + width: 16px; + height: 16px; + accent-color: var(--color-accent); +} + +/* Switch (toggle) */ +.switch-wrapper { + display: flex; + align-items: center; + gap: var(--spacing-2); +} + +/* Join specific styles */ +.join-columns { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--spacing-4); +} + +.join-type-selector { + display: flex; + align-items: center; + gap: var(--spacing-2); +} + +.join-type-label { + font-size: var(--font-size-sm); + color: var(--color-text-primary); + font-weight: var(--font-weight-medium); + min-width: 70px; +} + +.selectors-header { + display: flex; + justify-content: space-between; + padding: var(--spacing-2) var(--spacing-4); + background-color: var(--color-background-muted); + border-bottom: 1px solid var(--color-border-light); +} + +.selectors-title { + flex: 1; + text-align: center; + font-size: var(--font-size-sm); + color: var(--color-text-secondary); + font-weight: var(--font-weight-medium); +} + +.selectors-container { + padding: var(--spacing-3); + display: flex; + flex-direction: column; + gap: var(--spacing-2); +} + +.selectors-row { + display: flex; + gap: var(--spacing-3); + align-items: center; +} + +/* Scrollbar */ +.flowfile-editor-root ::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +.flowfile-editor-root ::-webkit-scrollbar-track { + background: var(--color-background-tertiary); +} + +.flowfile-editor-root ::-webkit-scrollbar-thumb { + background: var(--color-border-primary); + border-radius: var(--radius-sm); +} + +.flowfile-editor-root ::-webkit-scrollbar-thumb:hover { + background: var(--color-text-muted); +} + +/* Loading states */ +.loading-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + align-items: center; + justify-content: center; + background: var(--loading-bg, rgba(255, 255, 255, 0.8)); + z-index: 100; +} + +/* ========================================================================== + Spinner Styles (from flowfile_frontend dataPreview) + ========================================================================== */ + +.spinner-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: var(--color-background-primary); + opacity: 0.9; + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; +} + +.spinner { + width: 50px; + height: 50px; + border: 5px solid var(--color-border-primary); + border-top: 5px solid var(--color-accent); + border-radius: 50%; + animation: spin 1s linear infinite; +} + +.spinner.small { + width: 16px; + height: 16px; + border-width: 2px; +} + +@keyframes spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} + +/* ========================================================================== + Data Preview Table Container + ========================================================================== */ + +.table-container { + display: flex; + flex-direction: column; + height: 100%; + width: 100%; + position: relative; +} + +/* Fetch Data Section Styles */ +.fetch-data-section { + padding: 20px; + text-align: center; + background-color: var(--color-background-secondary); + border: 1px solid var(--color-border-primary); + border-top: none; + border-radius: 0 0 8px 8px; +} + +.fetch-data-section p { + color: var(--color-text-secondary); + font-size: var(--font-size-base); + margin-bottom: var(--spacing-3); +} + +.fetch-data-button { + background-color: var(--color-button-secondary); + color: var(--color-text-inverse); + border: none; + padding: var(--spacing-2) var(--spacing-5); + font-size: var(--font-size-base); + border-radius: var(--radius-md); + cursor: pointer; + transition: all 0.2s ease; + position: relative; +} + +.fetch-data-button:hover:not(:disabled) { + background-color: var(--color-button-secondary-hover); + transform: translateY(-1px); + box-shadow: var(--shadow-md); +} + +.fetch-data-button:active:not(:disabled) { + transform: translateY(0); +} + +.fetch-data-button:disabled { + background-color: var(--color-button-secondary-light); + cursor: not-allowed; + opacity: 0.7; +} + +/* Animation classes */ +.fade-enter-active, +.fade-leave-active { + transition: opacity var(--transition-base); +} + +.fade-enter-from, +.fade-leave-to { + opacity: 0; +} + +.slide-enter-active, +.slide-leave-active { + transition: transform var(--transition-base); +} + +.slide-enter-from, +.slide-leave-to { + transform: translateX(100%); +} + +@keyframes slideDown { + from { + opacity: 0; + transform: translateY(-20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes shimmer { + 0% { + left: -100%; + } + 100% { + left: 100%; + } +} + +/* ========================================================================== + Theme Toggle Button + ========================================================================== */ + +.theme-toggle { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-sm); + background: var(--color-background-primary); + color: var(--color-text-primary); + cursor: pointer; + transition: all var(--transition-base); +} + +.theme-toggle:hover { + background: var(--color-background-hover); + border-color: var(--color-accent); +} + +.theme-toggle svg { + width: 18px; + height: 18px; +} + +/* ========================================================================== + Action Buttons (matching flowfile_frontend HeaderButtons.vue) + ========================================================================== */ + +.action-buttons { + display: flex; + align-items: center; + gap: var(--spacing-2); + height: 50px; + font-family: var(--font-family-base); +} + +.action-btn { + display: flex; + align-items: center; + gap: var(--spacing-1-5); + padding: var(--spacing-2) var(--spacing-3); + height: 34px; + background-color: var(--color-background-primary); + border: 1px solid var(--color-border-light); + border-radius: var(--border-radius-lg); + cursor: pointer; + transition: all var(--transition-fast); + color: var(--color-text-primary); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + box-shadow: var(--shadow-xs); +} + +.action-btn:hover:not(:disabled) { + background-color: var(--color-background-tertiary); + border-color: var(--color-border-secondary); +} + +.action-btn:active:not(:disabled) { + transform: translateY(1px); + box-shadow: none; +} + +.action-btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.action-btn.active { + background-color: var(--color-accent-subtle); + border-color: var(--color-accent); + color: var(--color-accent); +} + +.action-btn.danger:hover:not(:disabled) { + background-color: var(--color-danger-light); + border-color: var(--color-danger); + color: var(--color-danger); +} + +.action-btn .btn-icon { + font-size: 18px; + color: var(--color-text-secondary); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: optimizeLegibility; +} + +.action-btn:hover:not(:disabled) .btn-icon { + color: var(--color-text-primary); +} + +.action-btn.active .btn-icon { + color: var(--color-accent); +} + +.action-btn.danger:hover:not(:disabled) .btn-icon { + color: var(--color-danger); +} + +.btn-text { + white-space: nowrap; +} + +/* Run button specific styling */ +.action-btn.run-btn { + background-color: var(--color-success); + border-color: var(--color-success); + color: white; +} + +.action-btn.run-btn .btn-icon { + color: white; +} + +.action-btn.run-btn:hover:not(:disabled) { + background-color: var(--color-success-hover); + border-color: var(--color-success-hover); +} + +.action-btn.run-btn:disabled { + background-color: var(--color-success); + opacity: 0.7; +} + +/* Toolbar divider */ +.toolbar-divider { + width: 1px; + height: 24px; + background: var(--color-border-primary); + margin: 0 var(--spacing-1); +} diff --git a/flowfile_wasm/src/utils/iconUrls.ts b/flowfile_wasm/src/utils/iconUrls.ts new file mode 100644 index 000000000..439f63892 --- /dev/null +++ b/flowfile_wasm/src/utils/iconUrls.ts @@ -0,0 +1,36 @@ +/** + * Explicit icon imports for library build compatibility. + * Vite inlines these as base64 data URIs when assetsInlineLimit is set. + * This avoids import.meta.url issues in library mode. + */ +import inputData from '../assets/icons/input_data.png' +import manualInput from '../assets/icons/manual_input.png' +import filter from '../assets/icons/filter.png' +import select from '../assets/icons/select.png' +import sort from '../assets/icons/sort.png' +import polarsCode from '../assets/icons/polars_code.png' +import unique from '../assets/icons/unique.png' +import sample from '../assets/icons/sample.png' +import join from '../assets/icons/join.png' +import groupBy from '../assets/icons/group_by.png' +import pivot from '../assets/icons/pivot.png' +import unpivot from '../assets/icons/unpivot.png' +import view from '../assets/icons/view.png' +import output from '../assets/icons/output.png' + +export const iconUrls: Record = { + 'input_data.png': inputData, + 'manual_input.png': manualInput, + 'filter.png': filter, + 'select.png': select, + 'sort.png': sort, + 'polars_code.png': polarsCode, + 'unique.png': unique, + 'sample.png': sample, + 'join.png': join, + 'group_by.png': groupBy, + 'pivot.png': pivot, + 'unpivot.png': unpivot, + 'view.png': view, + 'output.png': output, +} diff --git a/flowfile_wasm/vite.config.ts b/flowfile_wasm/vite.config.ts index 0100e15fc..c6b2bc022 100644 --- a/flowfile_wasm/vite.config.ts +++ b/flowfile_wasm/vite.config.ts @@ -2,6 +2,8 @@ import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' import { resolve } from 'path' +const isLibBuild = process.env.BUILD_MODE === 'lib' + export default defineConfig({ plugins: [vue()], resolve: { @@ -16,7 +18,27 @@ export default defineConfig({ 'Cross-Origin-Embedder-Policy': 'require-corp' } }, - build: { + build: isLibBuild ? { + target: 'esnext', + lib: { + entry: resolve(__dirname, 'src/lib/index.ts'), + name: 'FlowfileEditor', + formats: ['es'], + fileName: 'flowfile-editor' + }, + rollupOptions: { + external: ['vue', 'pinia'], + output: { + globals: { + vue: 'Vue', + pinia: 'Pinia' + }, + assetFileNames: 'style.[ext]' + } + }, + cssCodeSplit: false, + assetsInlineLimit: 100000 // Inline icon PNGs as base64 data URIs + } : { target: 'esnext' }, optimizeDeps: { From 987768ce8cd42d4e0481935526e1b88240b92d3d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 13 Feb 2026 14:10:43 +0000 Subject: [PATCH 2/7] Add external_data node type for receiving data from host application Instead of requiring indirect node_reference matching, this adds a proper "External Data" node type that users drag onto the canvas and select a dataset from a dropdown. The host application provides datasets via the inputData prop, and they appear as selectable options in the node settings. On export, only the dataset name and schema snapshot are saved (not the actual data), so flows remain lightweight and the host re-provides data on re-import. https://claude.ai/code/session_01SztvepLRjChvuh634qxFnu --- flowfile_wasm/src/components/Canvas.vue | 5 +- .../components/nodes/ExternalDataSettings.vue | 260 ++++++++++++++++++ flowfile_wasm/src/config/nodeDescriptions.ts | 4 + flowfile_wasm/src/lib/FlowfileEditor.vue | 36 ++- flowfile_wasm/src/stores/flow-store.ts | 86 +++++- flowfile_wasm/src/stores/schema-inference.ts | 2 +- flowfile_wasm/src/types/index.ts | 9 + flowfile_wasm/src/utils/iconUrls.ts | 2 + 8 files changed, 382 insertions(+), 22 deletions(-) create mode 100644 flowfile_wasm/src/components/nodes/ExternalDataSettings.vue diff --git a/flowfile_wasm/src/components/Canvas.vue b/flowfile_wasm/src/components/Canvas.vue index 08ef9cbc6..f9e3cd62b 100644 --- a/flowfile_wasm/src/components/Canvas.vue +++ b/flowfile_wasm/src/components/Canvas.vue @@ -250,6 +250,7 @@ import FlowNode from './nodes/FlowNode.vue' import NodeTitle from './nodes/NodeTitle.vue' import ReadCsvSettings from './nodes/ReadCsvSettings.vue' import ManualInputSettings from './nodes/ManualInputSettings.vue' +import ExternalDataSettings from './nodes/ExternalDataSettings.vue' import FilterSettings from './nodes/FilterSettings.vue' import SelectSettings from './nodes/SelectSettings.vue' import GroupBySettings from './nodes/GroupBySettings.vue' @@ -340,7 +341,8 @@ const nodeCategories = ref([ isOpen: true, nodes: [ { type: 'read', name: 'Read CSV', icon: 'input_data.png', inputs: 0, outputs: 1 }, - { type: 'manual_input', name: 'Manual Input', icon: 'manual_input.png', inputs: 0, outputs: 1 } + { type: 'manual_input', name: 'Manual Input', icon: 'manual_input.png', inputs: 0, outputs: 1 }, + { type: 'external_data', name: 'External Data', icon: 'external_source.png', inputs: 0, outputs: 1 } ] }, { @@ -573,6 +575,7 @@ function getSettingsComponent(type: string) { const components: Record = { read: ReadCsvSettings, manual_input: ManualInputSettings, + external_data: ExternalDataSettings, filter: FilterSettings, select: SelectSettings, group_by: GroupBySettings, diff --git a/flowfile_wasm/src/components/nodes/ExternalDataSettings.vue b/flowfile_wasm/src/components/nodes/ExternalDataSettings.vue new file mode 100644 index 000000000..eb6a16f7f --- /dev/null +++ b/flowfile_wasm/src/components/nodes/ExternalDataSettings.vue @@ -0,0 +1,260 @@ + + + + + diff --git a/flowfile_wasm/src/config/nodeDescriptions.ts b/flowfile_wasm/src/config/nodeDescriptions.ts index 2c07eacc0..8f44b2e21 100644 --- a/flowfile_wasm/src/config/nodeDescriptions.ts +++ b/flowfile_wasm/src/config/nodeDescriptions.ts @@ -13,6 +13,10 @@ export const nodeDescriptions: Record = { title: 'Manual Input', intro: 'Enter data manually in CSV format. Great for creating small datasets or test data.' }, + external_data: { + title: 'External Data', + intro: 'Load data provided by the host application. Select an available dataset from the dropdown.' + }, filter: { title: 'Filter', intro: 'Filter rows based on conditions. Use basic mode for simple filters or advanced mode for custom Polars expressions.' diff --git a/flowfile_wasm/src/lib/FlowfileEditor.vue b/flowfile_wasm/src/lib/FlowfileEditor.vue index 038079195..1f5061e27 100644 --- a/flowfile_wasm/src/lib/FlowfileEditor.vue +++ b/flowfile_wasm/src/lib/FlowfileEditor.vue @@ -108,10 +108,10 @@ watch(() => props.initialFlow, (flow) => { } }, { deep: true }) -// Watch for inputData prop changes +// Watch for inputData prop changes — push to store as external datasets watch(() => props.inputData, (data) => { - if (data && pyodideReady.value) { - injectInputData(data) + if (data) { + pushExternalDatasets(data) } }, { deep: true }) @@ -123,30 +123,23 @@ watch(pyodideReady, (ready) => { if (props.initialFlow) { flowStore.importFromFlowfile(props.initialFlow) } - // Inject input data if provided + // Push external datasets if provided (re-apply in case flow was just imported) if (props.inputData) { - injectInputData(props.inputData) + pushExternalDatasets(props.inputData) } } }) /** - * Inject input data into nodes matched by node_reference + * Push input data to the flow store as external datasets. + * External Data nodes select from these by name. */ -function injectInputData(data: InputDataMap) { +function pushExternalDatasets(data: InputDataMap) { + const datasets: Record = {} for (const [name, config] of Object.entries(data)) { - const content = typeof config === 'string' ? config : config.content - // Find a node whose node_reference matches the dataset name - let targetNodeId: number | null = null - flowStore.nodes.forEach((node, id) => { - if (node.node_reference === name) { - targetNodeId = id - } - }) - if (targetNodeId !== null) { - flowStore.setFileContent(targetNodeId, content) - } + datasets[name] = typeof config === 'string' ? config : config.content } + flowStore.setExternalDatasets(datasets) } function onExecutionComplete(results: Map) { @@ -160,6 +153,11 @@ function onOutput(data: OutputData) { onMounted(async () => { mounted.value = true + // Push external datasets immediately so they're available in the UI + if (props.inputData) { + pushExternalDatasets(props.inputData) + } + // Set embedded mode on theme store themeStore.setEmbedded(true) @@ -195,7 +193,7 @@ const api: FlowfileEditorAPI = { exportFlow: () => flowStore.exportToFlowfile(), importFlow: (data: FlowfileData) => flowStore.importFromFlowfile(data), setInputData: (name: string, content: string) => { - injectInputData({ [name]: content }) + pushExternalDatasets({ [name]: content }) }, getNodeResult: (nodeId: number) => flowStore.getNodeResult(nodeId), clearFlow: () => flowStore.clearFlow(), diff --git a/flowfile_wasm/src/stores/flow-store.ts b/flowfile_wasm/src/stores/flow-store.ts index 5b3a9f404..43b530318 100644 --- a/flowfile_wasm/src/stores/flow-store.ts +++ b/flowfile_wasm/src/stores/flow-store.ts @@ -15,6 +15,7 @@ import type { NodeBase, NodeReadSettings, NodeManualInputSettings, + NodeExternalDataSettings, NodeFilterSettings, NodeSelectSettings, NodeGroupBySettings @@ -107,6 +108,9 @@ export const useFlowStore = defineStore('flow', () => { type OutputCallback = (data: { nodeId: number; content: string; fileName: string; mimeType: string }) => void const outputCallbacks = new Set() + // External datasets provided by the host application (for embedded mode) + const externalDatasets = ref>(new Map()) + // Preview cache state (for lazy loading) const previewCache = ref { invalidatePreviewCache(nodeId) } + /** + * Set external datasets available from the host application. + * Called by FlowfileEditor when inputData prop changes. + */ + function setExternalDatasets(datasets: Record) { + externalDatasets.value.clear() + for (const [name, content] of Object.entries(datasets)) { + externalDatasets.value.set(name, content) + } + + // Auto-load data into any external_data nodes that reference these datasets + nodes.value.forEach((node, nodeId) => { + if (node.type === 'external_data') { + const settings = node.settings as NodeExternalDataSettings + if (settings.dataset_name && externalDatasets.value.has(settings.dataset_name)) { + setFileContent(nodeId, externalDatasets.value.get(settings.dataset_name)!) + } + } + }) + } + + /** + * Get available external dataset names + */ + function getExternalDatasetNames(): string[] { + return Array.from(externalDatasets.value.keys()) + } + + /** + * Get content for an external dataset by name + */ + function getExternalDatasetContent(name: string): string | undefined { + return externalDatasets.value.get(name) + } + /** * Set schema for a source node from raw data fields * Used by manual input nodes when data structure changes @@ -1434,6 +1473,27 @@ result break } + case 'external_data': { + // External data executes exactly like manual_input - data is pre-loaded via setFileContent + const content = fileContents.value.get(nodeId) + if (!content) { + const settings = node.settings as NodeExternalDataSettings + const dsName = settings.dataset_name + return { success: false, error: dsName ? `No data loaded for dataset "${dsName}". Ensure the host provides this dataset.` : 'No dataset selected' } + } + setGlobal('_temp_content', content) + try { + result = await runPythonWithResult(` +import json +result = execute_manual_input(${nodeId}, _temp_content, json.loads(${toPythonJson(node.settings)})) +result +`) + } finally { + deleteGlobal('_temp_content') + } + break + } + case 'filter': { const inputId = node.inputIds[0] if (!inputId) { @@ -1759,6 +1819,13 @@ result } } as NodeManualInputSettings + case 'external_data': + return { + ...base, + dataset_name: '', + schema_snapshot: [] + } as NodeExternalDataSettings + case 'filter': return { ...base, @@ -1915,6 +1982,19 @@ result // In flowfile_core format, left_input_id is always null - inputs are in input_ids // Only right_input_id is used (for join nodes' second input) // Read description and node_reference from node level (primary) with fallback to settings + let settingInput = cleanSettingInput(node.settings) + + // For external_data nodes, save schema snapshot (not the actual data) + if (node.type === 'external_data') { + const result = nodeResults.value.get(id) + if (result?.schema) { + settingInput = { + ...settingInput, + schema_snapshot: result.schema.map(col => ({ name: col.name, data_type: col.data_type })) + } + } + } + const flowfileNode: FlowfileNode = { id: node.id, type: node.type, @@ -1926,7 +2006,7 @@ result right_input_id: node.rightInputId, input_ids: node.inputIds, outputs, - setting_input: cleanSettingInput(node.settings) + setting_input: settingInput } flowfileNodes.push(flowfileNode) @@ -2268,6 +2348,10 @@ result addEdge, removeEdge, setFileContent, + externalDatasets, + setExternalDatasets, + getExternalDatasetNames, + getExternalDatasetContent, selectNode, executeNode, executeFlow, diff --git a/flowfile_wasm/src/stores/schema-inference.ts b/flowfile_wasm/src/stores/schema-inference.ts index b162fcf44..3b42d349b 100644 --- a/flowfile_wasm/src/stores/schema-inference.ts +++ b/flowfile_wasm/src/stores/schema-inference.ts @@ -322,7 +322,7 @@ export function inferOutputSchema( * Check if a node type is a source node (produces data rather than transforming it) */ export function isSourceNode(nodeType: string): boolean { - return nodeType === 'read' || nodeType === 'manual_input' + return nodeType === 'read' || nodeType === 'manual_input' || nodeType === 'external_data' } /** diff --git a/flowfile_wasm/src/types/index.ts b/flowfile_wasm/src/types/index.ts index d2a279046..7f6c7951c 100644 --- a/flowfile_wasm/src/types/index.ts +++ b/flowfile_wasm/src/types/index.ts @@ -303,6 +303,13 @@ export interface NodeManualInputSettings extends NodeBase { raw_data_format?: RawData // Changed from 'raw_data' to match flowfile_core } +export interface NodeExternalDataSettings extends NodeBase { + /** Name of the external dataset (key into inputData prop) */ + dataset_name: string + /** Schema snapshot saved on export (data itself is NOT saved) */ + schema_snapshot?: MinimalFieldInfo[] +} + export interface NodeFilterSettings extends NodeSingleInput { filter_input: FilterInput } @@ -359,6 +366,7 @@ export interface NodeOutputSettings extends NodeSingleInput { export type NodeSettings = | NodeReadSettings | NodeManualInputSettings + | NodeExternalDataSettings | NodeFilterSettings | NodeSelectSettings | NodeSortSettings @@ -487,6 +495,7 @@ export const NODE_TYPES = { // Input nodes read: 'read', // Matches flowfile_core's 'read' type manual_input: 'manual_input', + external_data: 'external_data', // Transform nodes filter: 'filter', diff --git a/flowfile_wasm/src/utils/iconUrls.ts b/flowfile_wasm/src/utils/iconUrls.ts index 439f63892..f33e504c2 100644 --- a/flowfile_wasm/src/utils/iconUrls.ts +++ b/flowfile_wasm/src/utils/iconUrls.ts @@ -17,6 +17,7 @@ import pivot from '../assets/icons/pivot.png' import unpivot from '../assets/icons/unpivot.png' import view from '../assets/icons/view.png' import output from '../assets/icons/output.png' +import externalSource from '../assets/icons/external_source.png' export const iconUrls: Record = { 'input_data.png': inputData, @@ -33,4 +34,5 @@ export const iconUrls: Record = { 'unpivot.png': unpivot, 'view.png': view, 'output.png': output, + 'external_source.png': externalSource, } From de91f634a38b6c40ca716cb7b612c93b7fb817c5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 13 Feb 2026 14:25:10 +0000 Subject: [PATCH 3/7] Fix transparent context menu by removing Teleport to body The right-click context menu was teleported to , which placed it outside the .flowfile-editor-root container. In embedded mode, CSS variables are scoped to .flowfile-editor-root (not :root), so the teleported menu lost access to --bg-secondary and --border-color, rendering it transparent. Removing the Teleport keeps the menu in the DOM tree where CSS variables are inherited. The position:fixed style still positions it relative to the viewport. Also added external_data icon mapping to FlowNode's iconMap. https://claude.ai/code/session_01SztvepLRjChvuh634qxFnu --- .../src/components/nodes/FlowNode.vue | 37 +++++++++---------- 1 file changed, 18 insertions(+), 19 deletions(-) diff --git a/flowfile_wasm/src/components/nodes/FlowNode.vue b/flowfile_wasm/src/components/nodes/FlowNode.vue index a1a152bfd..e1765dd11 100644 --- a/flowfile_wasm/src/components/nodes/FlowNode.vue +++ b/flowfile_wasm/src/components/nodes/FlowNode.vue @@ -53,26 +53,24 @@
- - -
-
- - - - Run Now -
-
-
- - - - - - Delete -
+ +
+
+ + + + Run Now +
+
+
+ + + + + + Delete
- +
@@ -87,6 +85,7 @@ import { useFlowStore } from '../../stores/flow-store' const iconMap: Record = { read: 'input_data.png', manual_input: 'manual_input.png', + external_data: 'external_source.png', filter: 'filter.png', select: 'select.png', group_by: 'group_by.png', From 0b291014f6716ed9761fcf6ee6c249f540f8bc66 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 13 Feb 2026 14:31:28 +0000 Subject: [PATCH 4/7] Fix context menu positioning by teleporting to Canvas container MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VueFlow renders nodes inside a transformed container, which breaks position:fixed — making the context menu appear at the wrong coordinates. Instead of teleporting to (loses CSS vars) or rendering inline (breaks positioning), teleport to a dedicated container div inside Canvas.vue. This sits inside the CSS variable scope but outside VueFlow's transform layer, fixing both the styling and positioning. https://claude.ai/code/session_01SztvepLRjChvuh634qxFnu --- flowfile_wasm/src/components/Canvas.vue | 3 ++ .../src/components/nodes/FlowNode.vue | 36 ++++++++++--------- 2 files changed, 22 insertions(+), 17 deletions(-) diff --git a/flowfile_wasm/src/components/Canvas.vue b/flowfile_wasm/src/components/Canvas.vue index f9e3cd62b..74291afa4 100644 --- a/flowfile_wasm/src/components/Canvas.vue +++ b/flowfile_wasm/src/components/Canvas.vue @@ -220,6 +220,9 @@ + +
+
diff --git a/flowfile_wasm/src/components/nodes/FlowNode.vue b/flowfile_wasm/src/components/nodes/FlowNode.vue index e1765dd11..2c8ca9bd0 100644 --- a/flowfile_wasm/src/components/nodes/FlowNode.vue +++ b/flowfile_wasm/src/components/nodes/FlowNode.vue @@ -53,24 +53,26 @@ - -
-
- - - - Run Now -
-
-
- - - - - - Delete + + +
+
+ + + + Run Now +
+
+
+ + + + + + Delete +
-
+
From 35e93780c6a564485c21dd9465e2b2d9b52d91ca Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 13 Feb 2026 14:46:13 +0000 Subject: [PATCH 5/7] Add external_output node and code generation for external nodes - external_data code gen: generates pl.scan_csv("dataset.csv") placeholder with a comment telling the user to replace the path - external_output node: full stack implementation (types, settings UI, execute handler, code gen). On execution, collects the input DataFrame and sends it to the host via output callbacks. Code gen produces df.collect() to materialize the result. - New SVG icons for both external_data (blue database + inbound arrow) and external_output (green document + outbound arrow), replacing the old external_source.png https://claude.ai/code/session_01SztvepLRjChvuh634qxFnu --- .../src/assets/icons/external_data.svg | 14 ++ .../src/assets/icons/external_output.svg | 15 ++ flowfile_wasm/src/components/Canvas.vue | 9 +- .../nodes/ExternalOutputSettings.vue | 179 ++++++++++++++++++ .../src/components/nodes/FlowNode.vue | 5 +- .../src/composables/useCodeGeneration.ts | 24 +++ flowfile_wasm/src/config/nodeDescriptions.ts | 4 + flowfile_wasm/src/stores/flow-store.ts | 41 ++++ flowfile_wasm/src/types/index.ts | 7 + flowfile_wasm/src/utils/iconUrls.ts | 6 +- 10 files changed, 297 insertions(+), 7 deletions(-) create mode 100644 flowfile_wasm/src/assets/icons/external_data.svg create mode 100644 flowfile_wasm/src/assets/icons/external_output.svg create mode 100644 flowfile_wasm/src/components/nodes/ExternalOutputSettings.vue diff --git a/flowfile_wasm/src/assets/icons/external_data.svg b/flowfile_wasm/src/assets/icons/external_data.svg new file mode 100644 index 000000000..cbec864ca --- /dev/null +++ b/flowfile_wasm/src/assets/icons/external_data.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/flowfile_wasm/src/assets/icons/external_output.svg b/flowfile_wasm/src/assets/icons/external_output.svg new file mode 100644 index 000000000..e299f9352 --- /dev/null +++ b/flowfile_wasm/src/assets/icons/external_output.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/flowfile_wasm/src/components/Canvas.vue b/flowfile_wasm/src/components/Canvas.vue index 74291afa4..299967cf1 100644 --- a/flowfile_wasm/src/components/Canvas.vue +++ b/flowfile_wasm/src/components/Canvas.vue @@ -267,6 +267,7 @@ import CodeGenerator from './CodeGenerator.vue' import PivotSettings from './nodes/PivotSettings.vue' import UnpivotSettings from './nodes/UnpivotSettings.vue' import OutputSettings from './nodes/OutputSettings.vue' +import ExternalOutputSettings from './nodes/ExternalOutputSettings.vue' import NodeSettingsWrapper from './nodes/NodeSettingsWrapper.vue' import { getNodeDescription } from '../config/nodeDescriptions' import MissingFilesModal from './MissingFilesModal.vue' @@ -345,7 +346,7 @@ const nodeCategories = ref([ nodes: [ { type: 'read', name: 'Read CSV', icon: 'input_data.png', inputs: 0, outputs: 1 }, { type: 'manual_input', name: 'Manual Input', icon: 'manual_input.png', inputs: 0, outputs: 1 }, - { type: 'external_data', name: 'External Data', icon: 'external_source.png', inputs: 0, outputs: 1 } + { type: 'external_data', name: 'External Data', icon: 'external_data.svg', inputs: 0, outputs: 1 } ] }, { @@ -381,7 +382,8 @@ const nodeCategories = ref([ isOpen: true, nodes: [ { type: 'explore_data', name: 'Preview', icon: 'view.png', inputs: 1, outputs: 0 }, - { type: 'output', name: 'Write Data', icon: 'output.png', inputs: 1, outputs: 0 } + { type: 'output', name: 'Write Data', icon: 'output.png', inputs: 1, outputs: 0 }, + { type: 'external_output', name: 'External Output', icon: 'external_output.svg', inputs: 1, outputs: 0 } ] } ]) @@ -590,7 +592,8 @@ function getSettingsComponent(type: string) { explore_data: PreviewSettings, pivot: PivotSettings, unpivot: UnpivotSettings, - output: OutputSettings + output: OutputSettings, + external_output: ExternalOutputSettings } return components[type] || null } diff --git a/flowfile_wasm/src/components/nodes/ExternalOutputSettings.vue b/flowfile_wasm/src/components/nodes/ExternalOutputSettings.vue new file mode 100644 index 000000000..9a6109cf3 --- /dev/null +++ b/flowfile_wasm/src/components/nodes/ExternalOutputSettings.vue @@ -0,0 +1,179 @@ + + + + + diff --git a/flowfile_wasm/src/components/nodes/FlowNode.vue b/flowfile_wasm/src/components/nodes/FlowNode.vue index 2c8ca9bd0..69e636174 100644 --- a/flowfile_wasm/src/components/nodes/FlowNode.vue +++ b/flowfile_wasm/src/components/nodes/FlowNode.vue @@ -87,7 +87,7 @@ import { useFlowStore } from '../../stores/flow-store' const iconMap: Record = { read: 'input_data.png', manual_input: 'manual_input.png', - external_data: 'external_source.png', + external_data: 'external_data.svg', filter: 'filter.png', select: 'select.png', group_by: 'group_by.png', @@ -99,7 +99,8 @@ const iconMap: Record = { explore_data: 'view.png', pivot: 'pivot.png', unpivot: 'unpivot.png', - output: 'output.png' + output: 'output.png', + external_output: 'external_output.svg' } interface NodeData { diff --git a/flowfile_wasm/src/composables/useCodeGeneration.ts b/flowfile_wasm/src/composables/useCodeGeneration.ts index 0964d8fe3..2811e59dd 100644 --- a/flowfile_wasm/src/composables/useCodeGeneration.ts +++ b/flowfile_wasm/src/composables/useCodeGeneration.ts @@ -17,6 +17,7 @@ import type { NodeSampleSettings, NodeReadSettings, NodeManualInputSettings, + NodeExternalDataSettings, NodeOutputSettings, PolarsCodeSettings, FilterOperator, @@ -176,6 +177,9 @@ class FlowToPolarsConverter { case 'manual_input': this.handleManualInput(node.settings as NodeManualInputSettings, varName) break + case 'external_data': + this.handleExternalData(node.settings as NodeExternalDataSettings, varName) + break case 'filter': this.handleFilter(node.settings as NodeFilterSettings, varName, inputVars) break @@ -219,6 +223,9 @@ class FlowToPolarsConverter { case 'output': this.handleOutput(node.settings as NodeOutputSettings, varName, inputVars) break + case 'external_output': + this.handleExternalOutput(varName, inputVars) + break default: this.unsupportedNodes.push({ id: node.id, @@ -308,6 +315,23 @@ class FlowToPolarsConverter { this.addCode('') } + private handleExternalData(settings: NodeExternalDataSettings, varName: string): void { + const datasetName = settings.dataset_name || 'external_data' + const fileName = `${datasetName}.csv` + + this.addComment(`# External dataset: "${datasetName}"`) + this.addComment(`# Replace the path below with the actual location of your "${datasetName}" data`) + this.addCode(`${varName} = pl.scan_csv("${fileName}")`) + this.addCode('') + } + + private handleExternalOutput(varName: string, inputVars: { main?: string }): void { + const inputDf = inputVars.main || 'df' + this.addComment(`# External output — collect result for downstream use`) + this.addCode(`${varName} = ${inputDf}.collect()`) + this.addCode('') + } + private handleFilter(settings: NodeFilterSettings, varName: string, inputVars: { main?: string }): void { const inputDf = inputVars.main || 'df' const filterInput = settings.filter_input diff --git a/flowfile_wasm/src/config/nodeDescriptions.ts b/flowfile_wasm/src/config/nodeDescriptions.ts index 8f44b2e21..41391461e 100644 --- a/flowfile_wasm/src/config/nodeDescriptions.ts +++ b/flowfile_wasm/src/config/nodeDescriptions.ts @@ -64,6 +64,10 @@ export const nodeDescriptions: Record = { output: { title: 'Write Data', intro: 'Export your data as CSV. Run the flow to prepare the data, then download the file to your computer.' + }, + external_output: { + title: 'External Output', + intro: 'Send the result data back to the host application via the output callback. Use this to return processed data from the embedded editor.' } } diff --git a/flowfile_wasm/src/stores/flow-store.ts b/flowfile_wasm/src/stores/flow-store.ts index 43b530318..b7933dc51 100644 --- a/flowfile_wasm/src/stores/flow-store.ts +++ b/flowfile_wasm/src/stores/flow-store.ts @@ -16,6 +16,7 @@ import type { NodeReadSettings, NodeManualInputSettings, NodeExternalDataSettings, + NodeExternalOutputSettings, NodeFilterSettings, NodeSelectSettings, NodeGroupBySettings @@ -1680,6 +1681,40 @@ result break } + case 'external_output': { + // External output: execute like output but always emit CSV to callbacks + const inputId = node.inputIds[0] + if (!inputId) { + return { success: false, error: 'No input connected' } + } + const settings = node.settings as NodeExternalOutputSettings + const outputName = settings.output_name || 'result' + // Use the same output execution to produce CSV content + const extResult = await runPythonWithResult(` +import json +result = execute_output(${nodeId}, ${inputId}, json.loads('{"output_settings": {"name": "${outputName}.csv", "directory": ".", "file_type": "csv", "write_mode": "overwrite", "table_settings": {"file_type": "csv", "delimiter": ",", "encoding": "utf-8"}, "polars_method": "sink_csv"}}')) +result +`) + if (extResult?.success && extResult?.download) { + const { content, file_name, mime_type } = extResult.download + // Notify output callbacks (primary purpose of this node in embedded mode) + outputCallbacks.forEach(cb => cb({ + nodeId, + content, + fileName: file_name, + mimeType: mime_type + })) + result = { + success: extResult.success, + schema: extResult.schema, + data: extResult.data + } + } else { + result = extResult + } + break + } + default: return { success: false, error: `Unknown node type: ${node.type}` } } @@ -1950,6 +1985,12 @@ result } } as any + case 'external_output': + return { + ...base, + output_name: 'result' + } as NodeExternalOutputSettings + default: return base as NodeSettings } diff --git a/flowfile_wasm/src/types/index.ts b/flowfile_wasm/src/types/index.ts index 7f6c7951c..c9ce76b5a 100644 --- a/flowfile_wasm/src/types/index.ts +++ b/flowfile_wasm/src/types/index.ts @@ -362,6 +362,11 @@ export interface NodeOutputSettings extends NodeSingleInput { output_settings: OutputSettings } +export interface NodeExternalOutputSettings extends NodeSingleInput { + /** Label for the output (used in callbacks and code generation) */ + output_name: string +} + // Union type for all node settings export type NodeSettings = | NodeReadSettings @@ -379,6 +384,7 @@ export type NodeSettings = | NodePivotSettings | NodeUnpivotSettings | NodeOutputSettings + | NodeExternalOutputSettings // ============================================================================= // FLOWFILE DATA STRUCTURE (for save/load - matches flowfile_core/schemas/schemas.py) @@ -516,6 +522,7 @@ export const NODE_TYPES = { // Output nodes explore_data: 'explore_data', // Matches flowfile_core's 'explore_data' type output: 'output', + external_output: 'external_output', } as const export type NodeType = typeof NODE_TYPES[keyof typeof NODE_TYPES] diff --git a/flowfile_wasm/src/utils/iconUrls.ts b/flowfile_wasm/src/utils/iconUrls.ts index f33e504c2..174ff192a 100644 --- a/flowfile_wasm/src/utils/iconUrls.ts +++ b/flowfile_wasm/src/utils/iconUrls.ts @@ -17,7 +17,8 @@ import pivot from '../assets/icons/pivot.png' import unpivot from '../assets/icons/unpivot.png' import view from '../assets/icons/view.png' import output from '../assets/icons/output.png' -import externalSource from '../assets/icons/external_source.png' +import externalData from '../assets/icons/external_data.svg' +import externalOutput from '../assets/icons/external_output.svg' export const iconUrls: Record = { 'input_data.png': inputData, @@ -34,5 +35,6 @@ export const iconUrls: Record = { 'unpivot.png': unpivot, 'view.png': view, 'output.png': output, - 'external_source.png': externalSource, + 'external_data.svg': externalData, + 'external_output.svg': externalOutput, } From 92a5be98b29ffd7162e8bdbf4614d795c9abe6c5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 13 Feb 2026 14:53:23 +0000 Subject: [PATCH 6/7] Add /embed-example page demonstrating embedded editor with live data Interactive example page at /embed-example that shows: - Two editable input datasets (orders + customers) as CSV text areas - Dataset names are editable and map to External Data node dropdown - An "Add Dataset" button for adding more inputs on the fly - Output results panel that shows data from External Output nodes - The full FlowfileEditor embedded in the right panel This serves as both a test page and reference implementation for consumers building their own integration. https://claude.ai/code/session_01SztvepLRjChvuh634qxFnu --- flowfile_wasm/src/router/index.ts | 5 + flowfile_wasm/src/views/EmbedExample.vue | 366 +++++++++++++++++++++++ 2 files changed, 371 insertions(+) create mode 100644 flowfile_wasm/src/views/EmbedExample.vue diff --git a/flowfile_wasm/src/router/index.ts b/flowfile_wasm/src/router/index.ts index 28a47ffee..3fafd9ad5 100644 --- a/flowfile_wasm/src/router/index.ts +++ b/flowfile_wasm/src/router/index.ts @@ -7,6 +7,11 @@ const router = createRouter({ path: '/', name: 'app', component: () => import('../views/AppPage.vue') + }, + { + path: '/embed-example', + name: 'embed-example', + component: () => import('../views/EmbedExample.vue') } ] }) diff --git a/flowfile_wasm/src/views/EmbedExample.vue b/flowfile_wasm/src/views/EmbedExample.vue new file mode 100644 index 000000000..0cfe0e55c --- /dev/null +++ b/flowfile_wasm/src/views/EmbedExample.vue @@ -0,0 +1,366 @@ + + + + + From 21d75fff25529222eb48f199ed622dc273173689 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 13 Feb 2026 15:03:03 +0000 Subject: [PATCH 7/7] Contain draggable panels within editor bounds using position: absolute DraggablePanel used position: fixed, so panels were positioned relative to the viewport. When the editor is embedded in part of a page, panels would overflow into the host page's other areas. Changed to position: absolute so panels are positioned relative to .canvas-container (which has position: relative + overflow: hidden). All window.innerWidth/Height references now use getContainerRect() to measure the actual container. Added ResizeObserver to track container size changes in addition to window resize events. Panel dragging is also now clamped to container boundaries. https://claude.ai/code/session_01SztvepLRjChvuh634qxFnu --- flowfile_wasm/src/components/Canvas.vue | 1 + .../src/components/common/DraggablePanel.vue | 61 ++++++++++++++----- 2 files changed, 48 insertions(+), 14 deletions(-) diff --git a/flowfile_wasm/src/components/Canvas.vue b/flowfile_wasm/src/components/Canvas.vue index 299967cf1..591469452 100644 --- a/flowfile_wasm/src/components/Canvas.vue +++ b/flowfile_wasm/src/components/Canvas.vue @@ -804,6 +804,7 @@ onUnmounted(() => { flex-direction: column; height: 100%; position: relative; + overflow: hidden; background: var(--bg-primary); } diff --git a/flowfile_wasm/src/components/common/DraggablePanel.vue b/flowfile_wasm/src/components/common/DraggablePanel.vue index 656c8b50a..716db5141 100644 --- a/flowfile_wasm/src/components/common/DraggablePanel.vue +++ b/flowfile_wasm/src/components/common/DraggablePanel.vue @@ -87,6 +87,7 @@ const resizeDirection = ref(null) let prevViewportWidth = window.innerWidth let prevViewportHeight = window.innerHeight let resizeDebounceTimer: ReturnType | null = null +let containerResizeObserver: ResizeObserver | null = null // Edge snap threshold in pixels - if panel is within this distance of an edge, consider it "docked" const EDGE_SNAP_THRESHOLD = 10 @@ -94,10 +95,24 @@ const MIN_PANEL_WIDTH = 200 const MIN_PANEL_HEIGHT = 100 const MIN_VISIBLE_HEADER = 50 // Minimum header visible for dragging +/** + * Get container dimensions. With position: absolute the panel is + * positioned relative to .canvas-container instead of the viewport. + */ +function getContainerRect(): { width: number; height: number; left: number; top: number } { + const parent = panelRef.value?.parentElement + if (parent) { + const rect = parent.getBoundingClientRect() + return { width: rect.width, height: rect.height, left: rect.left, top: rect.top } + } + return { width: window.innerWidth, height: window.innerHeight, left: 0, top: 0 } +} + // Save current panel state to storage function saveCurrentState() { if (!props.panelId) return + const container = getContainerRect() const state: PanelState = { width: width.value, height: height.value, @@ -105,9 +120,9 @@ function saveCurrentState() { top: top.value, isMinimized: isMinimized.value, zIndex: zIndex.value, - // Save viewport dimensions to detect resize on next load - savedViewportWidth: window.innerWidth, - savedViewportHeight: window.innerHeight + // Save container dimensions to detect resize on next load + savedViewportWidth: container.width, + savedViewportHeight: container.height } savePanelState(props.panelId, state) } @@ -130,8 +145,9 @@ function detectDockedEdges(vw: number, vh: number) { // Smart resize handler - maintains edge docking and proportional positioning function handleWindowResizeSmartly() { - const newVw = window.innerWidth - const newVh = window.innerHeight + const container = getContainerRect() + const newVw = container.width + const newVh = container.height // Skip if viewport didn't actually change if (newVw === prevViewportWidth && newVh === prevViewportHeight) { @@ -260,8 +276,9 @@ function handleWindowResize() { // Reset panel to initial position based on initialPosition prop function resetToInitialPosition() { - const vh = window.innerHeight - const vw = window.innerWidth + const container = getContainerRect() + const vh = container.height + const vw = container.width // Reset to initial dimensions width.value = props.initialWidth @@ -304,8 +321,9 @@ function handleLayoutReset() { // Compute initial position based on prop onMounted(() => { - const vh = window.innerHeight - const vw = window.innerWidth + const container = getContainerRect() + const vh = container.height + const vw = container.width // Initialize viewport tracking for smart resize prevViewportWidth = vw @@ -411,6 +429,15 @@ onMounted(() => { window.addEventListener('resize', handleWindowResize) // Listen for layout reset events from LayoutControls window.addEventListener('layout-reset', handleLayoutReset) + + // Observe container size changes (handles embedded editor resizing) + const parent = panelRef.value?.parentElement + if (parent) { + containerResizeObserver = new ResizeObserver(() => { + handleWindowResize() + }) + containerResizeObserver.observe(parent) + } }) // Watch for changes to initialTop and update position (only if no saved state) @@ -420,12 +447,12 @@ watch(() => props.initialTop, (newTop) => { return } - const vh = window.innerHeight + const container = getContainerRect() top.value = newTop // Adjust height for left/right panels if (props.initialPosition === 'left' || props.initialPosition === 'right') { - height.value = vh - newTop + height.value = container.height - newTop } }) @@ -473,9 +500,10 @@ function onMove(e: MouseEvent) { const dx = e.clientX - startX.value const dy = e.clientY - startY.value + const container = getContainerRect() - left.value = Math.max(0, startLeft.value + dx) - top.value = Math.max(0, startTop.value + dy) + left.value = Math.max(0, Math.min(startLeft.value + dx, container.width - MIN_VISIBLE_HEADER)) + top.value = Math.max(0, Math.min(startTop.value + dy, container.height - MIN_VISIBLE_HEADER)) } function stopMove() { @@ -544,6 +572,11 @@ onUnmounted(() => { document.removeEventListener('mouseup', stopResize) window.removeEventListener('resize', handleWindowResize) window.removeEventListener('layout-reset', handleLayoutReset) + // Clean up container resize observer + if (containerResizeObserver) { + containerResizeObserver.disconnect() + containerResizeObserver = null + } // Clear any pending debounce timer if (resizeDebounceTimer) { clearTimeout(resizeDebounceTimer) @@ -558,7 +591,7 @@ onUnmounted(() => {