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/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 aa952761d..591469452 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
@@ -218,6 +220,9 @@ + +
+
@@ -229,7 +234,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' @@ -246,6 +253,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' @@ -259,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' @@ -266,6 +275,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 +319,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 @@ -313,7 +345,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_data.svg', inputs: 0, outputs: 1 } ] }, { @@ -349,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 } ] } ]) @@ -546,6 +580,7 @@ function getSettingsComponent(type: string) { const components: Record = { read: ReadCsvSettings, manual_input: ManualInputSettings, + external_data: ExternalDataSettings, filter: FilterSettings, select: SelectSettings, group_by: GroupBySettings, @@ -557,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 } @@ -669,6 +705,7 @@ function autoSizeColumns() { // Toolbar handlers async function handleRunFlow() { await flowStore.executeFlow() + emit('execution-complete', nodeResults.value) } function handleSaveFlow() { @@ -723,10 +760,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 +785,10 @@ onMounted(async () => { onUnmounted(() => { window.removeEventListener('keydown', handleKeyDown) + // Unregister output callback + if (flowStore.offOutput) { + flowStore.offOutput(handleOutputCallback) + } }) @@ -753,6 +804,7 @@ onUnmounted(() => { flex-direction: column; height: 100%; position: relative; + overflow: hidden; background: var(--bg-primary); } 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/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(() => { 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 a1a152bfd..69e636174 100644 --- a/flowfile_wasm/src/components/nodes/FlowNode.vue +++ b/flowfile_wasm/src/components/nodes/FlowNode.vue @@ -53,8 +53,8 @@ - - + +
@@ -87,6 +87,7 @@ import { useFlowStore } from '../../stores/flow-store' const iconMap: Record = { read: 'input_data.png', manual_input: 'manual_input.png', + external_data: 'external_data.svg', filter: 'filter.png', select: 'select.png', group_by: 'group_by.png', @@ -98,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/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/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 2c07eacc0..41391461e 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.' @@ -60,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/lib/FlowfileEditor.vue b/flowfile_wasm/src/lib/FlowfileEditor.vue new file mode 100644 index 000000000..1f5061e27 --- /dev/null +++ b/flowfile_wasm/src/lib/FlowfileEditor.vue @@ -0,0 +1,238 @@ + + + + + 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/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/stores/flow-store.ts b/flowfile_wasm/src/stores/flow-store.ts index 768a2a2e1..b7933dc51 100644 --- a/flowfile_wasm/src/stores/flow-store.ts +++ b/flowfile_wasm/src/stores/flow-store.ts @@ -15,6 +15,8 @@ import type { NodeBase, NodeReadSettings, NodeManualInputSettings, + NodeExternalDataSettings, + NodeExternalOutputSettings, NodeFilterSettings, NodeSelectSettings, NodeGroupBySettings @@ -103,6 +105,13 @@ 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() + + // 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 @@ -1430,6 +1474,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) { @@ -1590,6 +1655,13 @@ result mime_type, row_count ) + // Notify output callbacks (for embeddable mode) + outputCallbacks.forEach(cb => cb({ + nodeId, + content, + fileName: file_name, + mimeType: mime_type + })) // Create result without content - just metadata result = { success: outputResult.success, @@ -1609,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}` } } @@ -1748,6 +1854,13 @@ result } } as NodeManualInputSettings + case 'external_data': + return { + ...base, + dataset_name: '', + schema_snapshot: [] + } as NodeExternalDataSettings + case 'filter': return { ...base, @@ -1872,6 +1985,12 @@ result } } as any + case 'external_output': + return { + ...base, + output_name: 'result' + } as NodeExternalOutputSettings + default: return base as NodeSettings } @@ -1904,6 +2023,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, @@ -1915,7 +2047,7 @@ result right_input_id: node.rightInputId, input_ids: node.inputIds, outputs, - setting_input: cleanSettingInput(node.settings) + setting_input: settingInput } flowfileNodes.push(flowfileNode) @@ -2257,6 +2389,10 @@ result addEdge, removeEdge, setFileContent, + externalDatasets, + setExternalDatasets, + getExternalDatasetNames, + getExternalDatasetContent, selectNode, executeNode, executeFlow, @@ -2282,6 +2418,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/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/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/types/index.ts b/flowfile_wasm/src/types/index.ts index d2a279046..c9ce76b5a 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 } @@ -355,10 +362,16 @@ 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 | NodeManualInputSettings + | NodeExternalDataSettings | NodeFilterSettings | NodeSelectSettings | NodeSortSettings @@ -371,6 +384,7 @@ export type NodeSettings = | NodePivotSettings | NodeUnpivotSettings | NodeOutputSettings + | NodeExternalOutputSettings // ============================================================================= // FLOWFILE DATA STRUCTURE (for save/load - matches flowfile_core/schemas/schemas.py) @@ -487,6 +501,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', @@ -507,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 new file mode 100644 index 000000000..174ff192a --- /dev/null +++ b/flowfile_wasm/src/utils/iconUrls.ts @@ -0,0 +1,40 @@ +/** + * 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' +import externalData from '../assets/icons/external_data.svg' +import externalOutput from '../assets/icons/external_output.svg' + +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, + 'external_data.svg': externalData, + 'external_output.svg': externalOutput, +} 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 @@ + + + + + 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: {