Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: undo/redo for workflow editor #3927

Open
wants to merge 13 commits into
base: main
Choose a base branch
from
3 changes: 3 additions & 0 deletions ArrowUturnLeft.svg
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"icon": {
"type": "element",
"isRootNode": true,
"name": "svg",
"attributes": {
"width": "16",
"height": "16",
"viewBox": "0 0 16 16",
"fill": "none",
"xmlns": "http://www.w3.org/2000/svg"
},
"children": [
{
"type": "element",
"name": "path",
"attributes": {
"d": "M15.1333 9L13.8004 7.66667L12.4666 9M14 8C14 11.3137 11.3137 14 8 14C4.68629 14 2 11.3137 2 8C2 4.68629 4.68629 2 8 2C10.2013 2 12.1257 3.18542 13.1697 4.95273M8 4.66667V8L10 9.33333",
"stroke": "currentColor",
"stroke-width": "1.5",
"stroke-linecap": "round",
"stroke-linejoin": "round"
},
"children": []
}
]
},
"name": "ChangeHistory"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// GENERATE BY script
// DON NOT EDIT IT MANUALLY

import * as React from 'react'
import data from './ChangeHistory.json'
import IconBase from '@/app/components/base/icons/IconBase'
import type { IconBaseProps, IconData } from '@/app/components/base/icons/IconBase'

const Icon = React.forwardRef<React.MutableRefObject<SVGElement>, Omit<IconBaseProps, 'data'>>((
props,
ref,
) => <IconBase {...props} ref={ref} data={data as IconData} />)

Icon.displayName = 'ChangeHistory'

export default Icon
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export { default as AlignLeft } from './AlignLeft'
export { default as BezierCurve03 } from './BezierCurve03'
export { default as ChangeHistory } from './ChangeHistory'
export { default as Colors } from './Colors'
export { default as Cursor02C } from './Cursor02C'
export { default as Hand02 } from './Hand02'
Expand Down
53 changes: 53 additions & 0 deletions web/app/components/workflow/header/undo-redo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import type { FC } from 'react'
import { memo } from 'react'
import { useTranslation } from 'react-i18next'

import Tooltip from '../../base/tooltip'
import { FlipBackward, FlipForward } from '../../base/icons/src/vender/line/arrows'
import { useNodesReadOnly } from '@/app/components/workflow/hooks'
import ViewWorkflowHistory from '@/app/components/workflow/header/view-workflow-history'

export type UndoRedoProps = { handleUndo: () => void; handleRedo: () => void }
const UndoRedo: FC<UndoRedoProps> = ({ handleUndo, handleRedo }) => {
const { t } = useTranslation()

const { nodesReadOnly } = useNodesReadOnly()

return (
<div className='flex items-center px-0.5 h-8 rounded-lg border-[0.5px] border-gray-200 bg-white text-gray-500 shadow-xs'>

<Tooltip selector={'workflow.common.undo'} content={t('workflow.common.undo')!} >
<div
data-tooltip-id='workflow.undo'
className={`
flex items-center px-1.5 h-7 rounded-md text-[13px] font-medium
hover:bg-black/5 hover:text-gray-700 cursor-pointer select-none
${nodesReadOnly && 'bg-primary-50 opacity-50 !cursor-not-allowed'}
`}
onClick={() => !nodesReadOnly && handleUndo()}
>
<FlipBackward className='h-4 w-4' />
</div>
</Tooltip>

<Tooltip selector={'workflow.redo'} content={t('workflow.common.redo')!} >
<div
data-tooltip-id='workflow.redo'
className={`
flex items-center px-1.5 h-7 rounded-md text-[13px] font-medium
hover:bg-black/5 hover:text-gray-700 cursor-pointer select-none
${nodesReadOnly && 'bg-primary-50 opacity-50 !cursor-not-allowed'}
`}
onClick={() => !nodesReadOnly && handleRedo()}
>
<FlipForward className='h-4 w-4' />

</div>
</Tooltip>
<div className="mx-[3px] w-[1px] h-3.5 bg-gray-200"></div>
<ViewWorkflowHistory />
</div>
)
}

export default memo(UndoRedo)
269 changes: 269 additions & 0 deletions web/app/components/workflow/header/view-workflow-history.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,269 @@
import {
memo,
useCallback,
useMemo,
useState,
} from 'react'
import cn from 'classnames'
import { useTranslation } from 'react-i18next'
import { useShallow } from 'zustand/react/shallow'
import { useStoreApi } from 'reactflow'
import {
useNodesReadOnly,
useWorkflowHistory,
} from '../hooks'
import type { WorkflowHistoryState } from '../workflow-history-store'
import { ChangeHistory } from '../../base/icons/src/vender/line/editor'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import TooltipPlus from '@/app/components/base/tooltip-plus'
import { useStore as useAppStore } from '@/app/components/app/store'
import { XClose } from '@/app/components/base/icons/src/vender/line/general'

type ChangeHistoryEntry = {
label: string
index: number
state: Partial<WorkflowHistoryState>
}

type ChangeHistoryList = {
pastStates: ChangeHistoryEntry[]
futureStates: ChangeHistoryEntry[]
statesCount: number
}

const ViewWorkflowHistory = () => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)

const { nodesReadOnly } = useNodesReadOnly()
const { setCurrentLogItem, setShowMessageLogModal } = useAppStore(useShallow(state => ({
appDetail: state.appDetail,
setCurrentLogItem: state.setCurrentLogItem,
setShowMessageLogModal: state.setShowMessageLogModal,
})))
const reactflowStore = useStoreApi()
const { store, getHistoryLabel } = useWorkflowHistory()

const { pastStates, futureStates, undo, redo, clear } = store.temporal.getState()
const [currentHistoryStateIndex, setCurrentHistoryStateIndex] = useState<number>(0)

const handleClearHistory = useCallback(() => {
clear()
setCurrentHistoryStateIndex(0)
}, [clear])

const handleSetState = useCallback(({ index }: ChangeHistoryEntry) => {
const { setEdges, setNodes } = reactflowStore.getState()
const diff = currentHistoryStateIndex + index
if (diff === 0)
return

if (diff < 0)
undo(diff * -1)
else
redo(diff)

const { edges, nodes } = store.getState()
if (edges.length === 0 && nodes.length === 0)
return

setEdges(edges)
setNodes(nodes)
}, [currentHistoryStateIndex, reactflowStore, redo, store, undo])

const calculateStepLabel = useCallback((index: number) => {
if (!index)
return

const count = index < 0 ? index * -1 : index
return `${index > 0 ? t('workflow.changeHistory.stepForward', { count }) : t('workflow.changeHistory.stepBackward', { count })}`
}
, [t])

const calculateChangeList: ChangeHistoryList = useMemo(() => {
const filterList = (list: any, startIndex = 0, reverse = false) => list.map((state: Partial<WorkflowHistoryState>, index: number) => {
return {
label: state.workflowHistoryEvent && getHistoryLabel(state.workflowHistoryEvent),
index: reverse ? list.length - 1 - index - startIndex : index - startIndex,
state,
}
}).filter(Boolean)

const historyData = {
pastStates: filterList(pastStates, pastStates.length).reverse(),
futureStates: filterList([...futureStates, (!pastStates.length && !futureStates.length) ? undefined : store.getState()].filter(Boolean), 0, true),
statesCount: 0,
}

historyData.statesCount = pastStates.length + futureStates.length

return {
...historyData,
statesCount: pastStates.length + futureStates.length,
}
}, [futureStates, getHistoryLabel, pastStates, store])

return (
(
<PortalToFollowElem
placement='bottom-end'
offset={{
mainAxis: 4,
crossAxis: 131,
}}
open={open}
onOpenChange={setOpen}
>
<PortalToFollowElemTrigger onClick={() => !nodesReadOnly && setOpen(v => !v)}>
<TooltipPlus
triggerMethod={nodesReadOnly ? 'click' : 'hover'}
popupContent={t('workflow.changeHistory.title')}
>
<div
className={`
flex items-center justify-center w-7 h-7 rounded-md hover:bg-black/5 cursor-pointer
${open && 'bg-primary-50'} ${nodesReadOnly && 'bg-primary-50 opacity-50 !cursor-not-allowed'}
`}
onClick={() => {
if (nodesReadOnly)
return
setCurrentLogItem()
setShowMessageLogModal(false)
}}
>
<ChangeHistory className={`w-4 h-4 hover:bg-black/5 hover:text-gray-700 ${open ? 'text-primary-600' : 'text-gray-500'}`} />
</div>
</TooltipPlus>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-[12]'>
<div
className='flex flex-col ml-2 min-w-[240px] max-w-[360px] bg-white border-[0.5px] border-gray-200 shadow-xl rounded-xl overflow-y-auto'
style={{
maxHeight: 'calc(2 / 3 * 100vh)',
}}
>
<div className='sticky top-0 bg-white flex items-center justify-between px-4 pt-3 text-base font-semibold text-gray-900'>
<div className='grow'>{t('workflow.changeHistory.title')}</div>
<div
className='shrink-0 flex items-center justify-center w-6 h-6 cursor-pointer'
onClick={() => {
setCurrentLogItem()
setShowMessageLogModal(false)
setOpen(false)
}}
>
<XClose className='w-4 h-4 text-gray-500' />
</div>
</div>
{
(
<div className='p-2'>
{
!calculateChangeList.statesCount && (
<div className='py-12'>
<ChangeHistory className='mx-auto mb-2 w-8 h-8 text-gray-300' />
<div className='text-center text-[13px] text-gray-400'>
{t('workflow.changeHistory.placeholder')}
</div>
</div>
)
}
<div className='flex flex-col'>
{
calculateChangeList.futureStates.map((item: ChangeHistoryEntry) => (
<div
key={item?.index}
className={cn(
'flex mb-0.5 px-2 py-[7px] rounded-lg hover:bg-primary-50 cursor-pointer',
item?.index === currentHistoryStateIndex && 'bg-primary-50',
)}
onClick={() => {
handleSetState(item)
setOpen(false)
}}
>
<div>
<div
className={cn(
'flex items-center text-[13px] font-medium leading-[18px]',
item?.index === currentHistoryStateIndex && 'text-primary-600',
)}
>
{item?.label || t('workflow.changeHistory.sessionStart')} ({calculateStepLabel(item?.index)}{item?.index === currentHistoryStateIndex && t('workflow.changeHistory.currentState')})
</div>
</div>
</div>
))
}
{
calculateChangeList.pastStates.map((item: ChangeHistoryEntry) => (
<div
key={item?.index}
className={cn(
'flex mb-0.5 px-2 py-[7px] rounded-lg hover:bg-primary-50 cursor-pointer',
item?.index === calculateChangeList.statesCount - 1 && 'bg-primary-50',
)}
onClick={() => {
handleSetState(item)
setOpen(false)
}}
>
<div>
<div
className={cn(
'flex items-center text-[13px] font-medium leading-[18px]',
item?.index === calculateChangeList.statesCount - 1 && 'text-primary-600',
)}
>
{item?.label || t('workflow.changeHistory.sessionStart')} ({calculateStepLabel(item?.index)})
</div>
</div>
</div>
))
}
{!!calculateChangeList.statesCount && (
<>
<div className="h-[1px] bg-gray-100" />
<div
className={cn(
'flex mb-0.5 px-2 py-[7px] rounded-lg cursor-pointer',
'hover:bg-red-50 hover:text-red-600',
)}
onClick={() => {
handleClearHistory()
setOpen(false)
}}
>
<div>
<div
className={cn(
'flex items-center text-[13px] font-medium leading-[18px]',
)}
>
{t('workflow.changeHistory.clearHistory')}
</div>
</div>
</div>
</>

)}
</div>
<div className="px-3 w-[240px] py-2 text-xs text-gray-500" >
<div className="flex items-center mb-1 h-[22px] font-medium uppercase">{t('workflow.changeHistory.hint')}</div>
<div className="mb-1 text-gray-700 leading-[18px]">{t('workflow.changeHistory.hintText')}</div>
</div>
</div>
)
}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
)
}

export default memo(ViewWorkflowHistory)
1 change: 1 addition & 0 deletions web/app/components/workflow/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@ export * from './use-workflow-interactions'
export * from './use-selection-interactions'
export * from './use-panel-interactions'
export * from './use-workflow-start-run'
export * from './use-workflow-history'