From be748b162c5317027285b3b672dce8f985e8c889 Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Mon, 20 Apr 2026 00:31:09 +0200 Subject: [PATCH] pwg-demo --- src/api/workflowApi.ts | 102 ++++++++ .../editor/Automation2FlowEditor.tsx | 23 +- .../nodes/frontendTypeRenderers/index.tsx | 227 +++++++++++++++++- .../GraphicalEditorWorkflowsPage.tsx | 96 +++++++- src/pages/views/workspace/WorkspaceInput.tsx | 13 + src/pages/views/workspace/WorkspacePage.tsx | 17 +- 6 files changed, 469 insertions(+), 9 deletions(-) diff --git a/src/api/workflowApi.ts b/src/api/workflowApi.ts index a321c40..8c7a9e2 100644 --- a/src/api/workflowApi.ts +++ b/src/api/workflowApi.ts @@ -392,6 +392,108 @@ export async function deleteWorkflow( }); } +// ------------------------------------------------------------------------- +// Workflow file IO (envelopeVersioned, .workflow.json) +// ------------------------------------------------------------------------- + +/** envelopeVersioned schema 1.0 — keys mirror the gateway constants. */ +export const WORKFLOW_FILE_SCHEMA_VERSION = '1.0'; +export const WORKFLOW_FILE_KIND = 'poweron.workflow'; +export const WORKFLOW_FILE_EXTENSION = '.workflow.json'; + +export interface WorkflowFileEnvelope { + $schemaVersion: string; + $kind: string; + $exportedAt?: string; + $gatewayVersion?: string; + label: string; + description?: string; + tags?: string[]; + templateScope?: AutoTemplateScope; + sharedReadOnly?: boolean; + notifyOnFailure?: boolean; + graph: Automation2Graph; + invocations?: WorkflowEntryPoint[]; +} + +export interface ImportWorkflowResponse { + workflow: AutoWorkflow; + warnings: string[]; + created: boolean; +} + +export interface ImportWorkflowOptions { + /** Inline envelope payload (preferred for round-trip in the editor). */ + envelope?: WorkflowFileEnvelope; + /** UDB FileItem.id of a previously uploaded ``.workflow.json``. */ + fileId?: string; + /** When set, the existing workflow is replaced instead of a new one being created. */ + existingWorkflowId?: string; +} + +/** POST /api/workflows/{instanceId}/workflows/import */ +export async function importWorkflowFromFile( + request: ApiRequestFunction, + instanceId: string, + options: ImportWorkflowOptions, +): Promise { + if (!options.envelope && !options.fileId) { + throw new Error('importWorkflowFromFile: either envelope or fileId is required'); + } + return await request({ + url: `/api/workflows/${instanceId}/workflows/import`, + method: 'post', + data: options, + }); +} + +export interface ExportWorkflowResult { + fileName: string; + envelope: WorkflowFileEnvelope; +} + +/** + * GET /api/workflows/{instanceId}/workflows/{workflowId}/export + * + * The backend returns ``{ fileName, envelope }`` when ``download=false`` and a + * raw JSON download (``Content-Disposition: attachment``) when ``download=true``. + * For programmatic use (e.g. re-uploading to UDB) keep download=false. + */ +export async function exportWorkflowToFile( + request: ApiRequestFunction, + instanceId: string, + workflowId: string, + download = false, +): Promise { + return await request({ + url: `/api/workflows/${instanceId}/workflows/${workflowId}/export`, + method: 'get', + params: { download }, + }); +} + +/** Quick content-sniffing — used by the UDB FilesTab to flag workflow files. */ +export function isWorkflowFileContent(payload: unknown): boolean { + if (!payload || typeof payload !== 'object') return false; + const p = payload as Record; + return ( + typeof p.$schemaVersion === 'string' && + p.$kind === WORKFLOW_FILE_KIND && + !!p.graph && + typeof p.graph === 'object' + ); +} + +/** Suggest a safe filename from the workflow label (mirrors gateway buildFileName). */ +export function workflowFileNameFor(label: string): string { + const slug = (label || 'workflow') + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 80) || 'workflow'; + return `${slug}${WORKFLOW_FILE_EXTENSION}`; +} + /** Delete by workflow ID only (Automations dashboard / orphan rows without featureInstanceId). */ export async function deleteSystemWorkflow( request: ApiRequestFunction, diff --git a/src/components/FlowEditor/editor/Automation2FlowEditor.tsx b/src/components/FlowEditor/editor/Automation2FlowEditor.tsx index 1438279..5a0d0d1 100644 --- a/src/components/FlowEditor/editor/Automation2FlowEditor.tsx +++ b/src/components/FlowEditor/editor/Automation2FlowEditor.tsx @@ -275,14 +275,35 @@ export const Automation2FlowEditor: React.FC = ({ in } else { applyGraphWithSync({ nodes: [], connections: [] }, wf.invocations); } + setWorkflows((prev) => { + const idx = prev.findIndex((w) => w.id === workflowId); + if (idx === -1) return [...prev, wf]; + const next = prev.slice(); + next[idx] = { ...prev[idx], ...wf }; + return next; + }); } catch (err: unknown) { + const status = (err as { response?: { status?: number } })?.response?.status; + if (status === 404) { + setWorkflows((prev) => prev.filter((w) => w.id !== workflowId)); + setCurrentWorkflowId((prev) => (prev === workflowId ? null : prev)); + setExecuteResult(null); + applyGraphWithSync({ nodes: [], connections: [] }, _buildDefaultInvocations(t('Jetzt ausführen'))); + try { + const result = await fetchWorkflows(request, instanceId); + setWorkflows(Array.isArray(result) ? result : result.items); + } catch (refreshErr) { + console.error(`${LOG} workflows refresh failed`, refreshErr); + } + return; + } setExecuteResult({ success: false, error: err instanceof Error ? err.message : String(err), }); } }, - [request, instanceId, handleFromApiGraph, applyGraphWithSync] + [request, instanceId, handleFromApiGraph, applyGraphWithSync, t] ); const handleWorkflowSelect = useCallback( diff --git a/src/components/FlowEditor/nodes/frontendTypeRenderers/index.tsx b/src/components/FlowEditor/nodes/frontendTypeRenderers/index.tsx index f966f32..3d86c7f 100644 --- a/src/components/FlowEditor/nodes/frontendTypeRenderers/index.tsx +++ b/src/components/FlowEditor/nodes/frontendTypeRenderers/index.tsx @@ -153,15 +153,23 @@ const HiddenInput: React.FC = () => null; const ConnectionPicker: React.FC = ({ param, value, onChange, instanceId, request }) => { const { t } = useLanguage(); const [connections, setConnections] = React.useState>([]); + const [loadError, setLoadError] = React.useState(null); + const authority = (param.frontendOptions?.authority as string | undefined) || undefined; React.useEffect(() => { if (!instanceId || !request) return; - request({ url: `/api/graphicalEditor/${instanceId}/options/user.connection`, method: 'get' }) + const qs = authority ? `?authority=${encodeURIComponent(authority)}` : ''; + setLoadError(null); + request({ url: `/api/workflows/${instanceId}/options/user.connection${qs}`, method: 'get' }) .then((res: unknown) => { const data = res as { options?: Array<{ value: string; label: string }> }; setConnections((data?.options || []).map((o) => ({ id: o.value, label: o.label }))); }) - .catch(() => {}); - }, [instanceId, request]); + .catch((err: unknown) => { + console.error('ConnectionPicker: failed to load connections', err); + setConnections([]); + setLoadError(err instanceof Error ? err.message : String(err)); + }); + }, [instanceId, request, authority]); return (
@@ -175,6 +183,16 @@ const ConnectionPicker: React.FC = ({ param, value, onChange ))} + {!loadError && connections.length === 0 && ( +
+ {authority + ? t('Keine {authority}-Verbindung gefunden — Verbindung in den Account-Einstellungen anlegen.', { authority }) + : t('Keine Verbindung gefunden — Verbindung in den Account-Einstellungen anlegen.')} +
+ )} + {loadError && ( +
{t('Verbindungen konnten nicht geladen werden')}
+ )}
); }; @@ -199,6 +217,205 @@ const FolderPicker: React.FC = ({ param, value, onChange, al ); }; +type SharepointSiteOption = { type: 'site'; value: string; label: string; siteId: string; path: string; webUrl?: string }; +type SharepointItemOption = { type: 'folder' | 'file'; value: string; label: string; path: string; siteId: string }; + +const SharepointPathPicker: React.FC = ({ param, value, onChange, allParams, request }) => { + const { t } = useLanguage(); + const isFilePicker = param.frontendType === 'sharepointFile'; + const dependsOn = (param.frontendOptions?.dependsOn as string | undefined) || 'connectionReference'; + const connectionReference = (allParams?.[dependsOn] as string | undefined) || ''; + const hasConnection = !!connectionReference; + + const [open, setOpen] = React.useState(false); + const [sites, setSites] = React.useState([]); + const [selectedSite, setSelectedSite] = React.useState(null); + const [currentPath, setCurrentPath] = React.useState(''); + const [items, setItems] = React.useState([]); + const [loading, setLoading] = React.useState(false); + const [error, setError] = React.useState(null); + + const loadSites = React.useCallback(async () => { + if (!request || !connectionReference) return; + setLoading(true); + setError(null); + try { + const res = (await request({ + url: `/api/sharepoint/folder-options?connectionReference=${encodeURIComponent(connectionReference)}`, + method: 'get', + })) as SharepointSiteOption[] | null; + setSites(Array.isArray(res) ? res : []); + } catch (err: unknown) { + console.error('SharepointPathPicker: failed to load sites', err); + setSites([]); + setError(err instanceof Error ? err.message : String(err)); + } finally { + setLoading(false); + } + }, [request, connectionReference]); + + const loadItems = React.useCallback(async (site: SharepointSiteOption, path: string) => { + if (!request) return; + setLoading(true); + setError(null); + try { + const params = new URLSearchParams({ connectionReference, siteId: site.siteId }); + if (path) params.append('path', path); + if (isFilePicker) params.append('includeFiles', 'true'); + const res = (await request({ + url: `/api/sharepoint/folder-options?${params.toString()}`, + method: 'get', + })) as SharepointItemOption[] | null; + setItems(Array.isArray(res) ? res : []); + } catch (err: unknown) { + console.error('SharepointPathPicker: failed to load items', err); + setItems([]); + setError(err instanceof Error ? err.message : String(err)); + } finally { + setLoading(false); + } + }, [request, connectionReference, isFilePicker]); + + React.useEffect(() => { + if (!open) return; + if (sites.length === 0 && !loading) loadSites(); + }, [open, sites.length, loading, loadSites]); + + React.useEffect(() => { + if (selectedSite) { + setCurrentPath(''); + loadItems(selectedSite, ''); + } else { + setItems([]); + } + }, [selectedSite, loadItems]); + + const navigateInto = (item: SharepointItemOption) => { + if (item.type !== 'folder' || !selectedSite) return; + setCurrentPath(item.path); + loadItems(selectedSite, item.path); + }; + + const goUp = () => { + if (!selectedSite || !currentPath) return; + const parts = currentPath.split('/'); + parts.pop(); + const parent = parts.join('/'); + setCurrentPath(parent); + loadItems(selectedSite, parent); + }; + + const buildFullPath = (sub: string) => { + const sitePath = (selectedSite?.path || '').replace(/\/+$/, ''); + const cleanSub = sub.replace(/^\/+/, ''); + if (!sitePath) return `/${cleanSub}`; + if (!cleanSub) return sitePath; + return `${sitePath}/${cleanSub}`; + }; + + const pickCurrentFolder = () => { + if (!selectedSite) return; + const fullPath = buildFullPath(currentPath); + onChange(fullPath); + setOpen(false); + }; + + const pickItem = (item: SharepointItemOption) => { + if (!selectedSite) return; + const fullPath = buildFullPath(item.path); + onChange(fullPath); + if (item.type === 'file') setOpen(false); + }; + + return ( +
+ +
+ onChange(e.target.value)} + disabled={!hasConnection} + placeholder={hasConnection ? (isFilePicker ? t('SharePoint-Dateipfad') : t('SharePoint-Ordnerpfad')) : t('Zuerst {field} wählen', { field: dependsOn })} + style={{ flex: 1, padding: '4px 8px', borderRadius: 4, border: '1px solid #ccc', opacity: hasConnection ? 1 : 0.5 }} + /> + +
+ + {open && hasConnection && ( +
+ {error &&
{error}
} +
+ {t('Seite:')} + + +
+ + {selectedSite && ( + <> +
+ {t('Pfad:')} {selectedSite.path}/{currentPath || {t('(Stammverzeichnis)')}} +
+
+ {currentPath && ( + + )} + +
+
+ {loading &&
{t('Lade')}
} + {!loading && items.length === 0 && ( +
{isFilePicker ? t('Keine Dateien oder Ordner') : t('Keine Unterordner')}
+ )} + {!loading && items.map((item) => ( +
+ (item.type === 'folder' ? navigateInto(item) : pickItem(item))} + style={{ flex: 1, cursor: 'pointer', userSelect: 'none' }} + title={item.type === 'folder' ? t('Öffnen') : t('Wählen')} + > + {item.type === 'folder' ? '📁' : '📄'} {item.label} + + +
+ ))} +
+ + )} +
+ )} +
+ ); +}; + const CaseListEditor: React.FC = ({ param, value, onChange }) => { const { t } = useLanguage(); const cases = Array.isArray(value) ? value : []; @@ -402,8 +619,8 @@ export const FRONTEND_TYPE_RENDERERS: Record = { file: TextInput, hidden: HiddenInput, userConnection: ConnectionPicker, - sharepointFolder: FolderPicker, - sharepointFile: FolderPicker, + sharepointFolder: SharepointPathPicker, + sharepointFile: SharepointPathPicker, clickupList: FolderPicker, clickupTask: FolderPicker, caseList: CaseListEditor, diff --git a/src/pages/views/graphicalEditor/GraphicalEditorWorkflowsPage.tsx b/src/pages/views/graphicalEditor/GraphicalEditorWorkflowsPage.tsx index 3a0d11c..4d0c0ad 100644 --- a/src/pages/views/graphicalEditor/GraphicalEditorWorkflowsPage.tsx +++ b/src/pages/views/graphicalEditor/GraphicalEditorWorkflowsPage.tsx @@ -6,9 +6,9 @@ * Actions: Edit, Delete, Aktivieren/Deaktivieren, Ausführen (nur bei manuellem Trigger). */ -import React, { useState, useCallback, useEffect } from 'react'; +import React, { useState, useCallback, useEffect, useRef } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; -import { FaPlay, FaSync, FaCheck, FaBan, FaPen } from 'react-icons/fa'; +import { FaPlay, FaSync, FaCheck, FaBan, FaPen, FaFileImport, FaFileExport } from 'react-icons/fa'; import { usePrompt } from '../../../hooks/usePrompt'; import { FormGeneratorTable, type ColumnConfig } from '../../../components/FormGenerator'; import { useInstanceId } from '../../../hooks/useCurrentInstance'; @@ -18,7 +18,13 @@ import { deleteWorkflow, executeGraph, updateWorkflow, + importWorkflowFromFile, + exportWorkflowToFile, + isWorkflowFileContent, + workflowFileNameFor, + WORKFLOW_FILE_EXTENSION, type Automation2Workflow, + type WorkflowFileEnvelope, } from '../../../api/workflowApi'; import { useToast } from '../../../contexts/ToastContext'; import { formatUnixTimestamp } from '../../../utils/time'; @@ -56,6 +62,8 @@ export const GraphicalEditorWorkflowsPage: React.FC = () => { const [activeFilter, setActiveFilter] = useState<'all' | 'active' | 'inactive'>('all'); const [paginationMeta, setPaginationMeta] = useState(null); + const [importing, setImporting] = useState(false); + const importFileInputRef = useRef(null); const load = useCallback(async (paginationParams?: any) => { if (!instanceId) return; @@ -180,6 +188,69 @@ export const GraphicalEditorWorkflowsPage: React.FC = () => { [instanceId, request, showSuccess, showError, load, t] ); + const handleExport = useCallback( + async (row: Automation2Workflow) => { + if (!instanceId) return; + try { + const result = await exportWorkflowToFile(request, instanceId, row.id, false); + const fileName = result.fileName || workflowFileNameFor(row.label); + const blob = new Blob([JSON.stringify(result.envelope, null, 2)], { + type: 'application/json;charset=utf-8', + }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = fileName; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + showSuccess(t('Workflow als Datei exportiert')); + } catch (e: any) { + showError(t('Fehler: {msg}', { msg: e?.message || t('Export fehlgeschlagen') })); + } + }, + [instanceId, request, showSuccess, showError, t], + ); + + const handleImportFileSelected = useCallback( + async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + e.target.value = ''; + if (!file || !instanceId) return; + setImporting(true); + try { + const text = await file.text(); + let envelope: WorkflowFileEnvelope; + try { + envelope = JSON.parse(text) as WorkflowFileEnvelope; + } catch { + showError(t('Datei ist kein gültiges JSON')); + return; + } + if (!isWorkflowFileContent(envelope)) { + showError(t('Datei ist kein PowerOn-Workflow ({ext})', { ext: WORKFLOW_FILE_EXTENSION })); + return; + } + const result = await importWorkflowFromFile(request, instanceId, { envelope }); + const warnings = result?.warnings ?? []; + if (warnings.length > 0) { + showSuccess( + t('Workflow importiert ({n} Warnungen). Aktivierung manuell.', { n: warnings.length }), + ); + } else { + showSuccess(t('Workflow importiert (deaktiviert). Bitte vor Aktivierung prüfen.')); + } + await load(); + } catch (e: any) { + showError(t('Fehler: {msg}', { msg: e?.message || t('Import fehlgeschlagen') })); + } finally { + setImporting(false); + } + }, + [instanceId, request, showSuccess, showError, load, t], + ); + const columns: ColumnConfig[] = [ { key: 'label', label: t('Workflow'), type: 'string', width: 200, sortable: true }, { @@ -274,6 +345,21 @@ export const GraphicalEditorWorkflowsPage: React.FC = () => { ))} + +