Shun Miyazawa il y a 2 ans
Parent
commit
963b6c292c

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

@@ -5,10 +5,12 @@ import type EventEmitter from 'events';
 import { Origin } from '@growi/core';
 import type { DrawioEditByViewerProps } from '@growi/remark-drawio';
 
+import { extractRemoteRevisionDataFromErrorObj } from '~/client/util/conflict';
 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';
@@ -34,6 +36,57 @@ export const useDrawioModalLauncherForView = (opts?: {
 
   const { open: openDrawioModal } = useDrawioModal();
 
+  const { open: openConflictDiffModal, close: closeConflictDiffModal } = useConflictDiffModal();
+
+  const { setRemoteLatestPageData } = useSetRemoteLatestPageData();
+
+  // eslint-disable-next-line max-len
+  const generateResolveConflictHandler = useCallback((revisionId: string, onConflict?: (conflictData: RemoteRevisionData, newMarkdown: string) => void) => {
+    if (currentPage == null || currentPage.revision == null || shareLinkId != null) {
+      return;
+    }
+
+    return async(newMarkdown: string) => {
+      try {
+        await updatePage({
+          pageId: currentPage._id,
+          revisionId,
+          body: newMarkdown,
+          origin: Origin.View,
+        });
+
+        opts?.onSaveSuccess?.();
+        closeConflictDiffModal();
+
+        // TODO: If no user is editing in the Editor, update ydoc as well
+        // https://redmine.weseek.co.jp/issues/142109
+      }
+
+      catch (error) {
+        const conflictData = extractRemoteRevisionDataFromErrorObj(error);
+        if (conflictData != null) {
+          // Called if conflicts occur after resolving conflicts
+          onConflict?.(conflictData, newMarkdown);
+          return;
+        }
+
+        logger.error('failed to save', error);
+        opts?.onSaveError?.(error);
+      }
+    };
+  }, [closeConflictDiffModal, currentPage, opts, shareLinkId]);
+
+  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 || shareLinkId != null) {
       return;
@@ -54,10 +107,15 @@ export const useDrawioModalLauncherForView = (opts?: {
       opts?.onSaveSuccess?.();
     }
     catch (error) {
+      const remoteRevidsionData = extractRemoteRevisionDataFromErrorObj(error);
+      if (remoteRevidsionData != null) {
+        onConflictHandler(remoteRevidsionData, newMarkdown);
+      }
+
       logger.error('failed to save', error);
       opts?.onSaveError?.(error);
     }
-  }, [currentPage, opts, shareLinkId]);
+  }, [currentPage, onConflictHandler, opts, shareLinkId]);
 
 
   // set handler to open DrawioModal

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

@@ -5,10 +5,12 @@ import type EventEmitter from 'events';
 import { Origin } from '@growi/core';
 
 import type MarkdownTable from '~/client/models/MarkdownTable';
+import { extractRemoteRevisionDataFromErrorObj } from '~/client/util/conflict';
 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';
@@ -34,6 +36,58 @@ export const useHandsontableModalLauncherForView = (opts?: {
 
   const { open: openHandsontableModal } = useHandsontableModal();
 
+  const { open: openConflictDiffModal, close: closeConflictDiffModal } = useConflictDiffModal();
+
+  const { setRemoteLatestPageData } = useSetRemoteLatestPageData();
+
+  // eslint-disable-next-line max-len
+  const generateResolveConflictHandler = useCallback((revisionId: string, onConflict?: (conflictData: RemoteRevisionData, newMarkdown: string) => void) => {
+    if (currentPage == null || currentPage.revision == null || shareLinkId != null) {
+      return;
+    }
+
+    return async(newMarkdown: string) => {
+      try {
+        await updatePage({
+          pageId: currentPage._id,
+          revisionId,
+          body: newMarkdown,
+          origin: Origin.View,
+        });
+
+        opts?.onSaveSuccess?.();
+        closeConflictDiffModal();
+
+        // TODO: If no user is editing in the Editor, update ydoc as well
+        // https://redmine.weseek.co.jp/issues/142109
+      }
+
+      catch (error) {
+        const conflictData = extractRemoteRevisionDataFromErrorObj(error);
+
+        if (conflictData != null) {
+          // Called if conflicts occur after resolving conflicts
+          onConflict?.(conflictData, newMarkdown);
+          return;
+        }
+
+        logger.error('failed to save', error);
+        opts?.onSaveError?.(error);
+      }
+    };
+  }, [closeConflictDiffModal, currentPage, opts, shareLinkId]);
+
+  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 || shareLinkId != null) {
       return;
@@ -54,10 +108,15 @@ export const useHandsontableModalLauncherForView = (opts?: {
       opts?.onSaveSuccess?.();
     }
     catch (error) {
+      const remoteRevidsionData = extractRemoteRevisionDataFromErrorObj(error);
+      if (remoteRevidsionData != null) {
+        onConflictHandler(remoteRevidsionData, newMarkdown);
+      }
+
       logger.error('failed to save', error);
       opts?.onSaveError?.(error);
     }
-  }, [currentPage, opts, shareLinkId]);
+  }, [currentPage, onConflictHandler, opts, shareLinkId]);
 
 
   // set handler to open HandsonTableModal

+ 22 - 0
apps/app/src/client/util/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;
+    }
+  }
+};

+ 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);
     },
   });