Răsfoiți Sursa

Merge pull request #4553 from weseek/feat/77877-78639-select-revision-and-copy-it-to-text-area

feat:select revision and copy it to text area
yuto-o 4 ani în urmă
părinte
comite
ae7c33e236

+ 7 - 1
packages/app/resource/locales/en_US/translation.json

@@ -462,8 +462,14 @@
   },
   "modal_resolve_conflict": {
     "file_conflicting_with_newer_remote": "This file is conflicting with newer remote file",
+    "resolve_conflict_message": "Please select page body",
     "resolve_conflict": "Resolve Conflict",
-    "resolve_and_save" : "Resolve and save"
+    "resolve_and_save" : "Resolve and save",
+    "select_revision" : "Select {{revision}}",
+    "requested_revision": "requested page body",
+    "origin_revision": "page body before request",
+    "latest_revision": "latest page body",
+    "selected_editable_revision": "Selected Revision(Editable)"
   },
   "link_edit": {
     "edit_link": "Edit Link",

+ 8 - 2
packages/app/resource/locales/ja_JP/translation.json

@@ -462,8 +462,14 @@
   },
   "modal_resolve_conflict": {
     "file_conflicting_with_newer_remote": "サーバー側の新しいファイルと衝突します。",
-    "resolve_conflict": "衝突を解消する",
-    "resolve_and_save" : "解消し保存する"
+    "resolve_conflict_message": "ページ本文を選んでください",
+    "resolve_conflict": "衝突を解消",
+    "resolve_and_save" : "解消し保存する",
+    "select_revision" : "{{revision}}にする",
+    "requested_revision": "送信された本文",
+    "origin_revision": "送信する前の本文",
+    "latest_revision": "最新の本文",
+    "selected_editable_revision": "保存するページ本文(編集可能)"
   },
   "link_edit": {
     "edit_link": "リンク編集",

+ 7 - 1
packages/app/resource/locales/zh_CN/translation.json

@@ -440,8 +440,14 @@
   },
   "modal_resolve_conflict": {
     "file_conflicting_with_newer_remote": "此文件与较新的远程文件冲突",
+    "resolve_conflict_message": "选择页面正文",
     "resolve_conflict": "解决冲突",
-    "resolve_and_save" : "解决冲突并保存"
+    "resolve_and_save" : "解决冲突并保存",
+    "select_revision" : "选择{{revision}}",
+    "requested_revision": "发送的页面正文",
+    "origin_revision": "发送前的页面正文",
+    "latest_revision": "最新页面正文",
+    "selected_editable_revision": "选定的可编辑页面正文"
   },
   "link_edit": {
     "edit_link": "Edit Link",

+ 32 - 1
packages/app/src/client/services/PageContainer.js

@@ -4,6 +4,8 @@ import { Container } from 'unstated';
 import * as entities from 'entities';
 import * as toastr from 'toastr';
 import { pagePathUtils } from '@growi/core';
+
+import { apiPost } from '../util/apiv1-client';
 import loggerFactory from '~/utils/logger';
 import { toastError } from '../util/apiNotification';
 
@@ -93,6 +95,8 @@ export default class PageContainer extends Container {
       isHackmdDraftUpdatingInRealtime: false,
       isConflictingOnSave: false,
       isConflictDiffModalOpen: false,
+
+      revisionsOnConflict: {},
     };
 
     // parse creator, lastUpdateUser and revisionAuthor
@@ -433,7 +437,6 @@ export default class PageContainer extends Container {
 
     const { pageId, path } = this.state;
     let { revisionId } = this.state;
-
     const options = Object.assign({}, optionsToSave);
 
     if (editorMode === 'hackmd') {
@@ -656,4 +659,32 @@ export default class PageContainer extends Container {
   retrieveMyBookmarkList() {
   }
 
+  async resolveConflict(pageId, revisionId, markdown, optionsToSave) {
+
+    if (optionsToSave == null) {
+      const msg = '\'saveAndReload\' requires the \'optionsToSave\' param';
+      throw new Error(msg);
+    }
+
+    const { path } = this.state;
+
+    const params = Object.assign(optionsToSave, {
+      page_id: pageId,
+      revision_id: revisionId,
+      body: markdown,
+    });
+
+    const res = await apiPost('/pages.update', params);
+
+    const editorContainer = this.appContainer.getContainer('EditorContainer');
+    editorContainer.clearDraft(path);
+
+    return res;
+  }
+
+  async resolveConflictAndReload(pageId, revisionId, markdown, optionsToSave) {
+    await this.resolveConflict(pageId, revisionId, markdown, optionsToSave);
+    window.location.reload();
+  }
+
 }

+ 6 - 2
packages/app/src/client/util/apiv1-client.ts

@@ -17,11 +17,15 @@ class Apiv1ErrorHandler extends Error {
 
   code;
 
-  constructor(message = '', code = '') {
+  data;
+
+  constructor(message = '', code = '', data = '') {
     super();
 
     this.message = message;
     this.code = code;
+    this.data = data;
+
   }
 
 }
@@ -35,7 +39,7 @@ export async function apiRequest(method: string, path: string, params: unknown):
 
   // Return error code if code is exist
   if (res.data.code != null) {
-    const error = new Apiv1ErrorHandler(res.data.error, res.data.code);
+    const error = new Apiv1ErrorHandler(res.data.error, res.data.code, res.data.data);
     throw error;
   }
 

+ 155 - 30
packages/app/src/components/PageEditor/ConflictDiffModal.tsx

@@ -1,44 +1,45 @@
-import React, { useState, useEffect, FC } from 'react';
+import React, { useState, useRef, FC } from 'react';
 import PropTypes from 'prop-types';
 import {
   Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
-import CodeMirror from 'codemirror/lib/codemirror';
+import { parseISO, format } from 'date-fns';
 import { useTranslation } from 'react-i18next';
+// TODO: consider whether to use codemirrorEditor
+import { UnControlled as CodeMirrorAny } from 'react-codemirror2';
+import PageContainer from '../../client/services/PageContainer';
+import EditorContainer from '../../client/services/EditorContainer';
 
-require('codemirror/lib/codemirror.css');
-require('codemirror/addon/merge/merge');
-require('codemirror/addon/merge/merge.css');
+require('codemirror/mode/htmlmixed/htmlmixed');
 const DMP = require('diff_match_patch');
 
+// avoid typescript type error
+const CodeMirror:any = CodeMirrorAny;
+
 Object.keys(DMP).forEach((key) => { window[key] = DMP[key] });
 
 type ConflictDiffModalProps = {
   isOpen: boolean | null;
   onCancel: (() => void) | null;
-  onResolveConflict: (() => void) | null;
+  pageContainer: PageContainer;
+  editorContainer: EditorContainer;
 };
 
 export const ConflictDiffModal: FC<ConflictDiffModalProps> = (props) => {
-  const [val, setVal] = useState('value 1');
-  const [orig, setOrig] = useState('value 2');
-  const [codeMirrorRef, setCodeMirrorRef] = useState<HTMLDivElement | null>(null);
   const { t } = useTranslation('');
+  const resolvedRevision = useRef<string>('');
+  const [isRevisionselected, setIsRevisionSelected] = useState<boolean>(false);
 
-  useEffect(() => {
-    if (codeMirrorRef) {
-      CodeMirror.MergeView(codeMirrorRef, {
-        value: val,
-        origLeft: orig,
-        origRight: null,
-        connect: 'align',
-        lineNumbers: true,
-        collapseIdentical: true,
-        highlightDifferences: true,
-        allowEditingOriginals: false,
-      });
-    }
-  }, [codeMirrorRef, orig, val]);
+  const { pageContainer, editorContainer } = props;
+  const { request, origin, latest } = pageContainer.state.revisionsOnConflict || { request: {}, origin: {}, latest: {} };
+
+  const codeMirrorRevisionOption = {
+    mode: 'htmlmixed',
+    lineNumbers: true,
+    tabSize: 2,
+    indentUnit: 2,
+    readOnly: true,
+  };
 
   const onCancel = () => {
     if (props.onCancel != null) {
@@ -46,19 +47,141 @@ export const ConflictDiffModal: FC<ConflictDiffModalProps> = (props) => {
     }
   };
 
-  const onResolveConflict = () => {
-    if (props.onResolveConflict != null) {
-      props.onResolveConflict();
+  const onResolveConflict = async() : Promise<void> => {
+    // disable button after clicked
+    setIsRevisionSelected(false);
+    try {
+      await pageContainer.resolveConflictAndReload(
+        pageContainer.state.pageId,
+        latest.revisionId,
+        resolvedRevision.current,
+        editorContainer.getCurrentOptionsToSave(),
+      );
+    }
+    catch (error) {
+      pageContainer.showErrorToastr(error);
     }
   };
 
   return (
-    <Modal isOpen={props.isOpen || false} toggle={onCancel} className="modal-gfm-cheatsheet">
+    <Modal isOpen={props.isOpen || false} toggle={onCancel} className="modal-gfm-cheatsheet" size="xl">
       <ModalHeader tag="h4" toggle={onCancel} className="bg-primary text-light">
         <i className="icon-fw icon-exclamation" />{t('modal_resolve_conflict.resolve_conflict')}
       </ModalHeader>
       <ModalBody>
-        <div ref={(el) => { setCodeMirrorRef(el) }}></div>
+        {Object.keys(pageContainer.state.revisionsOnConflict || {}).length > 0
+          && (
+            <div className="row mx-2">
+              <div className="col-12 text-center mt-2 mb-4">
+                <h2 className="font-weight-bold">{t('modal_resolve_conflict.resolve_conflict_message')}</h2>
+              </div>
+              <div className="col-12 col-md-4 border border-dark">
+                <h3 className="font-weight-bold my-2">{t('modal_resolve_conflict.requested_revision')}</h3>
+                <div className="d-flex align-items-center my-3">
+                  <div>
+                    <img height="40px" className="rounded-circle" src={request.userImgPath} />
+                  </div>
+                  <div className="ml-3 text-muted">
+                    <p className="my-0">updated by {request.userName}</p>
+                    <p className="my-0">{format(parseISO(request.createdAt), 'yyyy/MM/dd HH:mm:ss')}</p>
+                  </div>
+                </div>
+                <CodeMirror
+                  value={request.revisionBody}
+                  options={codeMirrorRevisionOption}
+                />
+                <div className="text-center my-4">
+                  <button
+                    type="button"
+                    className="btn btn-primary"
+                    onClick={() => {
+                      setIsRevisionSelected(true);
+                      resolvedRevision.current = request.revisionBody;
+                    }}
+                  >
+                    <i className="icon-fw icon-arrow-down-circle"></i>
+                    {t('modal_resolve_conflict.select_revision', { revision: 'request' })}
+                  </button>
+                </div>
+              </div>
+              <div className="col-12 col-md-4 border border-dark">
+                <h3 className="font-weight-bold my-2">{t('origin_revision')}</h3>
+                <div className="d-flex align-items-center my-3">
+                  <div>
+                    <img height="40px" className="rounded-circle" src={origin.userImgPath} />
+                  </div>
+                  <div className="ml-3 text-muted">
+                    <p className="my-0">updated by {origin.userName}</p>
+                    <p className="my-0">{format(parseISO(origin.createdAt), 'yyyy/MM/dd HH:mm:ss')}</p>
+                  </div>
+                </div>
+                <CodeMirror
+                  value={origin.revisionBody}
+                  options={codeMirrorRevisionOption}
+                />
+                <div className="text-center my-4">
+                  <button
+                    type="button"
+                    className="btn btn-primary"
+                    onClick={() => {
+                      setIsRevisionSelected(true);
+                      resolvedRevision.current = origin.revisionBody;
+                    }}
+                  >
+                    <i className="icon-fw icon-arrow-down-circle"></i>
+                    {t('modal_resolve_conflict.select_revision', { revision: 'origin' })}
+                  </button>
+                </div>
+              </div>
+              <div className="col-12 col-md-4 border border-dark">
+                <h3 className="font-weight-bold my-2">{t('modal_resolve_conflict.latest_revision')}</h3>
+                <div className="d-flex align-items-center my-3">
+                  <div>
+                    <img height="40px" className="rounded-circle" src={latest.userImgPath} />
+                  </div>
+                  <div className="ml-3 text-muted">
+                    <p className="my-0">updated by {latest.userName}</p>
+                    <p className="my-0">{format(parseISO(latest.createdAt), 'yyyy/MM/dd HH:mm:ss')}</p>
+                  </div>
+                </div>
+                <CodeMirror
+                  value={latest.revisionBody}
+                  options={codeMirrorRevisionOption}
+                />
+                <div className="text-center my-4">
+                  <button
+                    type="button"
+                    className="btn btn-primary"
+                    onClick={() => {
+                      setIsRevisionSelected(true);
+                      resolvedRevision.current = latest.revisionBody;
+                    }}
+                  >
+                    <i className="icon-fw icon-arrow-down-circle"></i>
+                    {t('modal_resolve_conflict.select_revision', { revision: 'latest' })}
+                  </button>
+                </div>
+              </div>
+              <div className="col-12 border border-dark">
+                <h3 className="font-weight-bold my-2">{t('modal_resolve_conflict.selected_editable_revision')}</h3>
+                <CodeMirror
+                  value={resolvedRevision.current}
+                  options={{
+                    mode: 'htmlmixed',
+                    lineNumbers: true,
+                    tabSize: 2,
+                    indentUnit: 2,
+                    placeholder: t('modal_resolve_conflict.resolve_conflict_message'),
+                  }}
+                  onChange={(editor, data, pageBody) => {
+                    if (pageBody === '') setIsRevisionSelected(false);
+                    resolvedRevision.current = pageBody;
+                  }}
+                />
+              </div>
+            </div>
+          )
+        }
       </ModalBody>
       <ModalFooter>
         <button
@@ -70,8 +193,9 @@ export const ConflictDiffModal: FC<ConflictDiffModalProps> = (props) => {
         </button>
         <button
           type="button"
-          className="btn btn-outline-primary ml-3"
+          className="btn btn-primary ml-3"
           onClick={onResolveConflict}
+          disabled={!isRevisionselected}
         >
           {t('modal_resolve_conflict.resolve_and_save')}
         </button>
@@ -83,7 +207,8 @@ export const ConflictDiffModal: FC<ConflictDiffModalProps> = (props) => {
 ConflictDiffModal.propTypes = {
   isOpen: PropTypes.bool,
   onCancel: PropTypes.func,
-  onResolveConflict: PropTypes.func,
+  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+  editorContainer:  PropTypes.instanceOf(EditorContainer).isRequired,
 };
 
 ConflictDiffModal.defaultProps = {

+ 2 - 1
packages/app/src/components/PageEditor/Editor.jsx

@@ -374,7 +374,8 @@ class Editor extends AbstractEditor {
         <ConflictDiffModal
           isOpen={this.props.pageContainer.state.isConflictDiffModalOpen}
           onCancel={() => this.props.pageContainer.setState({ isConflictDiffModalOpen: false })}
-          onResolveConflict={() => {}}
+          pageContainer={this.props.pageContainer}
+          editorContainer={this.props.editorContainer}
         />
       </>
     );

+ 2 - 3
packages/app/src/components/PageStatusAlert.jsx

@@ -59,9 +59,9 @@ class PageStatusAlert extends React.Component {
         {t('modal_resolve_conflict.file_conflicting_with_newer_remote')}
       </>,
       <>
-        <button type="button" onClick={() => { }} className="btn btn-outline-white mr-4">
+        <button type="button" onClick={() => this.refreshPage()} className="btn btn-outline-white mr-4">
           <i className="icon-fw icon-reload mr-1"></i>
-          Reload
+          {t('Load latest')}
         </button>
         <button type="button" onClick={() => pageContainer.setState({ isConflictDiffModalOpen: true })} className="btn btn-outline-white">
           <i className="fa fa-fw fa-file-text-o mr-1"></i>
@@ -113,7 +113,6 @@ class PageStatusAlert extends React.Component {
     const isHackmdDocumentOutdated = revisionIdHackmdSynced !== remoteRevisionId;
 
     let getContentsFunc = null;
-
     // when conflicting on save
     if (isConflictingOnSave) {
       getContentsFunc = this.getContentsForRevisionOutdated;

+ 1 - 0
packages/app/src/components/SavePageControls.jsx

@@ -52,6 +52,7 @@ class SavePageControls extends React.Component {
       if (error.code === 'conflict') {
         pageContainer.setState({
           isConflictingOnSave: true,
+          revisionsOnConflict: error.data,
         });
       }
     }

+ 3 - 3
packages/app/src/server/routes/page.js

@@ -839,21 +839,21 @@ module.exports = function(crowi, app) {
       const revisions = {};
 
       revisions.request = {
-        revisionID: '',
+        revisionId: '',
         revisionBody: pageBody,
         createdAt: new Date(),
         userName: req.user.name,
         userImgPath: req.user.imageUrlCached,
       };
       revisions.origin = {
-        revisionID: originRevision._id.toString(),
+        revisionId: originRevision._id.toString(),
         revisionBody: originRevision.body,
         createdAt: originRevision.createdAt,
         userName: originRevision.author.name,
         userImgPath: originRevision.author.imageUrlCached,
       };
       revisions.latest = {
-        revisionID: latestRevision._id.toString(),
+        revisionId: latestRevision._id.toString(),
         revisionBody: latestRevision.body,
         createdAt: latestRevision.createdAt,
         userName: latestRevision.author.name,