feat: support importing and overwriting workflow DSL (#5511)
Co-authored-by: StyleZhang <jasonapring2015@outlook.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useReactFlow } from 'reactflow'
|
||||
import { useWorkflowStore } from '../store'
|
||||
import { WORKFLOW_DATA_UPDATE } from '../constants'
|
||||
@@ -11,6 +12,9 @@ import { useEdgesInteractions } from './use-edges-interactions'
|
||||
import { useNodesInteractions } from './use-nodes-interactions'
|
||||
import { useEventEmitterContextContext } from '@/context/event-emitter'
|
||||
import { fetchWorkflowDraft } from '@/service/workflow'
|
||||
import { exportAppConfig } from '@/service/apps'
|
||||
import { useToastContext } from '@/app/components/base/toast'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
|
||||
export const useWorkflowInteractions = () => {
|
||||
const workflowStore = useWorkflowStore()
|
||||
@@ -71,3 +75,29 @@ export const useWorkflowUpdate = () => {
|
||||
handleRefreshWorkflowDraft,
|
||||
}
|
||||
}
|
||||
|
||||
export const useDSL = () => {
|
||||
const { t } = useTranslation()
|
||||
const { notify } = useToastContext()
|
||||
const appDetail = useAppStore(s => s.appDetail)
|
||||
|
||||
const handleExportDSL = useCallback(async () => {
|
||||
if (!appDetail)
|
||||
return
|
||||
try {
|
||||
const { data } = await exportAppConfig(appDetail.id)
|
||||
const a = document.createElement('a')
|
||||
const file = new Blob([data], { type: 'application/yaml' })
|
||||
a.href = URL.createObjectURL(file)
|
||||
a.download = `${appDetail.name}.yml`
|
||||
a.click()
|
||||
}
|
||||
catch (e) {
|
||||
notify({ type: 'error', message: t('app.exportFailed') })
|
||||
}
|
||||
}, [appDetail, notify, t])
|
||||
|
||||
return {
|
||||
handleExportDSL,
|
||||
}
|
||||
}
|
||||
|
@@ -20,6 +20,7 @@ import ReactFlow, {
|
||||
useEdgesState,
|
||||
useNodesState,
|
||||
useOnViewportChange,
|
||||
useReactFlow,
|
||||
} from 'reactflow'
|
||||
import type {
|
||||
Viewport,
|
||||
@@ -32,6 +33,7 @@ import type {
|
||||
} from './types'
|
||||
import { WorkflowContextProvider } from './context'
|
||||
import {
|
||||
useDSL,
|
||||
useEdgesInteractions,
|
||||
useNodesInteractions,
|
||||
useNodesReadOnly,
|
||||
@@ -58,6 +60,7 @@ import CandidateNode from './candidate-node'
|
||||
import PanelContextmenu from './panel-contextmenu'
|
||||
import NodeContextmenu from './node-contextmenu'
|
||||
import SyncingDataModal from './syncing-data-modal'
|
||||
import UpdateDSLModal from './update-dsl-modal'
|
||||
import {
|
||||
useStore,
|
||||
useWorkflowStore,
|
||||
@@ -76,6 +79,7 @@ import {
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { FeaturesProvider } from '@/app/components/base/features'
|
||||
import type { Features as FeaturesData } from '@/app/components/base/features/types'
|
||||
import { useFeaturesStore } from '@/app/components/base/features/hooks'
|
||||
import { useEventEmitterContextContext } from '@/context/event-emitter'
|
||||
import Confirm from '@/app/components/base/confirm/common'
|
||||
|
||||
@@ -99,15 +103,20 @@ const Workflow: FC<WorkflowProps> = memo(({
|
||||
}) => {
|
||||
const workflowContainerRef = useRef<HTMLDivElement>(null)
|
||||
const workflowStore = useWorkflowStore()
|
||||
const reactflow = useReactFlow()
|
||||
const featuresStore = useFeaturesStore()
|
||||
const [nodes, setNodes] = useNodesState(originalNodes)
|
||||
const [edges, setEdges] = useEdgesState(originalEdges)
|
||||
const showFeaturesPanel = useStore(state => state.showFeaturesPanel)
|
||||
const controlMode = useStore(s => s.controlMode)
|
||||
const nodeAnimation = useStore(s => s.nodeAnimation)
|
||||
const showConfirm = useStore(s => s.showConfirm)
|
||||
const showImportDSLModal = useStore(s => s.showImportDSLModal)
|
||||
const {
|
||||
setShowConfirm,
|
||||
setControlPromptEditorRerenderKey,
|
||||
setShowImportDSLModal,
|
||||
setSyncWorkflowDraftHash,
|
||||
} = workflowStore.getState()
|
||||
const {
|
||||
handleSyncWorkflowDraft,
|
||||
@@ -122,6 +131,19 @@ const Workflow: FC<WorkflowProps> = memo(({
|
||||
if (v.type === WORKFLOW_DATA_UPDATE) {
|
||||
setNodes(v.payload.nodes)
|
||||
setEdges(v.payload.edges)
|
||||
|
||||
if (v.payload.viewport)
|
||||
reactflow.setViewport(v.payload.viewport)
|
||||
|
||||
if (v.payload.features && featuresStore) {
|
||||
const { setFeatures } = featuresStore.getState()
|
||||
|
||||
setFeatures(v.payload.features)
|
||||
}
|
||||
|
||||
if (v.payload.hash)
|
||||
setSyncWorkflowDraftHash(v.payload.hash)
|
||||
|
||||
setTimeout(() => setControlPromptEditorRerenderKey(Date.now()))
|
||||
}
|
||||
})
|
||||
@@ -204,11 +226,15 @@ const Workflow: FC<WorkflowProps> = memo(({
|
||||
} = useSelectionInteractions()
|
||||
const {
|
||||
handlePaneContextMenu,
|
||||
handlePaneContextmenuCancel,
|
||||
} = usePanelInteractions()
|
||||
const {
|
||||
isValidConnection,
|
||||
} = useWorkflow()
|
||||
const { handleStartWorkflowRun } = useWorkflowStartRun()
|
||||
const {
|
||||
handleExportDSL,
|
||||
} = useDSL()
|
||||
|
||||
useOnViewportChange({
|
||||
onEnd: () => {
|
||||
@@ -266,6 +292,15 @@ const Workflow: FC<WorkflowProps> = memo(({
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
showImportDSLModal && (
|
||||
<UpdateDSLModal
|
||||
onCancel={() => setShowImportDSLModal(false)}
|
||||
onBackup={handleExportDSL}
|
||||
onImport={handlePaneContextmenuCancel}
|
||||
/>
|
||||
)
|
||||
}
|
||||
<ReactFlow
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
|
@@ -8,48 +8,30 @@ import { useClickAway } from 'ahooks'
|
||||
import ShortcutsName from './shortcuts-name'
|
||||
import { useStore } from './store'
|
||||
import {
|
||||
useDSL,
|
||||
useNodesInteractions,
|
||||
usePanelInteractions,
|
||||
useWorkflowStartRun,
|
||||
} from './hooks'
|
||||
import AddBlock from './operator/add-block'
|
||||
import { useOperator } from './operator/hooks'
|
||||
import { exportAppConfig } from '@/service/apps'
|
||||
import { useToastContext } from '@/app/components/base/toast'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
|
||||
const PanelContextmenu = () => {
|
||||
const { t } = useTranslation()
|
||||
const { notify } = useToastContext()
|
||||
const ref = useRef(null)
|
||||
const panelMenu = useStore(s => s.panelMenu)
|
||||
const clipboardElements = useStore(s => s.clipboardElements)
|
||||
const appDetail = useAppStore(s => s.appDetail)
|
||||
const setShowImportDSLModal = useStore(s => s.setShowImportDSLModal)
|
||||
const { handleNodesPaste } = useNodesInteractions()
|
||||
const { handlePaneContextmenuCancel } = usePanelInteractions()
|
||||
const { handleStartWorkflowRun } = useWorkflowStartRun()
|
||||
const { handleAddNote } = useOperator()
|
||||
const { handleExportDSL } = useDSL()
|
||||
|
||||
useClickAway(() => {
|
||||
handlePaneContextmenuCancel()
|
||||
}, ref)
|
||||
|
||||
const onExport = async () => {
|
||||
if (!appDetail)
|
||||
return
|
||||
try {
|
||||
const { data } = await exportAppConfig(appDetail.id)
|
||||
const a = document.createElement('a')
|
||||
const file = new Blob([data], { type: 'application/yaml' })
|
||||
a.href = URL.createObjectURL(file)
|
||||
a.download = `${appDetail.name}.yml`
|
||||
a.click()
|
||||
}
|
||||
catch (e) {
|
||||
notify({ type: 'error', message: t('app.exportFailed') })
|
||||
}
|
||||
}
|
||||
|
||||
const renderTrigger = () => {
|
||||
return (
|
||||
<div
|
||||
@@ -123,10 +105,16 @@ const PanelContextmenu = () => {
|
||||
<div className='p-1'>
|
||||
<div
|
||||
className='flex items-center justify-between px-3 h-8 text-sm text-gray-700 rounded-lg cursor-pointer hover:bg-gray-50'
|
||||
onClick={() => onExport()}
|
||||
onClick={() => handleExportDSL()}
|
||||
>
|
||||
{t('app.export')}
|
||||
</div>
|
||||
<div
|
||||
className='flex items-center justify-between px-3 h-8 text-sm text-gray-700 rounded-lg cursor-pointer hover:bg-gray-50'
|
||||
onClick={() => setShowImportDSLModal(true)}
|
||||
>
|
||||
{t('workflow.common.importDSL')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
@@ -129,6 +129,8 @@ type Shape = {
|
||||
setIsSyncingWorkflowDraft: (isSyncingWorkflowDraft: boolean) => void
|
||||
controlPromptEditorRerenderKey: number
|
||||
setControlPromptEditorRerenderKey: (controlPromptEditorRerenderKey: number) => void
|
||||
showImportDSLModal: boolean
|
||||
setShowImportDSLModal: (showImportDSLModal: boolean) => void
|
||||
}
|
||||
|
||||
export const createWorkflowStore = () => {
|
||||
@@ -217,6 +219,8 @@ export const createWorkflowStore = () => {
|
||||
setIsSyncingWorkflowDraft: isSyncingWorkflowDraft => set(() => ({ isSyncingWorkflowDraft })),
|
||||
controlPromptEditorRerenderKey: 0,
|
||||
setControlPromptEditorRerenderKey: controlPromptEditorRerenderKey => set(() => ({ controlPromptEditorRerenderKey })),
|
||||
showImportDSLModal: false,
|
||||
setShowImportDSLModal: showImportDSLModal => set(() => ({ showImportDSLModal })),
|
||||
}))
|
||||
}
|
||||
|
||||
|
154
web/app/components/workflow/update-dsl-modal.tsx
Normal file
154
web/app/components/workflow/update-dsl-modal.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
'use client'
|
||||
|
||||
import type { MouseEventHandler } from 'react'
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
RiAlertLine,
|
||||
RiCloseLine,
|
||||
} from '@remixicon/react'
|
||||
import { WORKFLOW_DATA_UPDATE } from './constants'
|
||||
import {
|
||||
initialEdges,
|
||||
initialNodes,
|
||||
} from './utils'
|
||||
import Uploader from '@/app/components/app/create-from-dsl-modal/uploader'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Modal from '@/app/components/base/modal'
|
||||
import { ToastContext } from '@/app/components/base/toast'
|
||||
import { updateWorkflowDraftFromDSL } from '@/service/workflow'
|
||||
import { useEventEmitterContextContext } from '@/context/event-emitter'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
|
||||
type UpdateDSLModalProps = {
|
||||
onCancel: () => void
|
||||
onBackup: () => void
|
||||
onImport?: () => void
|
||||
}
|
||||
|
||||
const UpdateDSLModal = ({
|
||||
onCancel,
|
||||
onBackup,
|
||||
onImport,
|
||||
}: UpdateDSLModalProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { notify } = useContext(ToastContext)
|
||||
const appDetail = useAppStore(s => s.appDetail)
|
||||
const [currentFile, setDSLFile] = useState<File>()
|
||||
const [fileContent, setFileContent] = useState<string>()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const { eventEmitter } = useEventEmitterContextContext()
|
||||
|
||||
const readFile = (file: File) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = function (event) {
|
||||
const content = event.target?.result
|
||||
setFileContent(content as string)
|
||||
}
|
||||
reader.readAsText(file)
|
||||
}
|
||||
|
||||
const handleFile = (file?: File) => {
|
||||
setDSLFile(file)
|
||||
if (file)
|
||||
readFile(file)
|
||||
if (!file)
|
||||
setFileContent('')
|
||||
}
|
||||
|
||||
const isCreatingRef = useRef(false)
|
||||
const handleImport: MouseEventHandler = useCallback(async () => {
|
||||
if (isCreatingRef.current)
|
||||
return
|
||||
isCreatingRef.current = true
|
||||
if (!currentFile)
|
||||
return
|
||||
try {
|
||||
if (appDetail && fileContent) {
|
||||
setLoading(true)
|
||||
const {
|
||||
graph,
|
||||
features,
|
||||
hash,
|
||||
} = await updateWorkflowDraftFromDSL(appDetail.id, fileContent)
|
||||
const { nodes, edges, viewport } = graph
|
||||
eventEmitter?.emit({
|
||||
type: WORKFLOW_DATA_UPDATE,
|
||||
payload: {
|
||||
nodes: initialNodes(nodes, edges),
|
||||
edges: initialEdges(edges, nodes),
|
||||
viewport,
|
||||
features,
|
||||
hash,
|
||||
},
|
||||
} as any)
|
||||
if (onImport)
|
||||
onImport()
|
||||
notify({ type: 'success', message: t('workflow.common.importSuccess') })
|
||||
setLoading(false)
|
||||
onCancel()
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
setLoading(false)
|
||||
notify({ type: 'error', message: t('workflow.common.importFailure') })
|
||||
}
|
||||
isCreatingRef.current = false
|
||||
}, [currentFile, fileContent, onCancel, notify, t, eventEmitter, appDetail, onImport])
|
||||
|
||||
return (
|
||||
<Modal
|
||||
className='p-6 w-[520px] rounded-2xl'
|
||||
isShow={true}
|
||||
onClose={() => {}}
|
||||
>
|
||||
<div className='flex items-center justify-between mb-6'>
|
||||
<div className='text-2xl font-semibold text-[#101828]'>{t('workflow.common.importDSL')}</div>
|
||||
<div className='flex items-center justify-center w-[22px] h-[22px] cursor-pointer' onClick={onCancel}>
|
||||
<RiCloseLine className='w-5 h-5 text-gray-500' />
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex mb-4 px-4 py-3 bg-[#FFFAEB] rounded-xl border border-[#FEDF89]'>
|
||||
<RiAlertLine className='shrink-0 mt-0.5 mr-2 w-4 h-4 text-[#F79009]' />
|
||||
<div>
|
||||
<div className='mb-2 text-sm font-medium text-[#354052]'>{t('workflow.common.importDSLTip')}</div>
|
||||
<Button
|
||||
variant='secondary-accent'
|
||||
onClick={onBackup}
|
||||
>
|
||||
{t('workflow.common.backupCurrentDraft')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className='mb-8'>
|
||||
<div className='mb-1 text-[13px] font-semibold text-[#354052]'>
|
||||
{t('workflow.common.chooseDSL')}
|
||||
</div>
|
||||
<Uploader
|
||||
file={currentFile}
|
||||
updateFile={handleFile}
|
||||
className='!mt-0'
|
||||
/>
|
||||
</div>
|
||||
<div className='flex justify-end'>
|
||||
<Button className='mr-2' onClick={onCancel}>{t('app.newApp.Cancel')}</Button>
|
||||
<Button
|
||||
disabled={!currentFile || loading}
|
||||
variant='warning'
|
||||
onClick={handleImport}
|
||||
loading={loading}
|
||||
>
|
||||
{t('workflow.common.overwriteAndImport')}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(UpdateDSLModal)
|
Reference in New Issue
Block a user