|
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) |
|
|