Browse Source

Merge pull request #8567 from weseek/feat/142111-resolve-conflict-from-view

feat: Resolve conflict from view
Yuki Takei 2 years ago
parent
commit
fc867a7258

+ 1 - 0
apps/app/public/static/locales/en_US/translation.json

@@ -438,6 +438,7 @@
     }
   },
   "modal_resolve_conflict": {
+    "conflicts_with_new_body_on_server_side": "Conflict with new body on server side. Please select or edit the page body to resolve the 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",

+ 1 - 0
apps/app/public/static/locales/ja_JP/translation.json

@@ -471,6 +471,7 @@
     }
   },
   "modal_resolve_conflict": {
+    "conflicts_with_new_body_on_server_side": "サーバー側の新しい本文と衝突します。ページ本文を選択または編集して衝突を解消してください。",
     "file_conflicting_with_newer_remote": "サーバー側の新しいファイルと衝突します。",
     "resolve_conflict_message": "ページ本文を選んでください",
     "resolve_conflict": "衝突を解消",

+ 1 - 0
apps/app/public/static/locales/zh_CN/translation.json

@@ -427,6 +427,7 @@
     }
   },
   "modal_resolve_conflict": {
+    "conflicts_with_new_body_on_server_side": "与服务器端的新正文文本冲突。 请选择或编辑页面正文以解决冲突",
     "file_conflicting_with_newer_remote": "此文件与较新的远程文件冲突",
     "resolve_conflict_message": "选择页面正文",
     "resolve_conflict": "解决冲突",

+ 0 - 5
apps/app/src/client/services/page-operation.ts

@@ -96,11 +96,6 @@ export const createPage = async(params: IApiv3PageCreateParams): Promise<IApiv3P
   return res.data;
 };
 
-export const updatePage = async(params: IApiv3PageUpdateParams): Promise<IApiv3PageUpdateResponse> => {
-  const res = await apiv3Put<IApiv3PageUpdateResponse>('/page', params);
-  return res.data;
-};
-
 export type UpdateStateAfterSaveOption = {
   supressEditingMarkdownMutation: boolean,
 }

+ 47 - 11
apps/app/src/client/services/side-effects/drawio-modal-launcher-for-view.ts

@@ -5,14 +5,14 @@ import type EventEmitter from 'events';
 import { Origin } from '@growi/core';
 import type { DrawioEditByViewerProps } from '@growi/remark-drawio';
 
+import { extractRemoteRevisionDataFromErrorObj, updatePage as _updatePage } from '~/client/services/update-page';
 import { replaceDrawioInMarkdown } from '~/components/Page/markdown-drawio-util-for-view';
 import { useShareLinkId } from '~/stores/context';
-import { useDrawioModal } from '~/stores/modal';
+import { useConflictDiffModal, useDrawioModal } from '~/stores/modal';
 import { useSWRxCurrentPage } from '~/stores/page';
+import { type RemoteRevisionData, useSetRemoteLatestPageData } from '~/stores/remote-latest-page';
 import loggerFactory from '~/utils/logger';
 
-import { updatePage } from '../page-operation';
-
 
 const logger = loggerFactory('growi:cli:side-effects:useDrawioModalLauncherForView');
 
@@ -34,31 +34,67 @@ export const useDrawioModalLauncherForView = (opts?: {
 
   const { open: openDrawioModal } = useDrawioModal();
 
-  const saveByDrawioModal = useCallback(async(drawioMxFile: string, bol: number, eol: number) => {
+  const { open: openConflictDiffModal, close: closeConflictDiffModal } = useConflictDiffModal();
+
+  const { setRemoteLatestPageData } = useSetRemoteLatestPageData();
+
+  // eslint-disable-next-line max-len
+  const updatePage = useCallback(async(revisionId:string, newMarkdown: string, onConflict: (conflictData: RemoteRevisionData, newMarkdown: string) => void) => {
     if (currentPage == null || currentPage.revision == null || shareLinkId != null) {
       return;
     }
 
-    const currentMarkdown = currentPage.revision.body;
-    const newMarkdown = replaceDrawioInMarkdown(drawioMxFile, currentMarkdown, bol, eol);
-
     try {
-      const currentRevisionId = currentPage.revision._id;
-      await updatePage({
+      await _updatePage({
         pageId: currentPage._id,
-        revisionId: currentRevisionId,
+        revisionId,
         body: newMarkdown,
         origin: Origin.View,
       });
 
+      closeConflictDiffModal();
       opts?.onSaveSuccess?.();
     }
     catch (error) {
+      const remoteRevidsionData = extractRemoteRevisionDataFromErrorObj(error);
+      if (remoteRevidsionData != null) {
+        onConflict(remoteRevidsionData, newMarkdown);
+      }
+
       logger.error('failed to save', error);
       opts?.onSaveError?.(error);
     }
-  }, [currentPage, opts, shareLinkId]);
+  }, [closeConflictDiffModal, currentPage, opts, shareLinkId]);
+
+  // eslint-disable-next-line max-len
+  const generateResolveConflictHandler = useCallback((revisionId: string, onConflict: (conflictData: RemoteRevisionData, newMarkdown: string) => void) => {
+    return async(newMarkdown: string) => {
+      await updatePage(revisionId, newMarkdown, onConflict);
+    };
+  }, [updatePage]);
+
+  const onConflictHandler = useCallback((remoteRevidsionData: RemoteRevisionData, newMarkdown: string) => {
+    setRemoteLatestPageData(remoteRevidsionData);
+
+    const resolveConflictHandler = generateResolveConflictHandler(remoteRevidsionData.remoteRevisionId, onConflictHandler);
+    if (resolveConflictHandler == null) {
+      return;
+    }
+
+    openConflictDiffModal(newMarkdown, resolveConflictHandler);
+  }, [generateResolveConflictHandler, openConflictDiffModal, setRemoteLatestPageData]);
+
+  const saveByDrawioModal = useCallback(async(drawioMxFile: string, bol: number, eol: number) => {
+    if (currentPage == null || currentPage.revision == null) {
+      return;
+    }
+
+    const currentRevisionId = currentPage.revision._id;
+    const currentMarkdown = currentPage.revision.body;
+    const newMarkdown = replaceDrawioInMarkdown(drawioMxFile, currentMarkdown, bol, eol);
 
+    await updatePage(currentRevisionId, newMarkdown, onConflictHandler);
+  }, [currentPage, onConflictHandler, updatePage]);
 
   // set handler to open DrawioModal
   useEffect(() => {

+ 47 - 11
apps/app/src/client/services/side-effects/handsontable-modal-launcher-for-view.ts

@@ -5,14 +5,14 @@ import type EventEmitter from 'events';
 import { Origin } from '@growi/core';
 
 import type MarkdownTable from '~/client/models/MarkdownTable';
+import { extractRemoteRevisionDataFromErrorObj, updatePage as _updatePage } from '~/client/services/update-page';
 import { getMarkdownTableFromLine, replaceMarkdownTableInMarkdown } from '~/components/Page/markdown-table-util-for-view';
 import { useShareLinkId } from '~/stores/context';
-import { useHandsontableModal } from '~/stores/modal';
+import { useHandsontableModal, useConflictDiffModal } from '~/stores/modal';
 import { useSWRxCurrentPage } from '~/stores/page';
+import { type RemoteRevisionData, useSetRemoteLatestPageData } from '~/stores/remote-latest-page';
 import loggerFactory from '~/utils/logger';
 
-import { updatePage } from '../page-operation';
-
 
 const logger = loggerFactory('growi:cli:side-effects:useHandsontableModalLauncherForView');
 
@@ -34,31 +34,67 @@ export const useHandsontableModalLauncherForView = (opts?: {
 
   const { open: openHandsontableModal } = useHandsontableModal();
 
-  const saveByHandsontableModal = useCallback(async(table: MarkdownTable, bol: number, eol: number) => {
+  const { open: openConflictDiffModal, close: closeConflictDiffModal } = useConflictDiffModal();
+
+  const { setRemoteLatestPageData } = useSetRemoteLatestPageData();
+
+  // eslint-disable-next-line max-len
+  const updatePage = useCallback(async(revisionId:string, newMarkdown: string, onConflict: (conflictData: RemoteRevisionData, newMarkdown: string) => void) => {
     if (currentPage == null || currentPage.revision == null || shareLinkId != null) {
       return;
     }
 
-    const currentMarkdown = currentPage.revision.body;
-    const newMarkdown = replaceMarkdownTableInMarkdown(table, currentMarkdown, bol, eol);
-
     try {
-      const currentRevisionId = currentPage.revision._id;
-      await updatePage({
+      await _updatePage({
         pageId: currentPage._id,
-        revisionId: currentRevisionId,
+        revisionId,
         body: newMarkdown,
         origin: Origin.View,
       });
 
+      closeConflictDiffModal();
       opts?.onSaveSuccess?.();
     }
     catch (error) {
+      const remoteRevidsionData = extractRemoteRevisionDataFromErrorObj(error);
+      if (remoteRevidsionData != null) {
+        onConflict?.(remoteRevidsionData, newMarkdown);
+      }
+
       logger.error('failed to save', error);
       opts?.onSaveError?.(error);
     }
-  }, [currentPage, opts, shareLinkId]);
+  }, [closeConflictDiffModal, currentPage, opts, shareLinkId]);
+
+  // eslint-disable-next-line max-len
+  const generateResolveConflictHandler = useCallback((revisionId: string, onConflict: (conflictData: RemoteRevisionData, newMarkdown: string) => void) => {
+    return async(newMarkdown: string) => {
+      await updatePage(revisionId, newMarkdown, onConflict);
+    };
+  }, [updatePage]);
+
+  const onConflictHandler = useCallback((remoteRevidsionData: RemoteRevisionData, newMarkdown: string) => {
+    setRemoteLatestPageData(remoteRevidsionData);
+
+    const resolveConflictHandler = generateResolveConflictHandler(remoteRevidsionData.remoteRevisionId, onConflictHandler);
+    if (resolveConflictHandler == null) {
+      return;
+    }
+
+    openConflictDiffModal(newMarkdown, resolveConflictHandler);
+  }, [generateResolveConflictHandler, openConflictDiffModal, setRemoteLatestPageData]);
+
+  const saveByHandsontableModal = useCallback(async(table: MarkdownTable, bol: number, eol: number) => {
+    if (currentPage == null || currentPage.revision == null) {
+      return;
+    }
+
+    const currentRevisionId = currentPage.revision._id;
+    const currentMarkdown = currentPage.revision.body;
+    const newMarkdown = replaceMarkdownTableInMarkdown(table, currentMarkdown, bol, eol);
 
+    await updatePage(currentRevisionId, newMarkdown, onConflictHandler);
+  }, [currentPage, onConflictHandler, updatePage]);
 
   // set handler to open HandsonTableModal
   useEffect(() => {

+ 22 - 0
apps/app/src/client/services/update-page/conflict.ts

@@ -0,0 +1,22 @@
+import type { ErrorV3 } from '@growi/core/dist/models';
+
+import { PageUpdateErrorCode } from '~/interfaces/apiv3';
+import { type RemoteRevisionData } from '~/stores/remote-latest-page';
+
+export const extractRemoteRevisionDataFromErrorObj = (errors: Array<ErrorV3>): RemoteRevisionData | undefined => {
+  for (const error of errors) {
+    if (error.code === PageUpdateErrorCode.CONFLICT) {
+
+      const latestRevision = error.args.returnLatestRevision;
+
+      const remoteRevidsionData = {
+        remoteRevisionId: latestRevision.revisionId,
+        remoteRevisionBody: latestRevision.revisionBody,
+        remoteRevisionLastUpdateUser: latestRevision.user,
+        remoteRevisionLastUpdatedAt: latestRevision.createdAt,
+      };
+
+      return remoteRevidsionData;
+    }
+  }
+};

+ 9 - 0
apps/app/src/client/services/update-page/index.ts

@@ -0,0 +1,9 @@
+import { apiv3Put } from '~/client/util/apiv3-client';
+import type { IApiv3PageUpdateParams, IApiv3PageUpdateResponse } from '~/interfaces/apiv3';
+
+export * from './conflict';
+
+export const updatePage = async(params: IApiv3PageUpdateParams): Promise<IApiv3PageUpdateResponse> => {
+  const res = await apiv3Put<IApiv3PageUpdateResponse>('/page', params);
+  return res.data;
+};

+ 20 - 5
apps/app/src/components/Page/PageContentsUtilities.tsx

@@ -3,7 +3,8 @@ import { useTranslation } from 'next-i18next';
 import { useUpdateStateAfterSave } from '~/client/services/page-operation';
 import { useDrawioModalLauncherForView } from '~/client/services/side-effects/drawio-modal-launcher-for-view';
 import { useHandsontableModalLauncherForView } from '~/client/services/side-effects/handsontable-modal-launcher-for-view';
-import { toastSuccess, toastError } from '~/client/util/toastr';
+import { toastSuccess, toastError, toastWarning } from '~/client/util/toastr';
+import { PageUpdateErrorCode } from '~/interfaces/apiv3';
 import { useCurrentPageId } from '~/stores/page';
 
 
@@ -19,8 +20,15 @@ export const PageContentsUtilities = (): null => {
 
       updateStateAfterSave?.();
     },
-    onSaveError: (error) => {
-      toastError(error);
+    onSaveError: (errors) => {
+      for (const error of errors) {
+        if (error.code === PageUpdateErrorCode.CONFLICT) {
+          toastWarning(t('modal_resolve_conflict.conflicts_with_new_body_on_server_side'));
+          return;
+        }
+      }
+
+      toastError(errors);
     },
   });
 
@@ -30,8 +38,15 @@ export const PageContentsUtilities = (): null => {
 
       updateStateAfterSave?.();
     },
-    onSaveError: (error) => {
-      toastError(error);
+    onSaveError: (errors) => {
+      for (const error of errors) {
+        if (error.code === PageUpdateErrorCode.CONFLICT) {
+          toastWarning(t('modal_resolve_conflict.conflicts_with_new_body_on_server_side'));
+          return;
+        }
+      }
+
+      toastError(errors);
     },
   });
 

+ 38 - 142
apps/app/src/components/PageEditor/ConflictDiffModal.tsx

@@ -1,9 +1,8 @@
-
 import React, {
   useState, useEffect, useCallback, useMemo,
 } from 'react';
 
-import type { IRevisionOnConflict } from '@growi/core';
+import type { IUser } from '@growi/core';
 import {
   MergeViewer, CodeMirrorEditorDiff, GlobalCodeMirrorEditorKey, useCodeMirrorEditorIsolated,
 } from '@growi/editor';
@@ -14,32 +13,32 @@ import {
   Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
 
-import { toastError, toastSuccess } from '~/client/util/toastr';
-import { useCurrentPathname, useCurrentUser } from '~/stores/context';
+import { useCurrentUser } from '~/stores/context';
 import { useConflictDiffModal } from '~/stores/modal';
-import { useCurrentPagePath, useSWRxCurrentPage, useCurrentPageId } from '~/stores/page';
+import { useSWRxCurrentPage } from '~/stores/page';
 import {
-  useRemoteRevisionBody, useRemoteRevisionId, useRemoteRevisionLastUpdatedAt, useRemoteRevisionLastUpdateUser, useSetRemoteLatestPageData,
+  useRemoteRevisionBody, useRemoteRevisionId, useRemoteRevisionLastUpdatedAt, useRemoteRevisionLastUpdateUser,
 } from '~/stores/remote-latest-page';
 
 import styles from './ConflictDiffModal.module.scss';
 
+type IRevisionOnConflict = {
+  revisionBody: string
+  createdAt: Date
+  user: IUser
+}
+
 type ConflictDiffModalCoreProps = {
-  // optionsToSave: OptionsToSave | undefined;
-  request: IRevisionOnConflictWithStringDate,
-  latest: IRevisionOnConflictWithStringDate,
-  onClose?: () => void,
-  onResolved?: () => void,
+  request: IRevisionOnConflict
+  latest: IRevisionOnConflict
 };
 
-type IRevisionOnConflictWithStringDate = Omit<IRevisionOnConflict, 'createdAt'> & {
-  createdAt: string
-}
+const formatedDate = (date: Date): string => {
+  return format(date, 'yyyy/MM/dd HH:mm:ss');
+};
 
 const ConflictDiffModalCore = (props: ConflictDiffModalCoreProps): JSX.Element => {
-  const {
-    request, latest, onClose, onResolved,
-  } = props;
+  const { request, latest } = props;
 
   const [resolvedRevision, setResolvedRevision] = useState<string>('');
   const [isRevisionselected, setIsRevisionSelected] = useState<boolean>(false);
@@ -50,12 +49,6 @@ const ConflictDiffModalCore = (props: ConflictDiffModalCoreProps): JSX.Element =
   const { data: conflictDiffModalStatus, close: closeConflictDiffModal } = useConflictDiffModal();
   const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(GlobalCodeMirrorEditorKey.DIFF);
 
-  // const { data: remoteRevisionId } = useRemoteRevisionId();
-  // const { setRemoteLatestPageData } = useSetRemoteLatestPageData();
-  // const { data: pageId } = useCurrentPageId();
-  // const { data: currentPagePath } = useCurrentPagePath();
-  // const { data: currentPathname } = useCurrentPathname();
-
   const selectRevisionHandler = useCallback((selectedRevision: string) => {
     setResolvedRevision(selectedRevision);
     setRevisionSelectedToggler(prev => !prev);
@@ -65,17 +58,14 @@ const ConflictDiffModalCore = (props: ConflictDiffModalCoreProps): JSX.Element =
     }
   }, [isRevisionselected]);
 
-  const closeModalHandler = useCallback(() => {
-    closeConflictDiffModal();
-    onClose?.();
-  }, [closeConflictDiffModal, onClose]);
-
   const resolveConflictHandler = useCallback(async() => {
     const newBody = codeMirrorEditor?.getDoc();
+    if (newBody == null) {
+      return;
+    }
 
-    // TODO: impl
-    onResolved?.();
-  }, [codeMirrorEditor, onResolved]);
+    await conflictDiffModalStatus?.onResolve?.(newBody);
+  }, [codeMirrorEditor, conflictDiffModalStatus]);
 
   useEffect(() => {
     codeMirrorEditor?.initDoc(resolvedRevision);
@@ -87,11 +77,11 @@ const ConflictDiffModalCore = (props: ConflictDiffModalCoreProps): JSX.Element =
       <button type="button" className="btn" onClick={() => setIsModalExpanded(prev => !prev)}>
         <span className="material-symbols-outlined">{isModalExpanded ? 'close_fullscreen' : 'open_in_full'}</span>
       </button>
-      <button type="button" className="btn" onClick={closeModalHandler} aria-label="Close">
+      <button type="button" className="btn" onClick={closeConflictDiffModal} aria-label="Close">
         <span className="material-symbols-outlined">close</span>
       </button>
     </div>
-  ), [closeModalHandler, isModalExpanded]);
+  ), [closeConflictDiffModal, isModalExpanded]);
 
   return (
     <Modal isOpen={conflictDiffModalStatus?.isOpened} className={`${styles['conflict-diff-modal']} ${isModalExpanded ? ' grw-modal-expanded' : ''}`} size="xl">
@@ -114,7 +104,7 @@ const ConflictDiffModalCore = (props: ConflictDiffModalCoreProps): JSX.Element =
               </div>
               <div className="ms-3 text-muted">
                 <p className="my-0">updated by {request.user.username}</p>
-                <p className="my-0">{request.createdAt}</p>
+                <p className="my-0">{ formatedDate(request.createdAt) }</p>
               </div>
             </div>
           </div>
@@ -127,7 +117,7 @@ const ConflictDiffModalCore = (props: ConflictDiffModalCoreProps): JSX.Element =
               </div>
               <div className="ms-3 text-muted">
                 <p className="my-0">updated by {latest.user.username}</p>
-                <p className="my-0">{latest.createdAt}</p>
+                <p className="my-0">{ formatedDate(latest.createdAt) }</p>
               </div>
             </div>
           </div>
@@ -176,7 +166,7 @@ const ConflictDiffModalCore = (props: ConflictDiffModalCoreProps): JSX.Element =
         <button
           type="button"
           className="btn btn-outline-secondary"
-          onClick={closeModalHandler}
+          onClick={closeConflictDiffModal}
         >
           {t('Cancel')}
         </button>
@@ -194,86 +184,10 @@ const ConflictDiffModalCore = (props: ConflictDiffModalCoreProps): JSX.Element =
 };
 
 
-const dummyTest1 = `# :tada: グローウィ へようこそ
-[![GitHub Releases](https://img.shields.io/github/release/weseek/growi.svg)](https://github.com/weseek/growi/releases/latest)
-[![MIT License](https://img.shields.io/badge/license-MIT-blue.svg?style=flat)](https://github.com/weseek/growi/blob/master/LICENSE)
-
-グローウィ は個人・法人向けの Wiki | ナレッジベースツールです。
-会社や大学の研究室、サークルでのナレッジ情報を簡単に共有でき、作られたページは誰でも編集が可能です。
-
-知っている情報をカジュアルに書き出しみんなで編集することで、**チーム内での暗黙知を減らす**ことができます。
-当たり前に共有される情報を日々増やしていきましょう。
-
-### :beginner: 簡単なページの作り方
-
-- 右上の "**作成**"ボタンまたは右下の**鉛筆アイコン**のボタンからページを書き始めることができます
-    - ページタイトルは後から変更できますので、適当に入力しても大丈夫です
-        - タイトル入力欄では、半角の / (スラッシュ) でページ階層を作れます
-        - (例)/カテゴリ1/カテゴリ2/作りたいページタイトル のように入力してみてください
-- \`\`- \` を行頭につけると、この文章のような箇条書きを書くことができます\`\`
-- 画像やPDF、Word/Excel/PowerPointなどの添付ファイルも、コピー&ペースト、ドラッグ&ドロップで貼ることができます
-- 書けたら "**更新**" ボタンを押してページを公開しましょう
-    - \`Ctrl(⌘) + S\` でも保存できます
-
-さらに詳しくはこちら: [ページを作成する](https://docs.growi.org/ja/guide/features/create_page.html)
-
-<div class="mt-4 card border-primary">
-  <div class="card-header bg-primary text-light">Tips</div>
-  <div class="card-body"><ul>
-    <li>Ctrl(⌘) + "/" でショートカットヘルプを表示します</li>
-    <li>HTML/CSS の記述には、<a href="https://getbootstrap.com/docs/4.6/components/">Bootstrap 4</a> を利用できます</li>
-  </ul></div>
-</div>
-`;
-
-const dummyTest2 = `# :tada: GROWI へようこそ
-[![GitHub Releases](https://img.shields.io/github/release/weseek/growi.svg)](https://github.com/weseek/growi/releases/latest)
-[![MIT License](https://img.shields.io/badge/license-MIT-blue.svg?style=flat)](https://github.com/weseek/growi/blob/master/LICENSE)
-
-GROWI は個人・法人向けの Wiki | ナレッジベースツールです。
-会社や大学の研究室、サークルでのナレッジ情報を簡単に共有でき、作られたページは誰でも編集が可能です。
-
-知っている情報をカジュアルに書き出しみんなで編集することで、**チーム内での暗黙知を減らす**ことができます。
-当たり前に共有される情報を日々増やしていきましょう。
-
-### :beginner: 簡単なページの作り方
-
-- 右上の "**作成**"ボタンまたは右下の**鉛筆アイコン**のボタンからページを書き始めることができます
-    - ページタイトルは後から変更できますので、適当に入力しても大丈夫です
-        - タイトル入力欄では、半角の / (スラッシュ) でページ階層を作れます
-        - (例)/カテゴリ1/カテゴリ2/作りたいページタイトル のように入力してみてください
-- \`\`- \` を行頭につけると、この文章のような箇条書きを書くことができます\`\`
-- 画像やPDF、Word/Excel/PowerPointなどの添付ファイルも、コピー&ペースト、ドラッグ&ドロップで貼ることができます
-- 書けたら "**更新**" ボタンを押してページを公開しましょう
-    - \`Ctrl(⌘) + S\` でも保存できます
-
-さらに詳しくはこちら: [ページを作成する](https://docs.growi.org/ja/guide/features/create_page.html)
-
-<div class="mt-4 card border-primary">
-  <div class="card-header bg-primary text-light">Tips</div>
-  <div class="card-body"><ul>
-    <li>Ctrl(⌘) + "/" でショートカットヘルプを表示します</li>
-    <li>HTML/CSS の記述には、<a href="https://getbootstrap.com/docs/4.6/components/">Bootstrap 4</a> を利用できます</li>
-  </ul></div>
-</div>
-`;
-
-type ConflictDiffModalProps = {
-  onClose?: () => void,
-  onResolved?: () => void,
-  // optionsToSave: OptionsToSave | undefined;
-  // afterResolvedHandler: () => void,
-};
-
-
-export const ConflictDiffModal = (props: ConflictDiffModalProps): JSX.Element => {
-  const {
-    onClose, onResolved,
-  } = props;
+export const ConflictDiffModal = (): JSX.Element => {
   const { data: currentUser } = useCurrentUser();
-
-  // state for current page
   const { data: currentPage } = useSWRxCurrentPage();
+  const { data: conflictDiffModalStatus } = useConflictDiffModal();
 
   // state for latest page
   const { data: remoteRevisionId } = useRemoteRevisionId();
@@ -281,43 +195,25 @@ export const ConflictDiffModal = (props: ConflictDiffModalProps): JSX.Element =>
   const { data: remoteRevisionLastUpdateUser } = useRemoteRevisionLastUpdateUser();
   const { data: remoteRevisionLastUpdatedAt } = useRemoteRevisionLastUpdatedAt();
 
-  const { data: conflictDiffModalStatus } = useConflictDiffModal();
-
-  const currentTime: Date = new Date();
-
   const isRemotePageDataInappropriate = remoteRevisionId == null || remoteRevisionBody == null || remoteRevisionLastUpdateUser == null;
 
-  if (!conflictDiffModalStatus?.isOpened || currentUser == null || currentPage == null) {
+  if (!conflictDiffModalStatus?.isOpened || currentUser == null || currentPage == null || isRemotePageDataInappropriate) {
     return <></>;
   }
 
-  const request: IRevisionOnConflictWithStringDate = {
-    revisionId: '',
-    revisionBody: dummyTest1,
-    createdAt: format(currentTime, 'yyyy/MM/dd HH:mm:ss'),
-    user: currentUser,
-  };
+  const currentTime: Date = new Date();
 
-  const latest: IRevisionOnConflictWithStringDate = {
-    revisionId: '',
-    revisionBody: dummyTest2,
-    createdAt: format(currentTime, 'yyyy/MM/dd HH:mm:ss'),
+  const request: IRevisionOnConflict = {
+    revisionBody: conflictDiffModalStatus.requestRevisionBody ?? '',
+    createdAt: currentTime,
     user: currentUser,
   };
 
-  // const latest: IRevisionOnConflictWithStringDate = {
-  //   revisionId: remoteRevisionId,
-  //   revisionBody: remoteRevisionBody,
-  //   createdAt: format(new Date(remoteRevisionLastUpdatedAt || currentTime.toString()), 'yyyy/MM/dd HH:mm:ss'),
-  //   user: remoteRevisionLastUpdateUser,
-  // };
-
-  const propsForCore = {
-    onResolved,
-    onClose,
-    request,
-    latest,
+  const latest: IRevisionOnConflict = {
+    revisionBody: remoteRevisionBody,
+    createdAt: new Date(remoteRevisionLastUpdatedAt ?? currentTime.toString()),
+    user: remoteRevisionLastUpdateUser,
   };
 
-  return <ConflictDiffModalCore {...propsForCore} />;
+  return <ConflictDiffModalCore request={request} latest={latest} />;
 };

+ 2 - 13
apps/app/src/components/PageEditor/PageEditor.tsx

@@ -18,9 +18,9 @@ import detectIndent from 'detect-indent';
 import { useTranslation } from 'next-i18next';
 import { throttle, debounce } from 'throttle-debounce';
 
-
 import { useShouldExpandContent } from '~/client/services/layout';
-import { useUpdateStateAfterSave, updatePage } from '~/client/services/page-operation';
+import { useUpdateStateAfterSave } from '~/client/services/page-operation';
+import { updatePage } from '~/client/services/update-page';
 import { apiv3Get, apiv3PostForm } from '~/client/util/apiv3-client';
 import { toastError, toastSuccess } from '~/client/util/toastr';
 import { SocketEventName } from '~/interfaces/websocket';
@@ -64,8 +64,6 @@ import { scrollEditor, scrollPreview } from './ScrollSyncHelper';
 
 import '@growi/editor/dist/style.css';
 
-// import { ConflictDiffModal } from './PageEditor/ConflictDiffModal';
-// import { ConflictDiffModal } from './ConflictDiffModal';
 
 const logger = loggerFactory('growi:PageEditor');
 
@@ -466,15 +464,6 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
             style={pastEndStyle}
           />
         </div>
-        {/*
-        <ConflictDiffModal
-          isOpen={conflictDiffModalStatus?.isOpened}
-          onClose={() => closeConflictDiffModal()}
-          markdownOnEdit={markdownToPreview}
-          optionsToSave={optionsToSave}
-          afterResolvedHandler={afterResolvedHandler}
-        />
-        */}
       </div>
 
       <EditorNavbarBottom />

+ 1 - 1
apps/app/src/components/PageStatusAlert.tsx

@@ -42,7 +42,7 @@ export const PageStatusAlert = (): JSX.Element => {
   }, [mutateEditingMarkdown, mutatePageData]);
 
   const onClickResolveConflict = useCallback(() => {
-    openConflictDiffModal();
+    // openConflictDiffModal();
   }, [openConflictDiffModal]);
 
   // TODO: re-impl for builtin editor

+ 4 - 0
apps/app/src/interfaces/apiv3/page.ts

@@ -38,3 +38,7 @@ export type IApiv3PageUpdateResponse = {
   page: IPageHasId,
   revision: IRevisionHasId,
 };
+
+export const PageUpdateErrorCode = {
+  CONFLICT: 'conflict',
+} as const;

+ 2 - 0
apps/app/src/pages/[[...path]].page.tsx

@@ -80,6 +80,7 @@ const LinkEditModal = dynamic(() => import('../components/PageEditor/LinkEditMod
 const PageStatusAlert = dynamic(() => import('../components/PageStatusAlert').then(mod => mod.PageStatusAlert), { ssr: false });
 const QuestionnaireModalManager = dynamic(() => import('~/features/questionnaire/client/components/QuestionnaireModalManager'), { ssr: false });
 const TagEditModal = dynamic(() => import('../components/PageTags/TagEditModal').then(mod => mod.TagEditModal), { ssr: false });
+const ConflictDiffModal = dynamic(() => import('../components/PageEditor/ConflictDiffModal').then(mod => mod.ConflictDiffModal), { ssr: false });
 
 const logger = loggerFactory('growi:pages:all');
 
@@ -382,6 +383,7 @@ Page.getLayout = function getLayout(page: React.ReactElement<Props>) {
       <TemplateModal />
       <LinkEditModal />
       <TagEditModal />
+      <ConflictDiffModal />
     </>
   );
 };

+ 2 - 4
apps/app/src/server/routes/apiv3/page/update-page.ts

@@ -9,7 +9,7 @@ import { body } from 'express-validator';
 import mongoose from 'mongoose';
 
 import { SupportedAction, SupportedTargetModel } from '~/interfaces/activity';
-import { type IApiv3PageUpdateParams } from '~/interfaces/apiv3';
+import { type IApiv3PageUpdateParams, PageUpdateErrorCode } from '~/interfaces/apiv3';
 import type { IOptionsForUpdate } from '~/interfaces/page';
 import { RehypeSanitizeOption } from '~/interfaces/rehype';
 import type Crowi from '~/server/crowi';
@@ -145,9 +145,7 @@ export const updatePageHandlersFactory: UpdatePageHandlersFactory = (crowi) => {
           createdAt: latestRevision?.createdAt,
           user: serializeUserSecurely(latestRevision?.author),
         };
-        return res.apiv3Err(new ErrorV3('Posted param "revisionId" is outdated.', 'conflict'), 409, {
-          returnLatestRevision,
-        });
+        return res.apiv3Err(new ErrorV3('Posted param "revisionId" is outdated.', PageUpdateErrorCode.CONFLICT, undefined, { returnLatestRevision }), 409);
       }
 
       let updatedPage;

+ 9 - 5
apps/app/src/stores/modal.tsx

@@ -566,13 +566,17 @@ export const useHandsontableModal = (status?: HandsontableModalStatus): SWRRespo
 /*
  * ConflictDiffModal
  */
+type ResolveConflictHandler = (newMarkdown: string) => Promise<void> | void;
+
 type ConflictDiffModalStatus = {
-  isOpened: boolean,
+ isOpened: boolean,
+ requestRevisionBody?: string,
+ onResolve?: ResolveConflictHandler
 }
 
 type ConflictDiffModalUtils = {
-  open(): void,
-  close(): void,
+ open(requestRevisionBody: string, onResolveConflict: ResolveConflictHandler): void,
+ close(): void,
 }
 
 export const useConflictDiffModal = (): SWRResponse<ConflictDiffModalStatus, Error> & ConflictDiffModalUtils => {
@@ -581,8 +585,8 @@ export const useConflictDiffModal = (): SWRResponse<ConflictDiffModalStatus, Err
   const swrResponse = useStaticSWR<ConflictDiffModalStatus, Error>('conflictDiffModal', undefined, { fallbackData: initialStatus });
 
   return Object.assign(swrResponse, {
-    open: () => {
-      swrResponse.mutate({ isOpened: true });
+    open: (requestRevisionBody: string, onResolve: ResolveConflictHandler) => {
+      swrResponse.mutate({ isOpened: true, requestRevisionBody, onResolve });
     },
     close: () => {
       swrResponse.mutate({ isOpened: false });

+ 0 - 8
packages/core/src/interfaces/revision.ts

@@ -31,14 +31,6 @@ export type IRevisionsForPagination = {
   revisions: IRevisionHasPageId[], // revisions in one pagination
   totalCounts: number // total counts
 }
-
-export type IRevisionOnConflict = {
-  revisionId: string,
-  revisionBody: string,
-  createdAt: Date,
-  user: IUser
-}
-
 export type HasRevisionShortbody = {
   revisionShortBody?: string,
 }