ConflictDiffModal.tsx 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311
  1. import type React from 'react';
  2. import { useCallback, useEffect, useMemo, useState } from 'react';
  3. import type { IUser } from '@growi/core';
  4. import { GlobalCodeMirrorEditorKey } from '@growi/editor';
  5. import { CodeMirrorEditorDiff } from '@growi/editor/dist/client/components/diff/CodeMirrorEditorDiff';
  6. import { MergeViewer } from '@growi/editor/dist/client/components/diff/MergeViewer';
  7. import { useCodeMirrorEditorIsolated } from '@growi/editor/dist/client/stores/codemirror-editor';
  8. import { UserPicture } from '@growi/ui/dist/components';
  9. import { format } from 'date-fns/format';
  10. import { useTranslation } from 'next-i18next';
  11. import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
  12. import { useCurrentUser } from '~/states/global';
  13. import {
  14. useCurrentPageData,
  15. useRemoteRevisionBody,
  16. useRemoteRevisionLastUpdatedAt,
  17. useRemoteRevisionLastUpdateUser,
  18. } from '~/states/page';
  19. import {
  20. useConflictDiffModalActions,
  21. useConflictDiffModalStatus,
  22. } from '~/states/ui/modal/conflict-diff';
  23. import styles from './ConflictDiffModal.module.scss';
  24. type IRevisionOnConflict = {
  25. revisionBody: string;
  26. createdAt: Date;
  27. user: IUser;
  28. };
  29. /**
  30. * ConflictDiffModalSubstance - Presentation component (heavy logic, rendered only when isOpen)
  31. */
  32. type ConflictDiffModalSubstanceProps = {
  33. request: IRevisionOnConflict;
  34. latest: IRevisionOnConflict;
  35. isModalExpanded: boolean;
  36. setIsModalExpanded: React.Dispatch<React.SetStateAction<boolean>>;
  37. };
  38. const formatedDate = (date: Date): string => {
  39. return format(date, 'yyyy/MM/dd HH:mm:ss');
  40. };
  41. const ConflictDiffModalSubstance = (
  42. props: ConflictDiffModalSubstanceProps,
  43. ): React.JSX.Element => {
  44. const { request, latest, isModalExpanded, setIsModalExpanded } = props;
  45. const [resolvedRevision, setResolvedRevision] = useState<string>('');
  46. const [isRevisionselected, setIsRevisionSelected] = useState<boolean>(false);
  47. const [revisionSelectedToggler, setRevisionSelectedToggler] =
  48. useState<boolean>(false);
  49. const { t } = useTranslation();
  50. const conflictDiffModalStatus = useConflictDiffModalStatus();
  51. const { close: closeConflictDiffModal } = useConflictDiffModalActions();
  52. const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(
  53. GlobalCodeMirrorEditorKey.DIFF,
  54. );
  55. // Memoize formatted dates
  56. const requestFormattedDate = useMemo(
  57. () => formatedDate(request.createdAt),
  58. [request.createdAt],
  59. );
  60. const latestFormattedDate = useMemo(
  61. () => formatedDate(latest.createdAt),
  62. [latest.createdAt],
  63. );
  64. const selectRevisionHandler = useCallback(
  65. (selectedRevision: string) => {
  66. setResolvedRevision(selectedRevision);
  67. setRevisionSelectedToggler((prev) => !prev);
  68. if (!isRevisionselected) {
  69. setIsRevisionSelected(true);
  70. }
  71. },
  72. [isRevisionselected],
  73. );
  74. const resolveConflictHandler = useCallback(async () => {
  75. const newBody = codeMirrorEditor?.getDocString();
  76. if (newBody == null) {
  77. return;
  78. }
  79. await conflictDiffModalStatus?.onResolve?.(newBody);
  80. }, [codeMirrorEditor, conflictDiffModalStatus]);
  81. // biome-ignore lint/correctness/useExhaustiveDependencies: ignore
  82. useEffect(() => {
  83. codeMirrorEditor?.initDoc(resolvedRevision);
  84. // Enable selecting the same revision after editing by including revisionSelectedToggler in the dependency array of useEffect
  85. }, [codeMirrorEditor, resolvedRevision, revisionSelectedToggler]);
  86. const headerButtons = useMemo(
  87. () => (
  88. <div className="d-flex align-items-center">
  89. <button
  90. type="button"
  91. className="btn"
  92. onClick={() => setIsModalExpanded((prev) => !prev)}
  93. >
  94. <span className="material-symbols-outlined">
  95. {isModalExpanded ? 'close_fullscreen' : 'open_in_full'}
  96. </span>
  97. </button>
  98. <button
  99. type="button"
  100. className="btn"
  101. onClick={closeConflictDiffModal}
  102. aria-label="Close"
  103. >
  104. <span className="material-symbols-outlined">close</span>
  105. </button>
  106. </div>
  107. ),
  108. [closeConflictDiffModal, isModalExpanded, setIsModalExpanded],
  109. );
  110. return (
  111. <>
  112. <ModalHeader
  113. tag="h4"
  114. className="d-flex align-items-center"
  115. close={headerButtons}
  116. >
  117. <span className="material-symbols-outlined me-1">error</span>
  118. {t('modal_resolve_conflict.resolve_conflict')}
  119. </ModalHeader>
  120. <ModalBody className="mx-4 my-1">
  121. <div className="row">
  122. <div className="col-12 text-center mt-2 mb-4">
  123. <h3 className="fw-bold text-muted">
  124. {t('modal_resolve_conflict.resolve_conflict_message')}
  125. </h3>
  126. </div>
  127. <div className="col-6">
  128. <h4 className="fw-bold my-2 text-muted">
  129. {t('modal_resolve_conflict.requested_revision')}
  130. </h4>
  131. <div className="d-flex align-items-center my-3">
  132. <div>
  133. <UserPicture user={request.user} size="lg" noLink noTooltip />
  134. </div>
  135. <div className="ms-3 text-muted">
  136. <p className="my-0">updated by {request.user.username}</p>
  137. <p className="my-0">{requestFormattedDate}</p>
  138. </div>
  139. </div>
  140. </div>
  141. <div className="col-6">
  142. <h4 className="fw-bold my-2 text-muted">
  143. {t('modal_resolve_conflict.latest_revision')}
  144. </h4>
  145. <div className="d-flex align-items-center my-3">
  146. <div>
  147. <UserPicture user={latest.user} size="lg" noLink noTooltip />
  148. </div>
  149. <div className="ms-3 text-muted">
  150. <p className="my-0">updated by {latest.user.username}</p>
  151. <p className="my-0">{latestFormattedDate}</p>
  152. </div>
  153. </div>
  154. </div>
  155. <MergeViewer
  156. leftBody={request.revisionBody}
  157. rightBody={latest.revisionBody}
  158. />
  159. <div className="col-6">
  160. <div className="text-center my-4">
  161. <button
  162. type="button"
  163. className="btn btn-outline-primary"
  164. onClick={() => {
  165. selectRevisionHandler(request.revisionBody);
  166. }}
  167. >
  168. <span className="material-symbols-outlined me-1">
  169. arrow_circle_down
  170. </span>
  171. {t('modal_resolve_conflict.select_revision', {
  172. revision: 'mine',
  173. })}
  174. </button>
  175. </div>
  176. </div>
  177. <div className="col-6">
  178. <div className="text-center my-4">
  179. <button
  180. type="button"
  181. className="btn btn-outline-primary"
  182. onClick={() => {
  183. selectRevisionHandler(latest.revisionBody);
  184. }}
  185. >
  186. <span className="material-symbols-outlined me-1">
  187. arrow_circle_down
  188. </span>
  189. {t('modal_resolve_conflict.select_revision', {
  190. revision: 'theirs',
  191. })}
  192. </button>
  193. </div>
  194. </div>
  195. <div className="col-12">
  196. <div className="border border-dark">
  197. <h4 className="fw-bold my-2 mx-2 text-muted">
  198. {t('modal_resolve_conflict.selected_editable_revision')}
  199. </h4>
  200. <CodeMirrorEditorDiff />
  201. </div>
  202. </div>
  203. </div>
  204. </ModalBody>
  205. <ModalFooter>
  206. <button
  207. type="button"
  208. className="btn btn-outline-secondary"
  209. onClick={closeConflictDiffModal}
  210. >
  211. {t('Cancel')}
  212. </button>
  213. <button
  214. type="button"
  215. className="btn btn-primary ms-3"
  216. onClick={resolveConflictHandler}
  217. disabled={!isRevisionselected}
  218. >
  219. {t('modal_resolve_conflict.resolve_and_save')}
  220. </button>
  221. </ModalFooter>
  222. </>
  223. );
  224. };
  225. /**
  226. * ConflictDiffModal - Container component (lightweight, always rendered)
  227. */
  228. export const ConflictDiffModal = (): React.JSX.Element => {
  229. const currentUser = useCurrentUser();
  230. const currentPage = useCurrentPageData();
  231. const conflictDiffModalStatus = useConflictDiffModalStatus();
  232. // state for latest page
  233. const remoteRevisionBody = useRemoteRevisionBody();
  234. const remoteRevisionLastUpdateUser = useRemoteRevisionLastUpdateUser();
  235. const remoteRevisionLastUpdatedAt = useRemoteRevisionLastUpdatedAt();
  236. const isRemotePageDataInappropriate =
  237. remoteRevisionBody == null || remoteRevisionLastUpdateUser == null;
  238. const [isModalExpanded, setIsModalExpanded] = useState<boolean>(false);
  239. // Check if all required data is available
  240. const isDataReady =
  241. conflictDiffModalStatus?.isOpened &&
  242. currentUser != null &&
  243. currentPage != null &&
  244. !isRemotePageDataInappropriate;
  245. // Prepare data for Substance
  246. const currentTime: Date = new Date();
  247. const request: IRevisionOnConflict | null = isDataReady
  248. ? {
  249. revisionBody: conflictDiffModalStatus.requestRevisionBody ?? '',
  250. createdAt: currentTime,
  251. user: currentUser,
  252. }
  253. : null;
  254. const latest: IRevisionOnConflict | null = isDataReady
  255. ? {
  256. revisionBody: remoteRevisionBody,
  257. createdAt: new Date(
  258. remoteRevisionLastUpdatedAt ?? currentTime.toString(),
  259. ),
  260. user: remoteRevisionLastUpdateUser,
  261. }
  262. : null;
  263. return (
  264. <Modal
  265. isOpen={conflictDiffModalStatus?.isOpened ?? false}
  266. className={`${styles['conflict-diff-modal']} ${isModalExpanded ? ' grw-modal-expanded' : ''}`}
  267. size="xl"
  268. >
  269. {isDataReady && request != null && latest != null && (
  270. <ConflictDiffModalSubstance
  271. request={request}
  272. latest={latest}
  273. isModalExpanded={isModalExpanded}
  274. setIsModalExpanded={setIsModalExpanded}
  275. />
  276. )}
  277. </Modal>
  278. );
  279. };