Spaces:
Building
Building
import { | |
memo, | |
useCallback, | |
useMemo, | |
useState, | |
} from 'react' | |
import { | |
RiCloseLine, | |
RiHistoryLine, | |
} from '@remixicon/react' | |
import { useTranslation } from 'react-i18next' | |
import { useShallow } from 'zustand/react/shallow' | |
import { useStoreApi } from 'reactflow' | |
import { | |
useNodesReadOnly, | |
useWorkflowHistory, | |
} from '../hooks' | |
import TipPopup from '../operator/tip-popup' | |
import type { WorkflowHistoryState } from '../workflow-history-store' | |
import cn from '@/utils/classnames' | |
import { | |
PortalToFollowElem, | |
PortalToFollowElemContent, | |
PortalToFollowElemTrigger, | |
} from '@/app/components/base/portal-to-follow-elem' | |
import { useStore as useAppStore } from '@/app/components/app/store' | |
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)}> | |
<TipPopup | |
title={t('workflow.changeHistory.title')} | |
> | |
<div | |
className={` | |
flex items-center justify-center w-8 h-8 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) | |
}} | |
> | |
<RiHistoryLine className={`w-4 h-4 hover:bg-black/5 hover:text-gray-700 ${open ? 'text-primary-600' : 'text-gray-500'}`} /> | |
</div> | |
</TipPopup> | |
</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' | |
> | |
<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) | |
}} | |
> | |
<RiCloseLine className='w-4 h-4 text-gray-500' /> | |
</div> | |
</div> | |
{ | |
( | |
<div | |
className='p-2 overflow-y-auto' | |
style={{ | |
maxHeight: 'calc(1 / 2 * 100vh)', | |
}} | |
> | |
{ | |
!calculateChangeList.statesCount && ( | |
<div className='py-12'> | |
<RiHistoryLine 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> | |
)) | |
} | |
</div> | |
</div> | |
) | |
} | |
{ | |
!!calculateChangeList.statesCount && ( | |
<> | |
<div className="h-[1px] bg-gray-100" /> | |
<div | |
className={cn( | |
'flex my-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 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> | |
</PortalToFollowElemContent> | |
</PortalToFollowElem> | |
) | |
) | |
} | |
export default memo(ViewWorkflowHistory) | |