view-workflow-history.tsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273
  1. import {
  2. memo,
  3. useCallback,
  4. useMemo,
  5. useState,
  6. } from 'react'
  7. import {
  8. RiCloseLine,
  9. RiHistoryLine,
  10. } from '@remixicon/react'
  11. import { useTranslation } from 'react-i18next'
  12. import { useShallow } from 'zustand/react/shallow'
  13. import { useStoreApi } from 'reactflow'
  14. import {
  15. useNodesReadOnly,
  16. useWorkflowHistory,
  17. } from '../hooks'
  18. import TipPopup from '../operator/tip-popup'
  19. import type { WorkflowHistoryState } from '../workflow-history-store'
  20. import cn from '@/utils/classnames'
  21. import {
  22. PortalToFollowElem,
  23. PortalToFollowElemContent,
  24. PortalToFollowElemTrigger,
  25. } from '@/app/components/base/portal-to-follow-elem'
  26. import { useStore as useAppStore } from '@/app/components/app/store'
  27. type ChangeHistoryEntry = {
  28. label: string
  29. index: number
  30. state: Partial<WorkflowHistoryState>
  31. }
  32. type ChangeHistoryList = {
  33. pastStates: ChangeHistoryEntry[]
  34. futureStates: ChangeHistoryEntry[]
  35. statesCount: number
  36. }
  37. const ViewWorkflowHistory = () => {
  38. const { t } = useTranslation()
  39. const [open, setOpen] = useState(false)
  40. const { nodesReadOnly } = useNodesReadOnly()
  41. const { setCurrentLogItem, setShowMessageLogModal } = useAppStore(useShallow(state => ({
  42. appDetail: state.appDetail,
  43. setCurrentLogItem: state.setCurrentLogItem,
  44. setShowMessageLogModal: state.setShowMessageLogModal,
  45. })))
  46. const reactflowStore = useStoreApi()
  47. const { store, getHistoryLabel } = useWorkflowHistory()
  48. const { pastStates, futureStates, undo, redo, clear } = store.temporal.getState()
  49. const [currentHistoryStateIndex, setCurrentHistoryStateIndex] = useState<number>(0)
  50. const handleClearHistory = useCallback(() => {
  51. clear()
  52. setCurrentHistoryStateIndex(0)
  53. }, [clear])
  54. const handleSetState = useCallback(({ index }: ChangeHistoryEntry) => {
  55. const { setEdges, setNodes } = reactflowStore.getState()
  56. const diff = currentHistoryStateIndex + index
  57. if (diff === 0)
  58. return
  59. if (diff < 0)
  60. undo(diff * -1)
  61. else
  62. redo(diff)
  63. const { edges, nodes } = store.getState()
  64. if (edges.length === 0 && nodes.length === 0)
  65. return
  66. setEdges(edges)
  67. setNodes(nodes)
  68. }, [currentHistoryStateIndex, reactflowStore, redo, store, undo])
  69. const calculateStepLabel = useCallback((index: number) => {
  70. if (!index)
  71. return
  72. const count = index < 0 ? index * -1 : index
  73. return `${index > 0 ? t('workflow.changeHistory.stepForward', { count }) : t('workflow.changeHistory.stepBackward', { count })}`
  74. }
  75. , [t])
  76. const calculateChangeList: ChangeHistoryList = useMemo(() => {
  77. const filterList = (list: any, startIndex = 0, reverse = false) => list.map((state: Partial<WorkflowHistoryState>, index: number) => {
  78. return {
  79. label: state.workflowHistoryEvent && getHistoryLabel(state.workflowHistoryEvent),
  80. index: reverse ? list.length - 1 - index - startIndex : index - startIndex,
  81. state,
  82. }
  83. }).filter(Boolean)
  84. const historyData = {
  85. pastStates: filterList(pastStates, pastStates.length).reverse(),
  86. futureStates: filterList([...futureStates, (!pastStates.length && !futureStates.length) ? undefined : store.getState()].filter(Boolean), 0, true),
  87. statesCount: 0,
  88. }
  89. historyData.statesCount = pastStates.length + futureStates.length
  90. return {
  91. ...historyData,
  92. statesCount: pastStates.length + futureStates.length,
  93. }
  94. }, [futureStates, getHistoryLabel, pastStates, store])
  95. return (
  96. (
  97. <PortalToFollowElem
  98. placement='bottom-end'
  99. offset={{
  100. mainAxis: 4,
  101. crossAxis: 131,
  102. }}
  103. open={open}
  104. onOpenChange={setOpen}
  105. >
  106. <PortalToFollowElemTrigger onClick={() => !nodesReadOnly && setOpen(v => !v)}>
  107. <TipPopup
  108. title={t('workflow.changeHistory.title')}
  109. >
  110. <div
  111. className={`
  112. flex items-center justify-center w-8 h-8 rounded-md hover:bg-black/5 cursor-pointer
  113. ${open && 'bg-primary-50'} ${nodesReadOnly && 'bg-primary-50 opacity-50 !cursor-not-allowed'}
  114. `}
  115. onClick={() => {
  116. if (nodesReadOnly)
  117. return
  118. setCurrentLogItem()
  119. setShowMessageLogModal(false)
  120. }}
  121. >
  122. <RiHistoryLine className={`w-4 h-4 hover:bg-black/5 hover:text-gray-700 ${open ? 'text-primary-600' : 'text-gray-500'}`} />
  123. </div>
  124. </TipPopup>
  125. </PortalToFollowElemTrigger>
  126. <PortalToFollowElemContent className='z-[12]'>
  127. <div
  128. 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'
  129. >
  130. <div className='sticky top-0 bg-white flex items-center justify-between px-4 pt-3 text-base font-semibold text-gray-900'>
  131. <div className='grow'>{t('workflow.changeHistory.title')}</div>
  132. <div
  133. className='shrink-0 flex items-center justify-center w-6 h-6 cursor-pointer'
  134. onClick={() => {
  135. setCurrentLogItem()
  136. setShowMessageLogModal(false)
  137. setOpen(false)
  138. }}
  139. >
  140. <RiCloseLine className='w-4 h-4 text-gray-500' />
  141. </div>
  142. </div>
  143. {
  144. (
  145. <div
  146. className='p-2 overflow-y-auto'
  147. style={{
  148. maxHeight: 'calc(1 / 2 * 100vh)',
  149. }}
  150. >
  151. {
  152. !calculateChangeList.statesCount && (
  153. <div className='py-12'>
  154. <RiHistoryLine className='mx-auto mb-2 w-8 h-8 text-gray-300' />
  155. <div className='text-center text-[13px] text-gray-400'>
  156. {t('workflow.changeHistory.placeholder')}
  157. </div>
  158. </div>
  159. )
  160. }
  161. <div className='flex flex-col'>
  162. {
  163. calculateChangeList.futureStates.map((item: ChangeHistoryEntry) => (
  164. <div
  165. key={item?.index}
  166. className={cn(
  167. 'flex mb-0.5 px-2 py-[7px] rounded-lg hover:bg-primary-50 cursor-pointer',
  168. item?.index === currentHistoryStateIndex && 'bg-primary-50',
  169. )}
  170. onClick={() => {
  171. handleSetState(item)
  172. setOpen(false)
  173. }}
  174. >
  175. <div>
  176. <div
  177. className={cn(
  178. 'flex items-center text-[13px] font-medium leading-[18px]',
  179. item?.index === currentHistoryStateIndex && 'text-primary-600',
  180. )}
  181. >
  182. {item?.label || t('workflow.changeHistory.sessionStart')} ({calculateStepLabel(item?.index)}{item?.index === currentHistoryStateIndex && t('workflow.changeHistory.currentState')})
  183. </div>
  184. </div>
  185. </div>
  186. ))
  187. }
  188. {
  189. calculateChangeList.pastStates.map((item: ChangeHistoryEntry) => (
  190. <div
  191. key={item?.index}
  192. className={cn(
  193. 'flex mb-0.5 px-2 py-[7px] rounded-lg hover:bg-primary-50 cursor-pointer',
  194. item?.index === calculateChangeList.statesCount - 1 && 'bg-primary-50',
  195. )}
  196. onClick={() => {
  197. handleSetState(item)
  198. setOpen(false)
  199. }}
  200. >
  201. <div>
  202. <div
  203. className={cn(
  204. 'flex items-center text-[13px] font-medium leading-[18px]',
  205. item?.index === calculateChangeList.statesCount - 1 && 'text-primary-600',
  206. )}
  207. >
  208. {item?.label || t('workflow.changeHistory.sessionStart')} ({calculateStepLabel(item?.index)})
  209. </div>
  210. </div>
  211. </div>
  212. ))
  213. }
  214. </div>
  215. </div>
  216. )
  217. }
  218. {
  219. !!calculateChangeList.statesCount && (
  220. <>
  221. <div className="h-[1px] bg-gray-100" />
  222. <div
  223. className={cn(
  224. 'flex my-0.5 px-2 py-[7px] rounded-lg cursor-pointer',
  225. 'hover:bg-red-50 hover:text-red-600',
  226. )}
  227. onClick={() => {
  228. handleClearHistory()
  229. setOpen(false)
  230. }}
  231. >
  232. <div>
  233. <div
  234. className={cn(
  235. 'flex items-center text-[13px] font-medium leading-[18px]',
  236. )}
  237. >
  238. {t('workflow.changeHistory.clearHistory')}
  239. </div>
  240. </div>
  241. </div>
  242. </>
  243. )
  244. }
  245. <div className="px-3 w-[240px] py-2 text-xs text-gray-500" >
  246. <div className="flex items-center mb-1 h-[22px] font-medium uppercase">{t('workflow.changeHistory.hint')}</div>
  247. <div className="mb-1 text-gray-700 leading-[18px]">{t('workflow.changeHistory.hintText')}</div>
  248. </div>
  249. </div>
  250. </PortalToFollowElemContent>
  251. </PortalToFollowElem>
  252. )
  253. )
  254. }
  255. export default memo(ViewWorkflowHistory)