Просмотр исходного кода

Merge branch 'feat/77877-resolve-page-conflict-and-update-it' into feat/resolve-conflict

yuto-oweseek 4 лет назад
Родитель
Сommit
306d705717

+ 1 - 0
packages/app/package.json

@@ -86,6 +86,7 @@
     "date-fns": "^2.23.0",
     "date-fns": "^2.23.0",
     "detect-indent": "^6.0.0",
     "detect-indent": "^6.0.0",
     "diff": "^5.0.0",
     "diff": "^5.0.0",
+    "diff_match_patch": "^0.1.1",
     "elasticsearch": "^16.0.0",
     "elasticsearch": "^16.0.0",
     "entities": "^2.0.0",
     "entities": "^2.0.0",
     "esa-nodejs": "^0.0.7",
     "esa-nodejs": "^0.0.7",

+ 11 - 0
packages/app/resource/locales/en_US/translation.json

@@ -460,6 +460,17 @@
     "enable_textlint": "Enable Textlint",
     "enable_textlint": "Enable Textlint",
     "dont_ask_again": "Don't ask again"
     "dont_ask_again": "Don't ask again"
   },
   },
+  "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",
+    "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": {
   "link_edit": {
     "edit_link": "Edit Link",
     "edit_link": "Edit Link",
     "set_link_and_label": "Set link and label",
     "set_link_and_label": "Set link and label",

+ 11 - 0
packages/app/resource/locales/ja_JP/translation.json

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

+ 11 - 0
packages/app/resource/locales/zh_CN/translation.json

@@ -438,6 +438,17 @@
     "enable_textlint": "启用Textlint",
     "enable_textlint": "启用Textlint",
     "dont_ask_again": "不要再问"
     "dont_ask_again": "不要再问"
   },
   },
+  "modal_resolve_conflict": {
+    "file_conflicting_with_newer_remote": "此文件与较新的远程文件冲突",
+    "resolve_conflict_message": "选择页面正文",
+    "resolve_conflict": "解决冲突",
+    "resolve_and_save" : "解决冲突并保存",
+    "select_revision" : "选择{{revision}}",
+    "requested_revision": "发送的页面正文",
+    "origin_revision": "发送前的页面正文",
+    "latest_revision": "最新页面正文",
+    "selected_editable_revision": "选定的可编辑页面正文"
+  },
   "link_edit": {
   "link_edit": {
     "edit_link": "Edit Link",
     "edit_link": "Edit Link",
     "set_link_and_label": "Set link and label",
     "set_link_and_label": "Set link and label",

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

@@ -4,6 +4,8 @@ import { Container } from 'unstated';
 import * as entities from 'entities';
 import * as entities from 'entities';
 import * as toastr from 'toastr';
 import * as toastr from 'toastr';
 import { pagePathUtils } from '@growi/core';
 import { pagePathUtils } from '@growi/core';
+
+import { apiPost } from '../util/apiv1-client';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 import { toastError } from '../util/apiNotification';
 import { toastError } from '../util/apiNotification';
 
 
@@ -91,6 +93,10 @@ export default class PageContainer extends Container {
       pageIdOnHackmd: mainContent.getAttribute('data-page-id-on-hackmd') || null,
       pageIdOnHackmd: mainContent.getAttribute('data-page-id-on-hackmd') || null,
       hasDraftOnHackmd: !!mainContent.getAttribute('data-page-has-draft-on-hackmd'),
       hasDraftOnHackmd: !!mainContent.getAttribute('data-page-has-draft-on-hackmd'),
       isHackmdDraftUpdatingInRealtime: false,
       isHackmdDraftUpdatingInRealtime: false,
+      isConflictingOnSave: false,
+      isConflictDiffModalOpen: false,
+
+      revisionsOnConflict: {},
     };
     };
 
 
     // parse creator, lastUpdateUser and revisionAuthor
     // parse creator, lastUpdateUser and revisionAuthor
@@ -431,7 +437,6 @@ export default class PageContainer extends Container {
 
 
     const { pageId, path } = this.state;
     const { pageId, path } = this.state;
     let { revisionId } = this.state;
     let { revisionId } = this.state;
-
     const options = Object.assign({}, optionsToSave);
     const options = Object.assign({}, optionsToSave);
 
 
     if (editorMode === 'hackmd') {
     if (editorMode === 'hackmd') {
@@ -654,4 +659,32 @@ export default class PageContainer extends Container {
   retrieveMyBookmarkList() {
   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;
   code;
 
 
-  constructor(message = '', code = '') {
+  data;
+
+  constructor(message = '', code = '', data = '') {
     super();
     super();
 
 
     this.message = message;
     this.message = message;
     this.code = code;
     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
   // Return error code if code is exist
   if (res.data.code != null) {
   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;
     throw error;
   }
   }
 
 

+ 13 - 0
packages/app/src/client/util/apiv1ErrorHandler.js

@@ -0,0 +1,13 @@
+class Apiv1ErrorHandler extends Error {
+
+  constructor(message = '', code = '', data = '') {
+    super();
+
+    this.message = message;
+    this.code = code;
+    this.data = data;
+  }
+
+}
+
+module.exports = Apiv1ErrorHandler;

+ 216 - 0
packages/app/src/components/PageEditor/ConflictDiffModal.tsx

@@ -0,0 +1,216 @@
+import React, { useState, useRef, FC } from 'react';
+import PropTypes from 'prop-types';
+import {
+  Modal, ModalHeader, ModalBody, ModalFooter,
+} from 'reactstrap';
+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/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;
+  pageContainer: PageContainer;
+  editorContainer: EditorContainer;
+};
+
+export const ConflictDiffModal: FC<ConflictDiffModalProps> = (props) => {
+  const { t } = useTranslation('');
+  const resolvedRevision = useRef<string>('');
+  const [isRevisionselected, setIsRevisionSelected] = useState<boolean>(false);
+
+  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) {
+      props.onCancel();
+    }
+  };
+
+  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" 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>
+        {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
+          type="button"
+          className="btn btn-outline-secondary"
+          onClick={onCancel}
+        >
+          {t('Cancel')}
+        </button>
+        <button
+          type="button"
+          className="btn btn-primary ml-3"
+          onClick={onResolveConflict}
+          disabled={!isRevisionselected}
+        >
+          {t('modal_resolve_conflict.resolve_and_save')}
+        </button>
+      </ModalFooter>
+    </Modal>
+  );
+};
+
+ConflictDiffModal.propTypes = {
+  isOpen: PropTypes.bool,
+  onCancel: PropTypes.func,
+  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+  editorContainer:  PropTypes.instanceOf(EditorContainer).isRequired,
+};
+
+ConflictDiffModal.defaultProps = {
+  isOpen: false,
+};

+ 94 - 83
packages/app/src/components/PageEditor/Editor.jsx

@@ -10,6 +10,7 @@ import {
 import Dropzone from 'react-dropzone';
 import Dropzone from 'react-dropzone';
 
 
 import EditorContainer from '~/client/services/EditorContainer';
 import EditorContainer from '~/client/services/EditorContainer';
+import PageContainer from '~/client/services/PageContainer';
 import { withUnstatedContainers } from '../UnstatedUtils';
 import { withUnstatedContainers } from '../UnstatedUtils';
 
 
 import Cheatsheet from './Cheatsheet';
 import Cheatsheet from './Cheatsheet';
@@ -18,6 +19,7 @@ import CodeMirrorEditor from './CodeMirrorEditor';
 import TextAreaEditor from './TextAreaEditor';
 import TextAreaEditor from './TextAreaEditor';
 
 
 import pasteHelper from './PasteHelper';
 import pasteHelper from './PasteHelper';
+import { ConflictDiffModal } from './ConflictDiffModal';
 
 
 class Editor extends AbstractEditor {
 class Editor extends AbstractEditor {
 
 
@@ -286,88 +288,96 @@ class Editor extends AbstractEditor {
     const isMobile = this.props.isMobile;
     const isMobile = this.props.isMobile;
 
 
     return (
     return (
-      <div style={flexContainer} className="editor-container">
-        <Dropzone
-          ref={(c) => { this.dropzone = c }}
-          accept={this.getAcceptableType()}
-          noClick
-          noKeyboard
-          multiple={false}
-          onDragLeave={this.dragLeaveHandler}
-          onDrop={this.dropHandler}
-        >
-          {({
-            getRootProps,
-            getInputProps,
-            isDragAccept,
-            isDragReject,
-          }) => {
-            return (
-              <div className={this.getDropzoneClassName(isDragAccept, isDragReject)} {...getRootProps()}>
-                { this.state.dropzoneActive && this.renderDropzoneOverlay() }
-
-                { this.state.isComponentDidMount && this.renderNavbar() }
-
-                {/* for PC */}
-                { !isMobile && (
-                  <Subscribe to={[EditorContainer]}>
-                    { editorContainer => (
-                      // eslint-disable-next-line arrow-body-style
-                      <CodeMirrorEditor
-                        ref={(c) => { this.cmEditor = c }}
-                        indentSize={editorContainer.state.indentSize}
-                        editorOptions={editorContainer.state.editorOptions}
-                        isTextlintEnabled={editorContainer.state.isTextlintEnabled}
-                        textlintRules={editorContainer.state.textlintRules}
-                        onInitializeTextlint={editorContainer.retrieveEditorSettings}
-                        onPasteFiles={this.pasteFilesHandler}
-                        onDragEnter={this.dragEnterHandler}
-                        onMarkdownHelpButtonClicked={this.showMarkdownHelp}
-                        onAddAttachmentButtonClicked={this.addAttachmentHandler}
-                        {...this.props}
-                      />
-                    )}
-                  </Subscribe>
-                )}
-
-                {/* for mobile */}
-                { isMobile && (
-                  <TextAreaEditor
-                    ref={(c) => { this.taEditor = c }}
-                    onPasteFiles={this.pasteFilesHandler}
-                    onDragEnter={this.dragEnterHandler}
-                    {...this.props}
-                  />
-                )}
-
-                <input {...getInputProps()} />
-              </div>
-            );
-          }}
-        </Dropzone>
-
-        { this.props.isUploadable
-          && (
-            <button
-              type="button"
-              className="btn btn-outline-secondary btn-block btn-open-dropzone"
-              onClick={this.addAttachmentHandler}
-            >
-              <i className="icon-paper-clip" aria-hidden="true"></i>&nbsp;
-              Attach files
-              <span className="d-none d-sm-inline">
-              &nbsp;by dragging &amp; dropping,&nbsp;
-                <span className="btn-link">selecting them</span>,&nbsp;
-                or pasting from the clipboard.
-              </span>
-
-            </button>
-          )
-        }
-
-        { this.renderCheatsheetModal() }
-
-      </div>
+      <>
+        <div style={flexContainer} className="editor-container">
+          <Dropzone
+            ref={(c) => { this.dropzone = c }}
+            accept={this.getAcceptableType()}
+            noClick
+            noKeyboard
+            multiple={false}
+            onDragLeave={this.dragLeaveHandler}
+            onDrop={this.dropHandler}
+          >
+            {({
+              getRootProps,
+              getInputProps,
+              isDragAccept,
+              isDragReject,
+            }) => {
+              return (
+                <div className={this.getDropzoneClassName(isDragAccept, isDragReject)} {...getRootProps()}>
+                  { this.state.dropzoneActive && this.renderDropzoneOverlay() }
+
+                  { this.state.isComponentDidMount && this.renderNavbar() }
+
+                  {/* for PC */}
+                  { !isMobile && (
+                    <Subscribe to={[EditorContainer]}>
+                      { editorContainer => (
+                        // eslint-disable-next-line arrow-body-style
+                        <CodeMirrorEditor
+                          ref={(c) => { this.cmEditor = c }}
+                          indentSize={editorContainer.state.indentSize}
+                          editorOptions={editorContainer.state.editorOptions}
+                          isTextlintEnabled={editorContainer.state.isTextlintEnabled}
+                          textlintRules={editorContainer.state.textlintRules}
+                          onInitializeTextlint={editorContainer.retrieveEditorSettings}
+                          onPasteFiles={this.pasteFilesHandler}
+                          onDragEnter={this.dragEnterHandler}
+                          onMarkdownHelpButtonClicked={this.showMarkdownHelp}
+                          onAddAttachmentButtonClicked={this.addAttachmentHandler}
+                          {...this.props}
+                        />
+                      )}
+                    </Subscribe>
+                  )}
+
+                  {/* for mobile */}
+                  { isMobile && (
+                    <TextAreaEditor
+                      ref={(c) => { this.taEditor = c }}
+                      onPasteFiles={this.pasteFilesHandler}
+                      onDragEnter={this.dragEnterHandler}
+                      {...this.props}
+                    />
+                  )}
+
+                  <input {...getInputProps()} />
+                </div>
+              );
+            }}
+          </Dropzone>
+
+          { this.props.isUploadable
+            && (
+              <button
+                type="button"
+                className="btn btn-outline-secondary btn-block btn-open-dropzone"
+                onClick={this.addAttachmentHandler}
+              >
+                <i className="icon-paper-clip" aria-hidden="true"></i>&nbsp;
+                Attach files
+                <span className="d-none d-sm-inline">
+                &nbsp;by dragging &amp; dropping,&nbsp;
+                  <span className="btn-link">selecting them</span>,&nbsp;
+                  or pasting from the clipboard.
+                </span>
+
+              </button>
+            )
+          }
+
+          { this.renderCheatsheetModal() }
+
+        </div>
+        <ConflictDiffModal
+          isOpen={this.props.pageContainer.state.isConflictDiffModalOpen}
+          onCancel={() => this.props.pageContainer.setState({ isConflictDiffModalOpen: false })}
+          pageContainer={this.props.pageContainer}
+          editorContainer={this.props.editorContainer}
+        />
+      </>
     );
     );
   }
   }
 
 
@@ -382,6 +392,7 @@ Editor.propTypes = Object.assign({
   onChange: PropTypes.func,
   onChange: PropTypes.func,
   onUpload: PropTypes.func,
   onUpload: PropTypes.func,
   editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
   editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
+  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
 }, AbstractEditor.propTypes);
 }, AbstractEditor.propTypes);
 
 
-export default withUnstatedContainers(Editor, [EditorContainer]);
+export default withUnstatedContainers(Editor, [EditorContainer, PageContainer]);

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

@@ -26,6 +26,7 @@ class PageStatusAlert extends React.Component {
     };
     };
 
 
     this.getContentsForSomeoneEditingAlert = this.getContentsForSomeoneEditingAlert.bind(this);
     this.getContentsForSomeoneEditingAlert = this.getContentsForSomeoneEditingAlert.bind(this);
+    this.getContentsForRevisionOutdated = this.getContentsForRevisionOutdated.bind(this);
     this.getContentsForDraftExistsAlert = this.getContentsForDraftExistsAlert.bind(this);
     this.getContentsForDraftExistsAlert = this.getContentsForDraftExistsAlert.bind(this);
     this.getContentsForUpdatedAlert = this.getContentsForUpdatedAlert.bind(this);
     this.getContentsForUpdatedAlert = this.getContentsForUpdatedAlert.bind(this);
   }
   }
@@ -49,6 +50,27 @@ class PageStatusAlert extends React.Component {
     ];
     ];
   }
   }
 
 
+  getContentsForRevisionOutdated() {
+    const { t, pageContainer } = this.props;
+    return [
+      ['bg-warning', 'd-hackmd-none'],
+      <>
+        <i className="icon-fw icon-pencil"></i>
+        {t('modal_resolve_conflict.file_conflicting_with_newer_remote')}
+      </>,
+      <>
+        <button type="button" onClick={() => this.refreshPage()} className="btn btn-outline-white mr-4">
+          <i className="icon-fw icon-reload mr-1"></i>
+          {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>
+          {t('modal_resolve_conflict.resolve_conflict')}
+        </button>
+      </>,
+    ];
+  }
+
   getContentsForDraftExistsAlert(isRealtime) {
   getContentsForDraftExistsAlert(isRealtime) {
     const { t } = this.props;
     const { t } = this.props;
     return [
     return [
@@ -84,16 +106,19 @@ class PageStatusAlert extends React.Component {
 
 
   render() {
   render() {
     const {
     const {
-      revisionId, revisionIdHackmdSynced, remoteRevisionId, hasDraftOnHackmd, isHackmdDraftUpdatingInRealtime,
+      revisionId, revisionIdHackmdSynced, remoteRevisionId, hasDraftOnHackmd, isHackmdDraftUpdatingInRealtime, isConflictingOnSave,
     } = this.props.pageContainer.state;
     } = this.props.pageContainer.state;
 
 
     const isRevisionOutdated = revisionId !== remoteRevisionId;
     const isRevisionOutdated = revisionId !== remoteRevisionId;
     const isHackmdDocumentOutdated = revisionIdHackmdSynced !== remoteRevisionId;
     const isHackmdDocumentOutdated = revisionIdHackmdSynced !== remoteRevisionId;
 
 
     let getContentsFunc = null;
     let getContentsFunc = null;
-
+    // when conflicting on save
+    if (isConflictingOnSave) {
+      getContentsFunc = this.getContentsForRevisionOutdated;
+    }
     // when remote revision is newer than both
     // when remote revision is newer than both
-    if (isHackmdDocumentOutdated && isRevisionOutdated) {
+    else if (isHackmdDocumentOutdated && isRevisionOutdated) {
       getContentsFunc = this.getContentsForUpdatedAlert;
       getContentsFunc = this.getContentsForUpdatedAlert;
     }
     }
     // when someone editing with HackMD
     // when someone editing with HackMD

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

@@ -49,6 +49,12 @@ class SavePageControls extends React.Component {
     catch (error) {
     catch (error) {
       logger.error('failed to save', error);
       logger.error('failed to save', error);
       pageContainer.showErrorToastr(error);
       pageContainer.showErrorToastr(error);
+      if (error.code === 'conflict') {
+        pageContainer.setState({
+          isConflictingOnSave: true,
+          revisionsOnConflict: error.data,
+        });
+      }
     }
     }
   }
   }
 
 

+ 30 - 2
packages/app/src/server/routes/page.js

@@ -828,9 +828,38 @@ module.exports = function(crowi, app) {
     }
     }
 
 
     // check revision
     // check revision
+    const Revision = crowi.model('Revision');
     let page = await Page.findByIdAndViewer(pageId, req.user);
     let page = await Page.findByIdAndViewer(pageId, req.user);
     if (page != null && revisionId != null && !page.isUpdatable(revisionId)) {
     if (page != null && revisionId != null && !page.isUpdatable(revisionId)) {
-      return res.json(ApiResponse.error('Posted param "revisionId" is outdated.', 'outdated'));
+      const populatedFields = 'name imageUrlCached';
+      // when isUpdatable is false, originRevisionId is a reqested revisionId
+      const originRevision = await Revision.findById(revisionId).populate('author', populatedFields);
+      const latestRevision = await Revision.findById(page.revision).populate('author', populatedFields);
+
+      const revisions = {};
+
+      revisions.request = {
+        revisionId: '',
+        revisionBody: pageBody,
+        createdAt: new Date(),
+        userName: req.user.name,
+        userImgPath: req.user.imageUrlCached,
+      };
+      revisions.origin = {
+        revisionId: originRevision._id.toString(),
+        revisionBody: originRevision.body,
+        createdAt: originRevision.createdAt,
+        userName: originRevision.author.name,
+        userImgPath: originRevision.author.imageUrlCached,
+      };
+      revisions.latest = {
+        revisionId: latestRevision._id.toString(),
+        revisionBody: latestRevision.body,
+        createdAt: latestRevision.createdAt,
+        userName: latestRevision.author.name,
+        userImgPath: latestRevision.author.imageUrlCached,
+      };
+      return res.json(ApiResponse.error('Posted param "revisionId" is outdated.', 'conflict', revisions));
     }
     }
 
 
     const options = { isSyncRevisionToHackmd };
     const options = { isSyncRevisionToHackmd };
@@ -839,7 +868,6 @@ module.exports = function(crowi, app) {
       options.grantUserGroupId = grantUserGroupId;
       options.grantUserGroupId = grantUserGroupId;
     }
     }
 
 
-    const Revision = crowi.model('Revision');
     const previousRevision = await Revision.findById(revisionId);
     const previousRevision = await Revision.findById(revisionId);
     try {
     try {
       page = await Page.updatePage(page, pageBody, previousRevision.body, req.user, options);
       page = await Page.updatePage(page, pageBody, previousRevision.body, req.user, options);

+ 2 - 1
packages/app/src/server/util/apiResponse.js

@@ -1,11 +1,12 @@
 function ApiResponse() {
 function ApiResponse() {
 }
 }
 
 
-ApiResponse.error = function(err, code) {
+ApiResponse.error = function(err, code, data) {
   const result = {};
   const result = {};
 
 
   result.ok = false;
   result.ok = false;
   result.code = code;
   result.code = code;
+  result.data = data;
 
 
   if (err instanceof Error) {
   if (err instanceof Error) {
     result.error = err.toString();
     result.error = err.toString();

+ 5 - 0
yarn.lock

@@ -7209,6 +7209,11 @@ diff@^5.0.0:
   resolved "https://registry.yarnpkg.com/diff/-/diff-5.0.0.tgz#7ed6ad76d859d030787ec35855f5b1daf31d852b"
   resolved "https://registry.yarnpkg.com/diff/-/diff-5.0.0.tgz#7ed6ad76d859d030787ec35855f5b1daf31d852b"
   integrity sha512-/VTCrvm5Z0JGty/BWHljh+BAiw3IK+2j87NGMu8Nwc/f48WoDAC395uomO9ZD117ZOBaHmkX1oyLvkVM/aIT3w==
   integrity sha512-/VTCrvm5Z0JGty/BWHljh+BAiw3IK+2j87NGMu8Nwc/f48WoDAC395uomO9ZD117ZOBaHmkX1oyLvkVM/aIT3w==
 
 
+diff_match_patch@^0.1.1:
+  version "0.1.1"
+  resolved "https://registry.yarnpkg.com/diff_match_patch/-/diff_match_patch-0.1.1.tgz#d3f14d5b76fb4b5a9cf44706261dadb5bd97edbc"
+  integrity sha1-0/FNW3b7S1qc9EcGJh2ttb2X7bw=
+
 diffie-hellman@^5.0.0:
 diffie-hellman@^5.0.0:
   version "5.0.2"
   version "5.0.2"
   resolved "https://registry.yarnpkg.com/diffie-hellman/-/diffie-hellman-5.0.2.tgz#b5835739270cfe26acf632099fded2a07f209e5e"
   resolved "https://registry.yarnpkg.com/diffie-hellman/-/diffie-hellman-5.0.2.tgz#b5835739270cfe26acf632099fded2a07f209e5e"