فهرست منبع

Merge branch 'master' into support/150088-sidebar-text-size

satof3 1 سال پیش
والد
کامیت
7702034f3e
24فایلهای تغییر یافته به همراه285 افزوده شده و 168 حذف شده
  1. 22 1
      CHANGELOG.md
  2. 1 1
      apps/app/docker/README.md
  3. 1 1
      apps/app/package.json
  4. 50 0
      apps/app/playwright/23-editor/saving.spec.ts
  5. 7 0
      apps/app/public/static/locales/en_US/translation.json
  6. 7 0
      apps/app/public/static/locales/fr_FR/translation.json
  7. 7 0
      apps/app/public/static/locales/ja_JP/translation.json
  8. 7 0
      apps/app/public/static/locales/zh_CN/translation.json
  9. 0 1
      apps/app/src/client/components/Common/Dropdown/PageItemControl.tsx
  10. 36 1
      apps/app/src/client/components/Navbar/GrowiContextualSubNavigation.tsx
  11. 10 1
      apps/app/src/client/components/ReactMarkdownComponents/DrawioViewerWithEditButton.tsx
  12. 9 1
      apps/app/src/client/components/ReactMarkdownComponents/TableWithEditButton.tsx
  13. 5 0
      apps/app/src/client/services/page-operation.ts
  14. 3 1
      apps/app/src/server/models/revision.ts
  15. 3 0
      apps/app/src/server/routes/apiv3/page/index.ts
  16. 64 0
      apps/app/src/server/routes/apiv3/page/sync-latest-revision-body-to-yjs-draft.ts
  17. 29 0
      apps/app/src/server/service/normalize-data/convert-revision-page-id-to-string.ts
  18. 2 0
      apps/app/src/server/service/normalize-data/index.ts
  19. 11 5
      apps/app/src/server/service/yjs/yjs.ts
  20. 0 144
      apps/app/test/cypress/e2e/23-editor/23-editor--saving.cy.ts
  21. 1 1
      apps/app/test/integration/service/page.test.js
  22. 8 8
      apps/app/test/integration/service/v5.non-public-page.test.ts
  23. 1 1
      apps/slackbot-proxy/package.json
  24. 1 1
      package.json

+ 22 - 1
CHANGELOG.md

@@ -1,9 +1,30 @@
 # Changelog
 
-## [Unreleased](https://github.com/weseek/growi/compare/v7.0.12...HEAD)
+## [Unreleased](https://github.com/weseek/growi/compare/v7.0.13...HEAD)
 
 *Please do not manually update this file. We've automated the process.*
 
+## [v7.0.13](https://github.com/weseek/growi/compare/v7.0.12...v7.0.13) - 2024-07-16
+
+### 💎 Features
+
+* feat: Sync latest revision body to Yjs draft (#8939) @miya
+
+### 🚀 Improvement
+
+* imprv: Better synchronizing between YDoc and the latest revision (#8959) @yuki-takei
+
+### 🐛 Bug Fixes
+
+* fix: Revision model (#8967) @yuki-takei
+* fix: Healthcheck with checkServices=mongo (#8961) @yuki-takei
+* fix: Enable  # next to headline in view (#8826) @reiji-h
+
+### 🧰 Maintenance
+
+* ci(deps): bump nodemailer from 6.6.2 to 6.9.14 (#8928) @dependabot
+* support: Update favicon (#8957) @satof3
+
 ## [v7.0.12](https://github.com/weseek/growi/compare/v7.0.11...v7.0.12) - 2024-07-10
 
 ### 🚀 Improvement

+ 1 - 1
apps/app/docker/README.md

@@ -10,7 +10,7 @@ GROWI Official docker image
 Supported tags and respective Dockerfile links
 ------------------------------------------------
 
-* [`7.0.12`, `7.0`, `7`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v7.0.12/apps/app/docker/Dockerfile)
+* [`7.0.13`, `7.0`, `7`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v7.0.13/apps/app/docker/Dockerfile)
 * [`6.3.2`, `6.3`, `6` (Dockerfile)](https://github.com/weseek/growi/blob/v6.3.2/apps/app/docker/Dockerfile)
 * [`6.2.4`, `6.2` (Dockerfile)](https://github.com/weseek/growi/blob/v6.2.4/apps/app/docker/Dockerfile)
 * [`6.1.15`, `6.1` (Dockerfile)](https://github.com/weseek/growi/blob/v6.1.15/apps/app/docker/Dockerfile)

+ 1 - 1
apps/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "7.0.13-RC.0",
+  "version": "7.0.14-RC.0",
   "license": "MIT",
   "private": "true",
   "scripts": {

+ 50 - 0
apps/app/playwright/23-editor/saving.spec.ts

@@ -0,0 +1,50 @@
+import path from 'path';
+
+import { test, expect, type Page } from '@playwright/test';
+
+const appendTextToEditorUntilContains = async(page: Page, text: string) => {
+  await page.locator('.cm-content').fill(text);
+  await expect(page.getByTestId('page-editor-preview-body')).toContainText(text);
+};
+
+
+test('Successfully create page under specific path', async({ page }) => {
+  const newPagePath = '/child';
+  const openPageCreateModalShortcutKey = 'c';
+
+  await page.goto('/Sandbox');
+
+  await page.keyboard.press(openPageCreateModalShortcutKey);
+  await expect(page.getByTestId('page-create-modal')).toBeVisible();
+  page.getByTestId('page-create-modal').locator('.rbt-input-main').fill(newPagePath);
+  page.getByTestId('btn-create-page-under-below').click();
+  await page.getByTestId('view-button').click();
+
+  const createdPageId = path.basename(page.url());
+  expect(createdPageId.length).toBe(24);
+});
+
+
+test('Successfully updating a page using a shortcut on a previously created page', async({ page }) => {
+  const body1 = 'hello';
+  const body2 = ' world!';
+  const savePageShortcutKey = 'Control+s';
+
+  await page.goto('/Sandbox/child');
+
+  // 1st
+  await page.getByTestId('editor-button').click();
+  await expect(page.getByTestId('grw-editor-navbar-bottom')).toBeVisible();
+  await appendTextToEditorUntilContains(page, body1);
+  await page.keyboard.press(savePageShortcutKey);
+  await page.getByTestId('view-button').click();
+  await expect(page.locator('.main')).toContainText(body1);
+
+  // 2nd
+  await page.getByTestId('editor-button').click();
+  await expect(page.getByTestId('grw-editor-navbar-bottom')).toBeVisible();
+  await appendTextToEditorUntilContains(page, body1 + body2);
+  await page.keyboard.press(savePageShortcutKey);
+  await page.getByTestId('view-button').click();
+  await expect(page.locator('.main')).toContainText(body1 + body2);
+});

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

@@ -9,6 +9,7 @@
   "delete_all": "Delete all",
   "Duplicate": "Duplicate",
   "PathRecovery": "Path recovery",
+  "SyncLatestRevisionBody": "Sync editor with latest body",
   "Copy": "Copy",
   "preview": "Preview",
   "desktop": "Desktop",
@@ -849,5 +850,11 @@
   },
   "create_page": {
     "untitled": "Untitled"
+  },
+  "sync-latest-reevision-body": {
+    "confirm": "Delete the draft data being entered into the editor and synchronize the latest text. Are you sure you want to run it?",
+    "alert": "The latest text may not have been synchronized. Please reload and check again.",
+    "success-toaster": "Latest text synchronized",
+    "error-toaster": "Synchronization of the latest text failed"
   }
 }

+ 7 - 0
apps/app/public/static/locales/fr_FR/translation.json

@@ -9,6 +9,7 @@
   "delete_all": "Tout supprimer",
   "Duplicate": "Dupliquer",
   "PathRecovery": "Récupération de chemin",
+  "SyncLatestRevisionBody": "Synchroniser l'éditeur avec le dernier corps",
   "Copy": "Copier",
   "preview": "Prévisualiser",
   "desktop": "Ordinateur",
@@ -840,5 +841,11 @@
     "show_wip_page": "Voir brouillon",
     "size_s": "Taille: P",
     "size_l": "Taille: G"
+  },
+  "sync-latest-reevision-body": {
+    "confirm": "Delete the draft data being entered into the editor and synchronize the latest text. Are you sure you want to run it?",
+    "alert": "Il se peut que le texte le plus récent n'ait pas été synchronisé. Veuillez recharger et vérifier à nouveau.",
+    "success-toaster": "Dernier texte synchronisé",
+    "error-toaster": "La synchronisation du dernier texte a échoué"
   }
 }

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

@@ -9,6 +9,7 @@
   "delete_all": "全て削除",
   "Duplicate": "複製",
   "PathRecovery": "パスを修復",
+  "SyncLatestRevisionBody": "エディターを最新の本文に同期",
   "Copy": "コピー",
   "preview": "プレビュー",
   "desktop": "パソコン",
@@ -882,5 +883,11 @@
   },
   "create_page": {
     "untitled": "無題のページ"
+  },
+  "sync-latest-reevision-body": {
+    "confirm": "エディターに入力中のドラフトデータを削除して最新の本文を同期します。実行しますか?",
+    "alert": "最新の本文が同期されていない可能性があります。リロードして再度ご確認ください。",
+    "success-toaster": "最新の本文を同期しました",
+    "error-toaster": "最新の本文の同期に失敗しました"
   }
 }

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

@@ -9,6 +9,7 @@
   "delete_all": "删除所有",
   "Duplicate": "复制",
   "PathRecovery": "路径恢复",
+  "SyncLatestRevisionBody": "将编辑器与最新机身同步",
   "Copy": "复制",
   "preview": "预览",
   "desktop": "电脑",
@@ -852,5 +853,11 @@
   },
   "create_page": {
     "untitled": "Untitled"
+  },
+  "sync-latest-reevision-body": {
+    "confirm": "删除输入编辑器的草稿数据,同步最新文本。 您真的想运行它吗?",
+    "alert": "最新文本可能尚未同步。 请重新加载并再次检查。",
+    "success-toaster": "同步最新文本",
+    "error-toaster": "同步最新文本失败"
   }
 }

+ 0 - 1
apps/app/src/client/components/Common/Dropdown/PageItemControl.tsx

@@ -108,7 +108,6 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
     await onClickRevertMenuItem(pageId);
   }, [onClickRevertMenuItem, pageId]);
 
-
   // eslint-disable-next-line react-hooks/rules-of-hooks
   const deleteItemClickedHandler = useCallback(async() => {
     if (pageInfo == null || onClickDeleteMenuItem == null) {

+ 36 - 1
apps/app/src/client/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -7,6 +7,8 @@ import type {
   IPageToRenameWithMeta, IPageWithMeta, IPageInfoForEntity,
 } from '@growi/core';
 import { pagePathUtils } from '@growi/core/dist/utils';
+import { GlobalCodeMirrorEditorKey } from '@growi/editor';
+import { useCodeMirrorEditorIsolated } from '@growi/editor/dist/client/stores/codemirror-editor';
 import { useTranslation } from 'next-i18next';
 import dynamic from 'next/dynamic';
 import Link from 'next/link';
@@ -14,7 +16,8 @@ import { useRouter } from 'next/router';
 import Sticky from 'react-stickynode';
 import { DropdownItem } from 'reactstrap';
 
-import { exportAsMarkdown, updateContentWidth } from '~/client/services/page-operation';
+import { exportAsMarkdown, updateContentWidth, syncLatestRevisionBody } from '~/client/services/page-operation';
+import { toastSuccess, toastError } from '~/client/util/toastr';
 import { GroundGlassBar } from '~/components/Navbar/GroundGlassBar';
 import type { OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction } from '~/interfaces/ui';
 import { useShouldExpandContent } from '~/services/layout/use-should-expand-content';
@@ -76,8 +79,40 @@ const PageOperationMenuItems = (props: PageOperationMenuItemsProps): JSX.Element
   const { open: openPresentationModal } = usePagePresentationModal();
   const { open: openAccessoriesModal } = usePageAccessoriesModal();
 
+  const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(GlobalCodeMirrorEditorKey.MAIN);
+
+  const syncLatestRevisionBodyHandler = useCallback(async() => {
+    // eslint-disable-next-line no-alert
+    const answer = window.confirm(t('sync-latest-reevision-body.confirm'));
+    if (answer) {
+      try {
+        const editingMarkdownLength = codeMirrorEditor?.getDoc().length;
+        const res = await syncLatestRevisionBody(pageId, editingMarkdownLength);
+
+        if (res?.isYjsDataBroken) {
+          // eslint-disable-next-line no-alert
+          window.alert(t('sync-latest-reevision-body.alert'));
+          return;
+        }
+
+        toastSuccess(t('sync-latest-reevision-body.success-toaster'));
+      }
+      catch {
+        toastError(t('sync-latest-reevision-body.error-toaster'));
+      }
+    }
+  }, [codeMirrorEditor, pageId, t]);
+
   return (
     <>
+      <DropdownItem
+        onClick={() => syncLatestRevisionBodyHandler()}
+        className="grw-page-control-dropdown-item"
+      >
+        <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">sync</span>
+        {t('SyncLatestRevisionBody')}
+      </DropdownItem>
+
       {/* Presentation */}
       <DropdownItem
         onClick={() => openPresentationModal()}

+ 10 - 1
apps/app/src/client/components/ReactMarkdownComponents/DrawioViewerWithEditButton.tsx

@@ -13,6 +13,7 @@ import {
   useIsGuestUser, useIsReadOnlyUser, useIsSharedUser, useShareLinkId,
 } from '~/stores-universal/context';
 import { useIsRevisionOutdated } from '~/stores/page';
+import { useCurrentPageYjsData } from '~/stores/yjs';
 
 import '@growi/remark-drawio/dist/style.css';
 import styles from './DrawioViewerWithEditButton.module.scss';
@@ -34,6 +35,7 @@ export const DrawioViewerWithEditButton = React.memo((props: DrawioViewerProps):
   const { data: isSharedUser } = useIsSharedUser();
   const { data: shareLinkId } = useShareLinkId();
   const { data: isRevisionOutdated } = useIsRevisionOutdated();
+  const { data: currentPageYjsData } = useCurrentPageYjsData();
 
   const [isRendered, setRendered] = useState(false);
   const [mxfile, setMxfile] = useState('');
@@ -57,7 +59,14 @@ export const DrawioViewerWithEditButton = React.memo((props: DrawioViewerProps):
     }
   }, []);
 
-  const showEditButton = !isRevisionOutdated && isRendered && !isGuestUser && !isReadOnlyUser && !isSharedUser && shareLinkId == null;
+  const isNoEditingUsers = currentPageYjsData?.awarenessStateSize == null || currentPageYjsData?.awarenessStateSize === 0;
+  const showEditButton = isNoEditingUsers
+     && !isRevisionOutdated
+     && isRendered
+     && !isGuestUser
+     && !isReadOnlyUser
+     && !isSharedUser
+     && shareLinkId == null;
 
   return (
     <div className={`drawio-viewer-with-edit-button ${styles['drawio-viewer-with-edit-button']}`}>

+ 9 - 1
apps/app/src/client/components/ReactMarkdownComponents/TableWithEditButton.tsx

@@ -8,6 +8,7 @@ import {
   useIsGuestUser, useIsReadOnlyUser, useIsSharedUser, useShareLinkId,
 } from '~/stores-universal/context';
 import { useIsRevisionOutdated } from '~/stores/page';
+import { useCurrentPageYjsData } from '~/stores/yjs';
 
 import styles from './TableWithEditButton.module.scss';
 
@@ -31,6 +32,7 @@ export const TableWithEditButton = React.memo((props: TableWithEditButtonProps):
   const { data: isSharedUser } = useIsSharedUser();
   const { data: shareLinkId } = useShareLinkId();
   const { data: isRevisionOutdated } = useIsRevisionOutdated();
+  const { data: currentPageYjsData } = useCurrentPageYjsData();
 
   const bol = node.position?.start.line;
   const eol = node.position?.end.line;
@@ -39,7 +41,13 @@ export const TableWithEditButton = React.memo((props: TableWithEditButtonProps):
     globalEmitter.emit('launchHandsonTableModal', bol, eol);
   }, [bol, eol]);
 
-  const showEditButton = !isRevisionOutdated && !isGuestUser && !isReadOnlyUser && !isSharedUser && shareLinkId == null;
+  const isNoEditingUsers = currentPageYjsData?.awarenessStateSize == null || currentPageYjsData?.awarenessStateSize === 0;
+  const showEditButton = isNoEditingUsers
+    && !isRevisionOutdated
+    && !isGuestUser
+    && !isReadOnlyUser
+    && !isSharedUser
+    && shareLinkId == null;
 
   return (
     <div className={`editable-with-handsontable ${styles['editable-with-handsontable']}`}>

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

@@ -174,3 +174,8 @@ export const unpublish = async(pageId: string): Promise<IPageHasId> => {
   const res = await apiv3Put(`/page/${pageId}/unpublish`);
   return res.data;
 };
+
+export const syncLatestRevisionBody = async(pageId: string, editingMarkdownLength?: number): Promise<{ isYjsDataBroken?: boolean } | void> => {
+  const res = await apiv3Put(`/page/${pageId}/sync-latest-revision-body-to-yjs-draft`, { editingMarkdownLength });
+  return res.data;
+};

+ 3 - 1
apps/app/src/server/models/revision.ts

@@ -34,8 +34,10 @@ export interface IRevisionModel extends Model<IRevisionDocument> {
 Schema.Types.String.checkRequired(v => typeof v === 'string');
 
 const revisionSchema = new Schema<IRevisionDocument, IRevisionModel>({
+  // The type of pageId is always converted to String at server startup
+  // Refer to this method (/src/server/service/normalize-data/convert-revision-page-id-to-string.ts) to change the pageId type
   pageId: {
-    type: Types.ObjectId, ref: 'Page', required: true, index: true,
+    type: String, required: true, index: true,
   },
   body: {
     type: String,

+ 3 - 0
apps/app/src/server/routes/apiv3/page/index.ts

@@ -31,6 +31,7 @@ import { checkPageExistenceHandlersFactory } from './check-page-existence';
 import { createPageHandlersFactory } from './create-page';
 import { getYjsDataHandlerFactory } from './get-yjs-data';
 import { publishPageHandlersFactory } from './publish-page';
+import { syncLatestRevisionBodyToYjsDraftHandlerFactory } from './sync-latest-revision-body-to-yjs-draft';
 import { unpublishPageHandlersFactory } from './unpublish-page';
 import { updatePageHandlersFactory } from './update-page';
 
@@ -953,5 +954,7 @@ module.exports = (crowi) => {
 
   router.get('/:pageId/yjs-data', getYjsDataHandlerFactory(crowi));
 
+  router.put('/:pageId/sync-latest-revision-body-to-yjs-draft', syncLatestRevisionBodyToYjsDraftHandlerFactory(crowi));
+
   return router;
 };

+ 64 - 0
apps/app/src/server/routes/apiv3/page/sync-latest-revision-body-to-yjs-draft.ts

@@ -0,0 +1,64 @@
+import type { IPage, IUserHasId } from '@growi/core';
+import { ErrorV3 } from '@growi/core/dist/models';
+import type { Request, RequestHandler } from 'express';
+import type { ValidationChain } from 'express-validator';
+import { param, body } from 'express-validator';
+import mongoose from 'mongoose';
+
+import type Crowi from '~/server/crowi';
+import type { PageModel } from '~/server/models/page';
+import { getYjsService } from '~/server/service/yjs';
+import loggerFactory from '~/utils/logger';
+
+import { apiV3FormValidator } from '../../../middlewares/apiv3-form-validator';
+import type { ApiV3Response } from '../interfaces/apiv3-response';
+
+
+const logger = loggerFactory('growi:routes:apiv3:page:sync-latest-revision-body-to-yjs-draft');
+
+type SyncLatestRevisionBodyToYjsDraftHandlerFactory = (crowi: Crowi) => RequestHandler[];
+
+type ReqParams = {
+  pageId: string,
+}
+type ReqBody = {
+  editingMarkdownLength?: number,
+}
+interface Req extends Request<ReqParams, ApiV3Response, ReqBody> {
+  user: IUserHasId,
+}
+export const syncLatestRevisionBodyToYjsDraftHandlerFactory: SyncLatestRevisionBodyToYjsDraftHandlerFactory = (crowi) => {
+  const Page = mongoose.model<IPage, PageModel>('Page');
+  const accessTokenParser = require('../../../middlewares/access-token-parser')(crowi);
+  const loginRequiredStrictly = require('../../../middlewares/login-required')(crowi);
+
+  // define validators for req.params
+  const validator: ValidationChain[] = [
+    param('pageId').isMongoId().withMessage('The param "pageId" must be specified'),
+    body('editingMarkdownLength').optional().isInt().withMessage('The body "editingMarkdownLength" must be integer'),
+  ];
+
+  return [
+    accessTokenParser, loginRequiredStrictly,
+    validator, apiV3FormValidator,
+    async(req: Req, res: ApiV3Response) => {
+      const { pageId } = req.params;
+      const { editingMarkdownLength } = req.body;
+
+      // check whether accessible
+      if (!(await Page.isAccessiblePageByViewer(pageId, req.user))) {
+        return res.apiv3Err(new ErrorV3('Current user is not accessible to this page.', 'forbidden-page'), 403);
+      }
+
+      try {
+        const yjsService = getYjsService();
+        const result = await yjsService.syncWithTheLatestRevisionForce(pageId, editingMarkdownLength);
+        return res.apiv3(result);
+      }
+      catch (err) {
+        logger.error(err);
+        return res.apiv3Err(err);
+      }
+    },
+  ];
+};

+ 29 - 0
apps/app/src/server/service/normalize-data/convert-revision-page-id-to-string.ts

@@ -0,0 +1,29 @@
+// see: https://redmine.weseek.co.jp/issues/150649
+
+import { type IRevisionHasId } from '@growi/core';
+import mongoose from 'mongoose';
+
+import { type IRevisionModel } from '~/server/models/revision';
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:service:NormalizeData:convert-revision-page-id-to-string');
+
+export const convertRevisionPageIdToString = async(): Promise<void> => {
+  const Revision = mongoose.model<IRevisionHasId, IRevisionModel>('Revision');
+
+  const filter = { pageId: { $type: 'objectId' } };
+  const update = [
+    {
+      $set: {
+        pageId: {
+          $toString: '$pageId',
+        },
+      },
+    },
+  ];
+
+  await Revision.updateMany(filter, update);
+
+  const explain = await Revision.updateMany(filter, update).explain('queryPlanner');
+  logger.debug(explain);
+};

+ 2 - 0
apps/app/src/server/service/normalize-data/index.ts

@@ -1,11 +1,13 @@
 import loggerFactory from '~/utils/logger';
 
+import { convertRevisionPageIdToString } from './convert-revision-page-id-to-string';
 import { renameDuplicateRootPages } from './rename-duplicate-root-pages';
 
 const logger = loggerFactory('growi:service:NormalizeData');
 
 export const normalizeData = async(): Promise<void> => {
   await renameDuplicateRootPages();
+  await convertRevisionPageIdToString();
 
   logger.info('normalizeData has been executed');
   return;

+ 11 - 5
apps/app/src/server/service/yjs/yjs.ts

@@ -32,7 +32,7 @@ type RequestWithUser = IncomingMessage & { user: IUserHasId };
 
 export interface IYjsService {
   getYDocStatus(pageId: string): Promise<YDocStatus>;
-  syncWithTheLatestRevisionForce(pageId: string): Promise<void>;
+  syncWithTheLatestRevisionForce(pageId: string, editingMarkdownLength?: number): Promise<{ isYjsDataBroken?: boolean } | void>
   getCurrentYdoc(pageId: string): Ydoc | undefined;
 }
 
@@ -72,8 +72,9 @@ class YjsService implements IYjsService {
     // create indexes
     createIndexes(MONGODB_PERSISTENCE_COLLECTION_NAME);
 
+    // TODO: https://redmine.weseek.co.jp/issues/150529
     // register middlewares
-    this.registerAccessiblePageChecker(ysocketio);
+    // this.registerAccessiblePageChecker(ysocketio);
 
     ysocketio.on('document-loaded', async(doc: Document) => {
       const pageId = doc.name;
@@ -135,7 +136,7 @@ class YjsService implements IYjsService {
   }
 
   public async getYDocStatus(pageId: string): Promise<YDocStatus> {
-    const dumpLog = (status: YDocStatus, args?: { [key: string]: number }) => {
+    const dumpLog = (status: YDocStatus, args?: { [key: string]: unknown }) => {
       logger.debug(`getYDocStatus('${pageId}') detected '${status}'`, args ?? {});
     };
 
@@ -151,7 +152,7 @@ class YjsService implements IYjsService {
       .lean();
 
     if (result == null) {
-      dumpLog(YDocStatus.ISOLATED);
+      dumpLog(YDocStatus.ISOLATED, { result });
       return YDocStatus.ISOLATED;
     }
 
@@ -180,14 +181,19 @@ class YjsService implements IYjsService {
     return YDocStatus.OUTDATED;
   }
 
-  public async syncWithTheLatestRevisionForce(pageId: string): Promise<void> {
+  public async syncWithTheLatestRevisionForce(pageId: string, editingMarkdownLength?: number): Promise<{ isYjsDataBroken?: boolean } | void> {
     const doc = this.ysocketio.documents.get(pageId);
 
     if (doc == null) {
       return;
     }
 
+    const ytextLength = doc?.getText('codemirror').length;
     syncYDoc(this.mdb, doc, true);
+
+    if (editingMarkdownLength != null) {
+      return { isYjsDataBroken: editingMarkdownLength !== ytextLength };
+    }
   }
 
   public getCurrentYdoc(pageId: string): Ydoc | undefined {

+ 0 - 144
apps/app/test/cypress/e2e/23-editor/23-editor--saving.cy.ts

@@ -1,144 +0,0 @@
-context('PageCreateButton', () => {
-
-  const ssPrefix = 'page-create-modal-';
-
-  beforeEach(() => {
-    // login
-    cy.fixture("user-admin.json").then(user => {
-      cy.login(user.username, user.password);
-    });
-  });
-
-  it.skip('Successfully create page under specific path', () => {
-    const pageName = 'child';
-
-    cy.visit('/foo/bar');
-    cy.collapseSidebar(true);
-
-    cy.waitUntil(() => {
-      // do
-      cy.getByTestid('newPageBtn').click({force: true});
-      // wait until
-      return cy.get('body').within(() => {
-        return Cypress.$('[data-testid=page-create-modal]').is(':visible');
-      });
-    });
-
-    cy.getByTestid('page-create-modal').should('be.visible').within(() => {
-      cy.get('.rbt-input-main').should('have.value', '/foo/bar/');
-      cy.get('.rbt-input-main').type(pageName);
-      cy.screenshot(`${ssPrefix}under-path-add-page-name`);
-      cy.getByTestid('btn-create-page-under-below').click();
-    });
-
-    cy.getByTestid('page-editor').should('be.visible');
-    cy.getByTestid('save-page-btn').as('save-page-btn').should('be.visible');
-    cy.waitUntil(() => {
-      // do
-      cy.get('@save-page-btn').click();
-      // wait until
-      return cy.get('@save-page-btn').then($elem => $elem.is(':disabled'));
-    });
-    cy.get('.layout-root').should('not.have.class', 'editing');
-
-    cy.getByTestid('grw-contextual-sub-nav').should('be.visible');
-
-    cy.waitUntilSkeletonDisappear();
-    cy.screenshot(`${ssPrefix}create-page-under-specific-page`);
-  });
-
-  it.skip('Trying to create template page under the root page fail', () => {
-    cy.visit('/');
-    cy.collapseSidebar(true);
-
-    cy.waitUntil(() => {
-      // do
-      cy.getByTestid('newPageBtn').click({force: true});
-      // wait until
-      return cy.getByTestid('page-create-modal').then($elem => $elem.is(':visible'));
-    });
-
-    cy.getByTestid('page-create-modal').should('be.visible').within(() => {
-      cy.getByTestid('grw-page-create-modal-path-name').should('have.text', '/');
-
-      cy.get('#template-type').click();
-      cy.get('#template-type').next().find('button:eq(0)').click({force: true});
-      cy.getByTestid('grw-btn-edit-page').should('be.visible').click();
-    });
-    cy.get('.Toastify__toast').should('be.visible');
-
-    cy.screenshot(`${ssPrefix}create-template-for-children-error`);
-
-    cy.get('.Toastify__toast').should('be.visible').within(() => {
-      cy.get('.Toastify__close-button').should('be.visible').click();
-      cy.get('.Toastify__progress-bar').invoke('attr', 'style', 'display: none')
-    });
-
-    cy.getByTestid('page-create-modal').should('be.visible').within(() => {
-      cy.get('#template-type').click();
-      cy.get('#template-type').next().find('button:eq(1)').click({force: true});
-      cy.getByTestid('grw-btn-edit-page').should('be.visible').click();
-    });
-    cy.get('.Toastify__toast').should('be.visible');
-    cy.screenshot(`${ssPrefix}create-template-for-descendants-error`);
-  });
-
-});
-
-
-context.skip('Shortcuts', () => {
-  const ssPrefix = 'shortcuts';
-
-  beforeEach(() => {
-    // login
-    cy.fixture("user-admin.json").then(user => {
-      cy.login(user.username, user.password);
-    });
-  });
-
-  it('Successfully updating a page using a shortcut on a previously created page', { scrollBehavior: false }, () => {
-    const body1 = 'hello';
-    const body2 = ' world!';
-    const savePageShortcutKey = '{ctrl+s}';
-
-    cy.visit('/Sandbox/child');
-
-    cy.get('#grw-page-editor-mode-manager').as('pageEditorModeManager').should('be.visible');
-    cy.waitUntil(() => {
-      // do
-      cy.get('@pageEditorModeManager').within(() => {
-        cy.get('button:nth-child(2)').click();
-      });
-      // until
-      return cy.get('.layout-root').then($elem => $elem.hasClass('editing'));
-    })
-
-    cy.get('.grw-editor-navbar-bottom').should('be.visible');
-
-    // 1st
-    cy.get('.CodeMirror').type(body1);
-    cy.get('.CodeMirror').contains(body1);
-    cy.get('.page-editor-preview-body').contains(body1);
-    cy.get('.CodeMirror').type(savePageShortcutKey);
-
-    cy.get('.Toastify__toast').should('be.visible').within(() => {
-      cy.get('.Toastify__close-button').should('be.visible').click();
-      cy.get('.Toastify__progress-bar').invoke('attr', 'style', 'display: none')
-    });
-    cy.screenshot(`${ssPrefix}-update-page-1`);
-
-    cy.get('.Toastify').should('not.be.visible');
-
-    // 2nd
-    cy.get('.CodeMirror').type(body2);
-    cy.get('.CodeMirror').contains(body2);
-    cy.get('.page-editor-preview-body').contains(body2);
-    cy.get('.CodeMirror').type(savePageShortcutKey);
-
-    cy.get('.Toastify__toast').should('be.visible').within(() => {
-      cy.get('.Toastify__close-button').should('be.visible').click();
-      cy.get('.Toastify__progress-bar').invoke('attr', 'style', 'display: none')
-    });
-    cy.screenshot(`${ssPrefix}-update-page-2`);
-  });
-});

+ 1 - 1
apps/app/test/integration/service/page.test.js

@@ -671,7 +671,7 @@ describe('PageService', () => {
       expect(insertedPage.lastUpdateUser).toEqual(testUser2._id);
 
       expect([insertedRevision]).not.toBeNull();
-      expect(insertedRevision.pageId).toEqual(insertedPage._id);
+      expect(insertedRevision.pageId).toEqual(insertedPage._id.toString());
       expect(insertedRevision._id).not.toEqual(childForDuplicateRevision._id);
       expect(insertedRevision.body).toEqual(childForDuplicateRevision.body);
 

+ 8 - 8
apps/app/test/integration/service/v5.non-public-page.test.ts

@@ -1289,8 +1289,8 @@ describe('PageService page operations with non-public pages', () => {
       expect(duplicatedPage2.parent).toStrictEqual(duplicatedPage1._id);
       expect(duplicatedRevision1.body).toBe(_revision1.body);
       expect(duplicatedRevision2.body).toBe(_revision2.body);
-      expect(duplicatedRevision1.pageId).toStrictEqual(duplicatedPage1._id);
-      expect(duplicatedRevision2.pageId).toStrictEqual(duplicatedPage2._id);
+      expect(duplicatedRevision1.pageId).toStrictEqual(duplicatedPage1._id.toString());
+      expect(duplicatedRevision2.pageId).toStrictEqual(duplicatedPage2._id.toString());
     });
     test('Should duplicate multiple pages. Page with GRANT_RESTRICTED should NOT be duplicated', async() => {
       const _path1 = '/np_duplicate4';
@@ -1329,8 +1329,8 @@ describe('PageService page operations with non-public pages', () => {
       expect(duplicatedPage3.parent).toStrictEqual(duplicatedPage1._id);
       expect(duplicatedRevision1.body).toBe(baseRevision1.body);
       expect(duplicatedRevision3.body).toBe(baseRevision3.body);
-      expect(duplicatedRevision1.pageId).toStrictEqual(duplicatedPage1._id);
-      expect(duplicatedRevision3.pageId).toStrictEqual(duplicatedPage3._id);
+      expect(duplicatedRevision1.pageId).toStrictEqual(duplicatedPage1._id.toString());
+      expect(duplicatedRevision3.pageId).toStrictEqual(duplicatedPage3._id.toString());
     });
     test('Should duplicate only user related pages and granted groups when onlyDuplicateUserRelatedResources is true', async() => {
       const _path1 = '/np_duplicate7';
@@ -1364,7 +1364,7 @@ describe('PageService page operations with non-public pages', () => {
       ]);
       expect(duplicatedPage1.parent).toStrictEqual(_page1.parent);
       expect(duplicatedRevision1.body).toBe(_revision1.body);
-      expect(duplicatedRevision1.pageId).toStrictEqual(duplicatedPage1._id);
+      expect(duplicatedRevision1.pageId).toStrictEqual(duplicatedPage1._id.toString());
     });
     test('Should duplicate all pages and granted groups when onlyDuplicateUserRelatedResources is false', async() => {
       const _path1 = '/np_duplicate7';
@@ -1410,18 +1410,18 @@ describe('PageService page operations with non-public pages', () => {
       ]);
       expect(duplicatedPage1.parent).toStrictEqual(_page1.parent);
       expect(duplicatedRevision1.body).toBe(_revision1.body);
-      expect(duplicatedRevision1.pageId).toStrictEqual(duplicatedPage1._id);
+      expect(duplicatedRevision1.pageId).toStrictEqual(duplicatedPage1._id.toString());
       expect(normalizeGrantedGroups(duplicatedPage2.grantedGroups)).toStrictEqual([
         { item: groupIdC, type: GroupType.userGroup },
         { item: externalGroupIdC, type: GroupType.externalUserGroup },
       ]);
       expect(duplicatedPage2.parent).toStrictEqual(duplicatedPage1._id);
       expect(duplicatedRevision2.body).toBe(_revision2.body);
-      expect(duplicatedRevision2.pageId).toStrictEqual(duplicatedPage2._id);
+      expect(duplicatedRevision2.pageId).toStrictEqual(duplicatedPage2._id.toString());
       expect(duplicatedPage3.grantedUsers).toStrictEqual([npDummyUser2._id]);
       expect(duplicatedPage3.parent).toStrictEqual(duplicatedPage1._id);
       expect(duplicatedRevision3.body).toBe(_revision3.body);
-      expect(duplicatedRevision3.pageId).toStrictEqual(duplicatedPage3._id);
+      expect(duplicatedRevision3.pageId).toStrictEqual(duplicatedPage3._id.toString());
     });
 
   });

+ 1 - 1
apps/slackbot-proxy/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/slackbot-proxy",
-  "version": "7.0.13-slackbot-proxy.0",
+  "version": "7.0.14-slackbot-proxy.0",
   "license": "MIT",
   "private": "true",
   "scripts": {

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "7.0.13-RC.0",
+  "version": "7.0.14-RC.0",
   "description": "Team collaboration software using markdown",
   "license": "MIT",
   "private": "true",