ConflictDiffModal.tsx 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275
  1. import React, {
  2. useState, useEffect, useRef, useMemo, useCallback,
  3. } from 'react';
  4. import { UserPicture } from '@growi/ui';
  5. import CodeMirror from 'codemirror/lib/codemirror';
  6. import { format } from 'date-fns';
  7. import { useTranslation } from 'next-i18next';
  8. import {
  9. Modal, ModalHeader, ModalBody, ModalFooter,
  10. } from 'reactstrap';
  11. import { IUser } from '~/interfaces/user';
  12. import { useCurrentUser } from '~/stores/context';
  13. import { useEditorMode } from '~/stores/ui';
  14. import PageContainer from '../../client/services/PageContainer';
  15. import { IRevisionOnConflict } from '../../interfaces/revision';
  16. import ExpandOrContractButton from '../ExpandOrContractButton';
  17. import { UncontrolledCodeMirror } from '../UncontrolledCodeMirror';
  18. require('codemirror/lib/codemirror.css');
  19. require('codemirror/addon/merge/merge');
  20. require('codemirror/addon/merge/merge.css');
  21. const DMP = require('diff_match_patch');
  22. Object.keys(DMP).forEach((key) => { window[key] = DMP[key] });
  23. type ConflictDiffModalProps = {
  24. isOpen?: boolean;
  25. onClose?: (() => void);
  26. pageContainer: PageContainer;
  27. markdownOnEdit: string;
  28. };
  29. type IRevisionOnConflictWithStringDate = Omit<IRevisionOnConflict, 'createdAt'> & {
  30. createdAt: string
  31. }
  32. const ConflictDiffModalCore = (props: ConflictDiffModalProps & { currentUser: IUser }): JSX.Element => {
  33. const { currentUser, pageContainer, onClose } = props;
  34. const { data: editorMode } = useEditorMode();
  35. const { t } = useTranslation('');
  36. const [resolvedRevision, setResolvedRevision] = useState<string>('');
  37. const [isRevisionselected, setIsRevisionSelected] = useState<boolean>(false);
  38. const [isModalExpanded, setIsModalExpanded] = useState<boolean>(false);
  39. const [codeMirrorRef, setCodeMirrorRef] = useState<HTMLDivElement | null>(null);
  40. const uncontrolledRef = useRef<CodeMirror>(null);
  41. const currentTime: Date = new Date();
  42. const request: IRevisionOnConflictWithStringDate = {
  43. revisionId: '',
  44. revisionBody: props.markdownOnEdit,
  45. createdAt: format(currentTime, 'yyyy/MM/dd HH:mm:ss'),
  46. user: currentUser,
  47. };
  48. const origin: IRevisionOnConflictWithStringDate = {
  49. revisionId: pageContainer.state.revisionId || '',
  50. revisionBody: pageContainer.state.markdown || '',
  51. createdAt: pageContainer.state.updatedAt || '',
  52. user: pageContainer.state.revisionAuthor,
  53. };
  54. const latest: IRevisionOnConflictWithStringDate = {
  55. revisionId: pageContainer.state.remoteRevisionId || '',
  56. revisionBody: pageContainer.state.remoteRevisionBody || '',
  57. createdAt: format(new Date(pageContainer.state.remoteRevisionUpdateAt || currentTime.toString()), 'yyyy/MM/dd HH:mm:ss'),
  58. user: pageContainer.state.lastUpdateUser,
  59. };
  60. useEffect(() => {
  61. if (codeMirrorRef != null) {
  62. CodeMirror.MergeView(codeMirrorRef, {
  63. value: origin.revisionBody,
  64. origLeft: request.revisionBody,
  65. origRight: latest.revisionBody,
  66. lineNumbers: true,
  67. collapseIdentical: true,
  68. showDifferences: true,
  69. highlightDifferences: true,
  70. connect: 'connect',
  71. readOnly: true,
  72. revertButtons: false,
  73. });
  74. }
  75. }, [codeMirrorRef, origin.revisionBody, request.revisionBody, latest.revisionBody]);
  76. const close = useCallback(() => {
  77. if (onClose != null) {
  78. onClose();
  79. }
  80. }, [onClose]);
  81. const onResolveConflict = useCallback(async() => {
  82. // disable button after clicked
  83. setIsRevisionSelected(false);
  84. const codeMirrorVal = uncontrolledRef.current?.editor.doc.getValue();
  85. try {
  86. await pageContainer.resolveConflict(codeMirrorVal, editorMode);
  87. close();
  88. pageContainer.showSuccessToastr();
  89. }
  90. catch (error) {
  91. pageContainer.showErrorToastr(error);
  92. }
  93. }, [editorMode, close, pageContainer]);
  94. const resizeAndCloseButtons = useMemo(() => (
  95. <div className="d-flex flex-nowrap">
  96. <ExpandOrContractButton
  97. isWindowExpanded={isModalExpanded}
  98. expandWindow={() => setIsModalExpanded(true)}
  99. contractWindow={() => setIsModalExpanded(false)}
  100. />
  101. <button type="button" className="close text-white" onClick={close} aria-label="Close">
  102. <span aria-hidden="true">&times;</span>
  103. </button>
  104. </div>
  105. ), [isModalExpanded, close]);
  106. const isOpen = props.isOpen ?? false;
  107. return (
  108. <Modal
  109. isOpen={isOpen}
  110. toggle={close}
  111. backdrop="static"
  112. className={`${isModalExpanded ? ' grw-modal-expanded' : ''}`}
  113. size="xl"
  114. >
  115. <ModalHeader tag="h4" toggle={onClose} className="bg-primary text-light align-items-center py-3" close={resizeAndCloseButtons}>
  116. <i className="icon-fw icon-exclamation" />{t('modal_resolve_conflict.resolve_conflict')}
  117. </ModalHeader>
  118. <ModalBody className="mx-4 my-1">
  119. { isOpen
  120. && (
  121. <div className="row">
  122. <div className="col-12 text-center mt-2 mb-4">
  123. <h2 className="font-weight-bold">{t('modal_resolve_conflict.resolve_conflict_message')}</h2>
  124. </div>
  125. <div className="col-4">
  126. <h3 className="font-weight-bold my-2">{t('modal_resolve_conflict.requested_revision')}</h3>
  127. <div className="d-flex align-items-center my-3">
  128. <div>
  129. <UserPicture user={request.user} size="lg" noLink noTooltip />
  130. </div>
  131. <div className="ml-3 text-muted">
  132. <p className="my-0">updated by {request.user.username}</p>
  133. <p className="my-0">{request.createdAt}</p>
  134. </div>
  135. </div>
  136. </div>
  137. <div className="col-4">
  138. <h3 className="font-weight-bold my-2">{t('modal_resolve_conflict.origin_revision')}</h3>
  139. <div className="d-flex align-items-center my-3">
  140. <div>
  141. <UserPicture user={origin.user} size="lg" noLink noTooltip />
  142. </div>
  143. <div className="ml-3 text-muted">
  144. <p className="my-0">updated by {origin.user.username}</p>
  145. <p className="my-0">{origin.createdAt}</p>
  146. </div>
  147. </div>
  148. </div>
  149. <div className="col-4">
  150. <h3 className="font-weight-bold my-2">{t('modal_resolve_conflict.latest_revision')}</h3>
  151. <div className="d-flex align-items-center my-3">
  152. <div>
  153. <UserPicture user={latest.user} size="lg" noLink noTooltip />
  154. </div>
  155. <div className="ml-3 text-muted">
  156. <p className="my-0">updated by {latest.user.username}</p>
  157. <p className="my-0">{latest.createdAt}</p>
  158. </div>
  159. </div>
  160. </div>
  161. <div className="col-12" ref={(el) => { setCodeMirrorRef(el) }}></div>
  162. <div className="col-4">
  163. <div className="text-center my-4">
  164. <button
  165. type="button"
  166. className="btn btn-outline-primary"
  167. onClick={() => {
  168. setIsRevisionSelected(true);
  169. setResolvedRevision(request.revisionBody);
  170. }}
  171. >
  172. <i className="icon-fw icon-arrow-down-circle"></i>
  173. {t('modal_resolve_conflict.select_revision', { revision: 'mine' })}
  174. </button>
  175. </div>
  176. </div>
  177. <div className="col-4">
  178. <div className="text-center my-4">
  179. <button
  180. type="button"
  181. className="btn btn-outline-primary"
  182. onClick={() => {
  183. setIsRevisionSelected(true);
  184. setResolvedRevision(origin.revisionBody);
  185. }}
  186. >
  187. <i className="icon-fw icon-arrow-down-circle"></i>
  188. {t('modal_resolve_conflict.select_revision', { revision: 'origin' })}
  189. </button>
  190. </div>
  191. </div>
  192. <div className="col-4">
  193. <div className="text-center my-4">
  194. <button
  195. type="button"
  196. className="btn btn-outline-primary"
  197. onClick={() => {
  198. setIsRevisionSelected(true);
  199. setResolvedRevision(latest.revisionBody);
  200. }}
  201. >
  202. <i className="icon-fw icon-arrow-down-circle"></i>
  203. {t('modal_resolve_conflict.select_revision', { revision: 'theirs' })}
  204. </button>
  205. </div>
  206. </div>
  207. <div className="col-12">
  208. <div className="border border-dark">
  209. <h3 className="font-weight-bold my-2 mx-2">{t('modal_resolve_conflict.selected_editable_revision')}</h3>
  210. <UncontrolledCodeMirror
  211. ref={uncontrolledRef}
  212. value={resolvedRevision}
  213. options={{
  214. placeholder: t('modal_resolve_conflict.resolve_conflict_message'),
  215. }}
  216. />
  217. </div>
  218. </div>
  219. </div>
  220. )}
  221. </ModalBody>
  222. <ModalFooter>
  223. <button
  224. type="button"
  225. className="btn btn-outline-secondary"
  226. onClick={onClose}
  227. >
  228. {t('Cancel')}
  229. </button>
  230. <button
  231. type="button"
  232. className="btn btn-primary ml-3"
  233. onClick={onResolveConflict}
  234. disabled={!isRevisionselected}
  235. >
  236. {t('modal_resolve_conflict.resolve_and_save')}
  237. </button>
  238. </ModalFooter>
  239. </Modal>
  240. );
  241. };
  242. export const ConflictDiffModal = (props: ConflictDiffModalProps): JSX.Element => {
  243. const { isOpen } = props;
  244. const { data: currentUser } = useCurrentUser();
  245. if (!isOpen || currentUser == null) {
  246. return <></>;
  247. }
  248. return <ConflictDiffModalCore {...props} currentUser={currentUser} />;
  249. };