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

Merge pull request #8989 from weseek/master

Release v7.0.16
Yuki Takei 1 год назад
Родитель
Сommit
80c5fd3fa0
41 измененных файлов с 484 добавлено и 276 удалено
  1. 1 1
      .github/workflows/reusable-app-prod.yml
  2. 1 1
      apps/app/package.json
  3. 0 0
      apps/app/playwright/23-editor/assets/example.txt
  4. 113 0
      apps/app/playwright/23-editor/with-navigation.spec.ts
  5. 2 2
      apps/app/resource/locales/ja_JP/welcome.md
  6. 20 0
      apps/app/src/client/components/Admin/UserGroup/UserGroupTable.module.scss
  7. 23 6
      apps/app/src/client/components/Admin/UserGroup/UserGroupTable.tsx
  8. 1 1
      apps/app/src/client/components/InstallerForm.tsx
  9. 9 5
      apps/app/src/client/components/PageComment/CommentEditor.tsx
  10. 10 6
      apps/app/src/client/components/PageEditor/PageEditor.tsx
  11. 1 1
      apps/app/src/client/components/ReactMarkdownComponents/RichAttachment.tsx
  12. 1 1
      apps/app/src/client/components/Sidebar/Bookmarks.tsx
  13. 2 2
      apps/app/src/client/components/Sidebar/Custom/CustomSidebar.tsx
  14. 2 2
      apps/app/src/client/components/Sidebar/InAppNotification/InAppNotification.tsx
  15. 1 1
      apps/app/src/client/components/Sidebar/PageTree/PageTree.tsx
  16. 1 1
      apps/app/src/client/components/Sidebar/RecentChanges/RecentChanges.tsx
  17. 1 1
      apps/app/src/client/components/Sidebar/Sidebar.module.scss
  18. 1 1
      apps/app/src/client/components/Sidebar/SidebarNav/SecondaryItems.tsx
  19. 2 2
      apps/app/src/client/components/Sidebar/Tag.tsx
  20. 1 1
      apps/app/src/components/PageView/PageAlerts/PageGrantAlert.tsx
  21. 2 2
      apps/app/src/migrations/20211227060705-revision-path-to-page-id-schema-migration--fixed-7549.js
  22. 1 1
      apps/app/src/server/routes/apiv3/customize-setting.js
  23. 25 7
      apps/app/src/server/routes/apiv3/page/index.ts
  24. 11 0
      apps/app/src/server/routes/apiv3/page/update-page.ts
  25. 9 0
      apps/app/src/server/routes/apiv3/revisions.js
  26. 3 2
      apps/app/src/server/service/page-grant.ts
  27. 15 10
      apps/app/src/server/service/passport.ts
  28. 124 0
      apps/app/src/server/service/revision/normalize-latest-revision-if-broken.integ.ts
  29. 38 0
      apps/app/src/server/service/revision/normalize-latest-revision-if-broken.ts
  30. 4 0
      apps/app/src/server/service/yjs/sync-ydoc.ts
  31. 3 0
      apps/app/src/server/service/yjs/yjs.integ.ts
  32. 4 0
      apps/app/src/server/service/yjs/yjs.ts
  33. 1 1
      apps/app/src/styles/_fonts.scss
  34. 0 175
      apps/app/test/cypress/e2e/23-editor/23-editor--with-navigation.cy.ts
  35. 1 1
      apps/slackbot-proxy/package.json
  36. 1 1
      package.json
  37. 5 7
      packages/editor/src/client/components-internal/CodeMirrorEditor/CodeMirrorEditor.tsx
  38. 7 2
      packages/editor/src/client/components-internal/playground/Playground.tsx
  39. 15 2
      packages/editor/src/client/components/CodeMirrorEditorMain.tsx
  40. 18 22
      packages/editor/src/client/services/use-codemirror-editor/use-codemirror-editor.ts
  41. 4 8
      packages/editor/src/client/stores/codemirror-editor.ts

+ 1 - 1
.github/workflows/reusable-app-prod.yml

@@ -213,7 +213,7 @@ jobs:
       fail-fast: false
       fail-fast: false
       matrix:
       matrix:
         # List string expressions that is comma separated ids of tests in "test/cypress/integration"
         # List string expressions that is comma separated ids of tests in "test/cypress/integration"
-        spec-group: ['21', '23', '30', '50']
+        spec-group: ['21', '30', '50']
 
 
     services:
     services:
       mongodb:
       mongodb:

+ 1 - 1
apps/app/package.json

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

+ 0 - 0
apps/app/test/cypress/e2e/23-editor/assets/example.txt → apps/app/playwright/23-editor/assets/example.txt


+ 113 - 0
apps/app/playwright/23-editor/with-navigation.spec.ts

@@ -0,0 +1,113 @@
+import { readFileSync } from 'fs';
+import path from 'path';
+
+import { test, expect, type Page } from '@playwright/test';
+
+/**
+ * for the issues:
+ * @see https://redmine.weseek.co.jp/issues/122040
+ * @see https://redmine.weseek.co.jp/issues/124281
+ */
+test('should not be cleared and should prevent GrantSelector from modified', async({ page }) => {
+  await page.goto('/Sandbox/for-122040');
+
+  // Open Editor
+  await page.getByTestId('editor-button').click();
+  await expect(page.getByTestId('grw-editor-navbar-bottom')).toBeVisible();
+
+  // Open GrantSelector and select "only me"
+  await page.getByTestId('grw-grant-selector').click();
+  const dropdownMenu = page.getByTestId('grw-grant-selector-dropdown-menu');
+  await expect(dropdownMenu).toBeVisible();
+  await dropdownMenu.locator('.dropdown-item').nth(2).click();
+  await expect(page.getByTestId('grw-grant-selector')).toContainText('Only me');
+
+  // Upload attachment
+  const filePath = path.resolve(__dirname, '../23-editor/assets/example.txt');
+  const buffer = readFileSync(filePath).toString('base64');
+  const dataTransfer = await page.evaluateHandle(
+    async({ bufferData, localFileName, localFileType }) => {
+      const dt = new DataTransfer();
+
+      const blobData = await fetch(bufferData).then(res => res.blob());
+
+      const file = new File([blobData], localFileName, {
+        type: localFileType,
+      });
+      dt.items.add(file);
+      return dt;
+    },
+    {
+      bufferData: `data:application/octet-stream;base64,${buffer}`,
+      localFileName: 'sample.tst',
+      localFileType: 'application/octet-stream',
+    },
+  );
+  await page.locator('.dropzone').first().dispatchEvent('drop', { dataTransfer });
+  await expect(page.getByTestId('page-editor-preview-body').getByTestId('rich-attachment')).toBeVisible();
+
+  // Save page
+  await page.getByTestId('save-page-btn').click();
+
+  // Expect grant not to be reset after uploading an attachment
+  await expect(page.getByTestId('page-grant-alert')).toContainText('Browsing of this page is restricted');
+});
+
+const appendTextToEditorUntilContains = async(page: Page, text: string) => {
+  await page.locator('.cm-content').fill(text);
+  await expect(page.getByTestId('page-editor-preview-body')).toContainText(text);
+};
+
+/**
+ * for the issue:
+ * @see https://redmine.weseek.co.jp/issues/115285
+ */
+test('Successfully updating the page body', async({ page }) => {
+  const page1Path = '/Sandbox/for-115285/page1';
+  const page2Path = '/Sandbox/for-115285/page2';
+
+  const page1Body = 'Hello';
+  const page2Body = 'World';
+
+
+  await page.goto(page1Path);
+
+  // Open Editor (page1)
+  await page.getByTestId('editor-button').click();
+  await expect(page.getByTestId('grw-editor-navbar-bottom')).toBeVisible();
+
+  // Append text
+  await appendTextToEditorUntilContains(page, page1Body);
+
+  // Save page
+  await page.getByTestId('save-page-btn').click();
+
+  await expect(page.locator('.main')).toContainText(page1Body);
+
+  // Duplicate page1
+  await page.getByTestId('grw-contextual-sub-nav').getByTestId('open-page-item-control-btn').click();
+  await page.getByTestId('open-page-duplicate-modal-btn').click();
+  await expect(page.getByTestId('page-duplicate-modal')).toBeVisible();
+  await page.locator('.form-control').fill(page2Path);
+  await page.getByTestId('btn-duplicate').click();
+
+  // Open Editor (page2)
+  await page.getByTestId('editor-button').click();
+  await expect(page.getByTestId('grw-editor-navbar-bottom')).toBeVisible();
+
+  // Expect to see the text from which you are duplicating
+  await expect(page.getByTestId('page-editor-preview-body')).toContainText(page1Body);
+
+  // Append text
+  await appendTextToEditorUntilContains(page, page1Body + page2Body);
+
+
+  await page.goto(page1Path);
+
+  // Open Editor (page1)
+  await page.getByTestId('editor-button').click();
+  await expect(page.getByTestId('grw-editor-navbar-bottom')).toBeVisible();
+
+  await expect(page.getByTestId('page-editor-preview-body')).toContainText(page1Body);
+
+});

+ 2 - 2
apps/app/resource/locales/ja_JP/welcome.md

@@ -43,8 +43,8 @@ GROWI は法人・個人向けの wiki | ナレッジベースツールです。
     - [GROWI に新しいメンバーを追加・招待する](https://docs.growi.org/ja/admin-guide/management-cookbook/user-management.html#%E6%96%B0%E8%A6%8F%E3%83%A6%E3%83%BC%E3%82%B6%E3%83%BC%E3%81%AE%E4%BB%AE%E7%99%BA%E8%A1%8C)
     - [GROWI に新しいメンバーを追加・招待する](https://docs.growi.org/ja/admin-guide/management-cookbook/user-management.html#%E6%96%B0%E8%A6%8F%E3%83%A6%E3%83%BC%E3%82%B6%E3%83%BC%E3%81%AE%E4%BB%AE%E7%99%BA%E8%A1%8C)
 
 
 ### :arrow_right: GROWI の見た目はこのままで満足ですか?
 ### :arrow_right: GROWI の見た目はこのままで満足ですか?
-- :heavy_check_mark: GROWI の見た目をカスタマイズしましょう!
-    - [GROWI のテーマをカスタマイズする](/admin/customize)
+- :heavy_check_mark: GROWI の見た目をカスタマイズしましょう!
+    - [GROWI のテーマをカスタマイズする](/admin/customize)
 
 
 ### :arrow_right: GROWI のセキュリティ設定は完了していますか?
 ### :arrow_right: GROWI のセキュリティ設定は完了していますか?
 - :heavy_check_mark: GROWI のセキュリティ設定を更新しましょう!
 - :heavy_check_mark: GROWI のセキュリティ設定を更新しましょう!

+ 20 - 0
apps/app/src/client/components/Admin/UserGroup/UserGroupTable.module.scss

@@ -0,0 +1,20 @@
+.user-group-edit-link {
+  text-decoration: underline;
+}
+
+// switch visibility of the edit icon
+.user-group-edit-link {
+  :global {
+    .grw-edit-icon {
+      visibility: hidden;
+    }
+  }
+
+  &:global {
+    &:hover {
+      .grw-edit-icon {
+        visibility: visible;
+      }
+    }
+  }
+}

+ 23 - 6
apps/app/src/client/components/Admin/UserGroup/UserGroupTable.tsx

@@ -9,6 +9,11 @@ import Link from 'next/link';
 import type { IExternalUserGroupHasId } from '~/features/external-user-group/interfaces/external-user-group';
 import type { IExternalUserGroupHasId } from '~/features/external-user-group/interfaces/external-user-group';
 
 
 
 
+import styles from './UserGroupTable.module.scss';
+
+const userGroupEditLinkStyle = styles['user-group-edit-link'] ?? '';
+
+
 type Props = {
 type Props = {
   headerLabel?: string,
   headerLabel?: string,
   userGroups: IUserGroupHasId[],
   userGroups: IUserGroupHasId[],
@@ -54,6 +59,23 @@ const generateGroupIdToChildGroupsMap = (childUserGroups: IUserGroupHasId[]): Re
   return map;
   return map;
 };
 };
 
 
+type UserGroupEditLinkProps = {
+  group:IUserGroupHasId,
+  isExternalGroup:boolean,
+}
+
+const UserGroupEditLink = (props: UserGroupEditLinkProps): JSX.Element => {
+  return (
+    <Link
+      href={`/admin/user-group-detail/${props.group._id}?isExternalGroup=${props.isExternalGroup}`}
+      className={`${userGroupEditLinkStyle} link-secondary link-offset-2 link-underline-opacity-25 link-underline-opacity-100-hover`}
+    >
+      <span className="material-symbols-outlined pe-2 pt-2">group</span>
+      <span>{props.group.name}</span>
+      <span className="grw-edit-icon material-symbols-outlined px-2 py-0">edit</span>
+    </Link>
+  );
+};
 
 
 export const UserGroupTable: FC<Props> = ({
 export const UserGroupTable: FC<Props> = ({
   headerLabel,
   headerLabel,
@@ -163,12 +185,7 @@ export const UserGroupTable: FC<Props> = ({
                 {isAclEnabled
                 {isAclEnabled
                   ? (
                   ? (
                     <td>
                     <td>
-                      <Link
-                        className="link-opacity-75-hover"
-                        href={`/admin/user-group-detail/${group._id}?isExternalGroup=${isExternalGroup}`}
-                      >
-                        {group.name}
-                      </Link>
+                      <UserGroupEditLink group={group} isExternalGroup={isExternalGroup} />
                     </td>
                     </td>
                   )
                   )
                   : (
                   : (

+ 1 - 1
apps/app/src/client/components/InstallerForm.tsx

@@ -251,7 +251,7 @@ const InstallerForm = memo((props: Props): JSX.Element => {
                   <span className="material-symbols-outlined">person_add</span>
                   <span className="material-symbols-outlined">person_add</span>
                 )}
                 )}
               </span>
               </span>
-              <label className="flex-grow-1">{ t('Create') }</label>
+              <span className="flex-grow-1">{ t('Create') }</span>
             </button>
             </button>
           </div>
           </div>
 
 

+ 9 - 5
apps/app/src/client/components/PageComment/CommentEditor.tsx

@@ -9,6 +9,7 @@ import { CodeMirrorEditorComment } from '@growi/editor/dist/client/components/Co
 import { useCodeMirrorEditorIsolated } from '@growi/editor/dist/client/stores/codemirror-editor';
 import { useCodeMirrorEditorIsolated } from '@growi/editor/dist/client/stores/codemirror-editor';
 import { useResolvedThemeForEditor } from '@growi/editor/dist/client/stores/use-resolved-theme';
 import { useResolvedThemeForEditor } from '@growi/editor/dist/client/stores/use-resolved-theme';
 import { UserPicture } from '@growi/ui/dist/components';
 import { UserPicture } from '@growi/ui/dist/components';
+import type { ReactCodeMirrorProps } from '@uiw/react-codemirror';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import dynamic from 'next/dynamic';
 import dynamic from 'next/dynamic';
 import {
 import {
@@ -208,10 +209,13 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
     });
     });
   }, [codeMirrorEditor, pageId]);
   }, [codeMirrorEditor, pageId]);
 
 
-  const onChangeHandler = useCallback(async(value: string) => {
-    const dirtyNum = await evaluateEditorDirtyMap(editorKey, value);
-    mutateIsEnabledUnsavedWarning(dirtyNum > 0);
-  }, [editorKey, evaluateEditorDirtyMap, mutateIsEnabledUnsavedWarning]);
+  const cmProps = useMemo<ReactCodeMirrorProps>(() => ({
+    onChange: async(value: string) => {
+      const dirtyNum = await evaluateEditorDirtyMap(editorKey, value);
+      mutateIsEnabledUnsavedWarning(dirtyNum > 0);
+    },
+  }), [editorKey, evaluateEditorDirtyMap, mutateIsEnabledUnsavedWarning]);
+
 
 
   // initialize CodeMirrorEditor
   // initialize CodeMirrorEditor
   useEffect(() => {
   useEffect(() => {
@@ -260,10 +264,10 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
             <CodeMirrorEditorComment
             <CodeMirrorEditorComment
               editorKey={editorKey}
               editorKey={editorKey}
               acceptedUploadFileType={acceptedUploadFileType}
               acceptedUploadFileType={acceptedUploadFileType}
-              onChange={onChangeHandler}
               onSave={postCommentHandler}
               onSave={postCommentHandler}
               onUpload={uploadHandler}
               onUpload={uploadHandler}
               editorSettings={editorSettings}
               editorSettings={editorSettings}
+              cmProps={cmProps}
             />
             />
           </TabPane>
           </TabPane>
           <TabPane tabId="comment_preview">
           <TabPane tabId="comment_preview">

+ 10 - 6
apps/app/src/client/components/PageEditor/PageEditor.tsx

@@ -3,7 +3,6 @@ import React, {
   useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState,
   useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState,
 } from 'react';
 } from 'react';
 
 
-
 import type EventEmitter from 'events';
 import type EventEmitter from 'events';
 import nodePath from 'path';
 import nodePath from 'path';
 
 
@@ -14,6 +13,7 @@ import { CodeMirrorEditorMain } from '@growi/editor/dist/client/components/CodeM
 import { useCodeMirrorEditorIsolated } from '@growi/editor/dist/client/stores/codemirror-editor';
 import { useCodeMirrorEditorIsolated } from '@growi/editor/dist/client/stores/codemirror-editor';
 import { useResolvedThemeForEditor } from '@growi/editor/dist/client/stores/use-resolved-theme';
 import { useResolvedThemeForEditor } from '@growi/editor/dist/client/stores/use-resolved-theme';
 import { useRect } from '@growi/ui/dist/utils';
 import { useRect } from '@growi/ui/dist/utils';
+import type { ReactCodeMirrorProps } from '@uiw/react-codemirror';
 import detectIndent from 'detect-indent';
 import detectIndent from 'detect-indent';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import { throttle, debounce } from 'throttle-debounce';
 import { throttle, debounce } from 'throttle-debounce';
@@ -159,10 +159,6 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
     setMarkdownToPreview(value);
     setMarkdownToPreview(value);
   })), []);
   })), []);
 
 
-  const markdownChangedHandler = useCallback((value: string) => {
-    setMarkdownPreviewWithDebounce(value);
-  }, [setMarkdownPreviewWithDebounce]);
-
 
 
   const { scrollEditorHandler, scrollPreviewHandler } = useScrollSync(GlobalCodeMirrorEditorKey.MAIN, previewRef);
   const { scrollEditorHandler, scrollPreviewHandler } = useScrollSync(GlobalCodeMirrorEditorKey.MAIN, previewRef);
 
 
@@ -267,6 +263,14 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
     });
     });
   }, [codeMirrorEditor, pageId]);
   }, [codeMirrorEditor, pageId]);
 
 
+
+  const cmProps = useMemo<ReactCodeMirrorProps>(() => ({
+    onChange: (value: string) => {
+      setMarkdownPreviewWithDebounce(value);
+    },
+  }), [setMarkdownPreviewWithDebounce]);
+
+
   // set handler to save and return to View
   // set handler to save and return to View
   useEffect(() => {
   useEffect(() => {
     globalEmitter.on('saveAndReturnToView', saveAndReturnToViewHandler);
     globalEmitter.on('saveAndReturnToView', saveAndReturnToViewHandler);
@@ -363,7 +367,6 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
         <div className="page-editor-editor-container flex-expand-vert border-end">
         <div className="page-editor-editor-container flex-expand-vert border-end">
           <CodeMirrorEditorMain
           <CodeMirrorEditorMain
             isEditorMode={editorMode === EditorMode.Editor}
             isEditorMode={editorMode === EditorMode.Editor}
-            onChange={markdownChangedHandler}
             onSave={saveWithShortcut}
             onSave={saveWithShortcut}
             onUpload={uploadHandler}
             onUpload={uploadHandler}
             acceptedUploadFileType={acceptedUploadFileType}
             acceptedUploadFileType={acceptedUploadFileType}
@@ -374,6 +377,7 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
             initialValue={initialValue}
             initialValue={initialValue}
             editorSettings={editorSettings}
             editorSettings={editorSettings}
             onEditorsUpdated={onEditorsUpdated}
             onEditorsUpdated={onEditorsUpdated}
+            cmProps={cmProps}
           />
           />
         </div>
         </div>
         <div
         <div

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

@@ -53,7 +53,7 @@ export const RichAttachment = React.memo((props: RichAttachmentProps) => {
   }
   }
 
 
   return (
   return (
-    <div className={`${styles.attachment} d-inline-block`}>
+    <div data-testid="rich-attachment" className={`${styles.attachment} d-inline-block`}>
       <div className="my-2 p-2 card">
       <div className="my-2 p-2 card">
         <div className="p-1 card-body d-flex align-items-center">
         <div className="p-1 card-body d-flex align-items-center">
           <div className="me-2 px-0 d-flex align-items-center justify-content-center">
           <div className="me-2 px-0 d-flex align-items-center justify-content-center">

+ 1 - 1
apps/app/src/client/components/Sidebar/Bookmarks.tsx

@@ -14,7 +14,7 @@ export const Bookmarks = () : JSX.Element => {
   return (
   return (
     <div className="px-3">
     <div className="px-3">
       <div className="grw-sidebar-content-header">
       <div className="grw-sidebar-content-header">
-        <h4 className="mb-0 py-4">{t('Bookmarks')}</h4>
+        <h3 className="fs-6 fw-bold mb-0 py-4">{t('Bookmarks')}</h3>
       </div>
       </div>
       {isGuestUser ? (
       {isGuestUser ? (
         <h4 className="fs-6">
         <h4 className="fs-6">

+ 2 - 2
apps/app/src/client/components/Sidebar/Custom/CustomSidebar.tsx

@@ -20,10 +20,10 @@ export const CustomSidebar = (): JSX.Element => {
   return (
   return (
     <div className="pt-4 pb-3 px-3">
     <div className="pt-4 pb-3 px-3">
       <div className="grw-sidebar-content-header d-flex">
       <div className="grw-sidebar-content-header d-flex">
-        <h4 className="mb-0">
+        <h3 className="fs-6 fw-bold mb-0">
           {t('CustomSidebar')}
           {t('CustomSidebar')}
           { !isLoading && <Link href="/Sidebar#edit" className="h6 ms-2"><span className="material-symbols-outlined">edit</span></Link> }
           { !isLoading && <Link href="/Sidebar#edit" className="h6 ms-2"><span className="material-symbols-outlined">edit</span></Link> }
-        </h4>
+        </h3>
         { !isLoading && <SidebarHeaderReloadButton onClick={() => mutate()} /> }
         { !isLoading && <SidebarHeaderReloadButton onClick={() => mutate()} /> }
       </div>
       </div>
 
 

+ 2 - 2
apps/app/src/client/components/Sidebar/InAppNotification/InAppNotification.tsx

@@ -17,9 +17,9 @@ export const InAppNotification = (): JSX.Element => {
   return (
   return (
     <div className="px-3">
     <div className="px-3">
       <div className="grw-sidebar-content-header py-4 d-flex">
       <div className="grw-sidebar-content-header py-4 d-flex">
-        <h4 className="mb-0">
+        <h3 className="fs-6 fw-bold mb-0">
           {t('In-App Notification')}
           {t('In-App Notification')}
-        </h4>
+        </h3>
       </div>
       </div>
 
 
       <InAppNotificationForms
       <InAppNotificationForms

+ 1 - 1
apps/app/src/client/components/Sidebar/PageTree/PageTree.tsx

@@ -23,7 +23,7 @@ export const PageTree = (): JSX.Element => {
   return (
   return (
     <div className="pt-4 pb-3 px-3">
     <div className="pt-4 pb-3 px-3">
       <div className="grw-sidebar-content-header d-flex">
       <div className="grw-sidebar-content-header d-flex">
-        <h4 className="mb-0">{t('Page Tree')}</h4>
+        <h3 className="fs-6 fw-bold mb-0">{t('Page Tree')}</h3>
         <Suspense>
         <Suspense>
           <PageTreeHeader
           <PageTreeHeader
             isWipPageShown={isWipPageShown}
             isWipPageShown={isWipPageShown}

+ 1 - 1
apps/app/src/client/components/Sidebar/RecentChanges/RecentChanges.tsx

@@ -21,7 +21,7 @@ export const RecentChanges = (): JSX.Element => {
   return (
   return (
     <div className="px-3" data-testid="grw-recent-changes">
     <div className="px-3" data-testid="grw-recent-changes">
       <div className="grw-sidebar-content-header py-4 d-flex">
       <div className="grw-sidebar-content-header py-4 d-flex">
-        <h4 className="mb-0 text-nowrap">{t('Recent Changes')}</h4>
+        <h3 className="fs-6 fw-bold mb-0 text-nowrap">{t('Recent Changes')}</h3>
         <Suspense>
         <Suspense>
           <RecentChangesHeader
           <RecentChangesHeader
             isSmall={isSmall}
             isSmall={isSmall}

+ 1 - 1
apps/app/src/client/components/Sidebar/Sidebar.module.scss

@@ -11,7 +11,7 @@
 .grw-sidebar :global {
 .grw-sidebar :global {
   .grw-sidebar-content-header {
   .grw-sidebar-content-header {
     .grw-btn-reload {
     .grw-btn-reload {
-      font-size: 18px;
+      font-size: 16px;
     }
     }
   }
   }
 }
 }

+ 1 - 1
apps/app/src/client/components/Sidebar/SidebarNav/SecondaryItems.tsx

@@ -47,10 +47,10 @@ export const SecondaryItems: FC = memo(() => {
 
 
   return (
   return (
     <div className={styles['grw-secondary-items']}>
     <div className={styles['grw-secondary-items']}>
-      {!isGuestUser && <PersonalDropdown />}
       <SecondaryItem label="Help" iconName="help" href={growiCloudUri != null ? 'https://growi.cloud/help/' : 'https://docs.growi.org'} isBlank />
       <SecondaryItem label="Help" iconName="help" href={growiCloudUri != null ? 'https://growi.cloud/help/' : 'https://docs.growi.org'} isBlank />
       {isAdmin && <SecondaryItem label="Admin" iconName="settings" href="/admin" />}
       {isAdmin && <SecondaryItem label="Admin" iconName="settings" href="/admin" />}
       <SecondaryItem label="Trash" href="/trash" iconName="delete" />
       <SecondaryItem label="Trash" href="/trash" iconName="delete" />
+      {!isGuestUser && <PersonalDropdown />}
     </div>
     </div>
   );
   );
 });
 });

+ 2 - 2
apps/app/src/client/components/Sidebar/Tag.tsx

@@ -44,8 +44,8 @@ const Tag: FC = () => {
   // todo: adjust design by XD
   // todo: adjust design by XD
   return (
   return (
     <div className="container-lg px-3 mb-5 pb-5" data-testid="grw-sidebar-content-tags">
     <div className="container-lg px-3 mb-5 pb-5" data-testid="grw-sidebar-content-tags">
-      <div className="grw-sidebar-content-header py-3 d-flex">
-        <h4 className="mb-0">{t('Tags')}</h4>
+      <div className="grw-sidebar-content-header pt-4 pb-3 d-flex">
+        <h3 className="fs-6 fw-bold mb-0">{t('Tags')}</h3>
         <SidebarHeaderReloadButton onClick={() => onReload()} />
         <SidebarHeaderReloadButton onClick={() => onReload()} />
       </div>
       </div>
 
 

+ 1 - 1
apps/app/src/components/PageView/PageAlerts/PageGrantAlert.tsx

@@ -55,7 +55,7 @@ export const PageGrantAlert = (): JSX.Element => {
 
 
 
 
   return (
   return (
-    <p className="alert alert-primary py-3 px-4">
+    <p data-testid="page-grant-alert" className="alert alert-primary py-3 px-4">
       {renderAlertContent()}
       {renderAlertContent()}
     </p>
     </p>
   );
   );

+ 2 - 2
apps/app/src/migrations/20211227060705-revision-path-to-page-id-schema-migration--fixed-7549.js

@@ -47,7 +47,7 @@ module.exports = {
           };
           };
         });
         });
 
 
-        await Revision.bulkWrite(updateManyOperations);
+        await Revision.bulkWrite(updateManyOperations, { strict: false });
 
 
         callback();
         callback();
       },
       },
@@ -98,7 +98,7 @@ module.exports = {
           };
           };
         });
         });
 
 
-        await Revision.bulkWrite(updateManyOperations);
+        await Revision.bulkWrite(updateManyOperations, { strict: false });
 
 
         callback();
         callback();
       },
       },

+ 1 - 1
apps/app/src/server/routes/apiv3/customize-setting.js

@@ -272,7 +272,7 @@ module.exports = (crowi) => {
     }
     }
   });
   });
 
 
-  router.get('/theme', loginRequiredStrictly, adminRequired, async(req, res) => {
+  router.get('/theme', loginRequiredStrictly, async(req, res) => {
 
 
     try {
     try {
       const currentTheme = await crowi.configManager.getConfig('crowi', 'customize:theme');
       const currentTheme = await crowi.configManager.getConfig('crowi', 'customize:theme');

+ 25 - 7
apps/app/src/server/routes/apiv3/page/index.ts

@@ -17,12 +17,13 @@ import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
 import { excludeReadOnlyUser } from '~/server/middlewares/exclude-read-only-user';
 import { excludeReadOnlyUser } from '~/server/middlewares/exclude-read-only-user';
 import { GlobalNotificationSettingEvent } from '~/server/models/GlobalNotificationSetting';
 import { GlobalNotificationSettingEvent } from '~/server/models/GlobalNotificationSetting';
-import type { PageModel } from '~/server/models/page';
+import type { PageDocument, PageModel } from '~/server/models/page';
 import { Revision } from '~/server/models/revision';
 import { Revision } from '~/server/models/revision';
 import Subscription from '~/server/models/subscription';
 import Subscription from '~/server/models/subscription';
 import { configManager } from '~/server/service/config-manager';
 import { configManager } from '~/server/service/config-manager';
 import type { IPageGrantService } from '~/server/service/page-grant';
 import type { IPageGrantService } from '~/server/service/page-grant';
 import { preNotifyService } from '~/server/service/pre-notify';
 import { preNotifyService } from '~/server/service/pre-notify';
+import { normalizeLatestRevisionIfBroken } from '~/server/service/revision/normalize-latest-revision-if-broken';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import type { ApiV3Response } from '../interfaces/apiv3-response';
 import type { ApiV3Response } from '../interfaces/apiv3-response';
@@ -661,7 +662,7 @@ module.exports = (crowi) => {
         }
         }
 
 
         const userRelatedGroups = await pageGrantService.getUserRelatedGroups(user);
         const userRelatedGroups = await pageGrantService.getUserRelatedGroups(user);
-        const isUserGrantedPageAccess = await pageGrantService.isUserGrantedPageAccess(page, user, userRelatedGroups);
+        const isUserGrantedPageAccess = await pageGrantService.isUserGrantedPageAccess(page, user, userRelatedGroups, true);
         if (!isUserGrantedPageAccess) {
         if (!isUserGrantedPageAccess) {
           return res.apiv3Err(new ErrorV3('Cannot access page or ancestor.', 'cannot_access_page'), 403);
           return res.apiv3Err(new ErrorV3('Cannot access page or ancestor.', 'cannot_access_page'), 403);
         }
         }
@@ -741,14 +742,17 @@ module.exports = (crowi) => {
   *            description: Return page's markdown
   *            description: Return page's markdown
   */
   */
   router.get('/export/:pageId', loginRequiredStrictly, validator.export, async(req, res) => {
   router.get('/export/:pageId', loginRequiredStrictly, validator.export, async(req, res) => {
-    const { pageId } = req.params;
+    const pageId: string = req.params.pageId;
     const { format, revisionId = null } = req.query;
     const { format, revisionId = null } = req.query;
     let revision;
     let revision;
     let pagePath;
     let pagePath;
 
 
+    const Page = mongoose.model<PageDocument, PageModel>('Page');
+
+    let page: PageDocument;
+
     try {
     try {
-      const Page = crowi.model('Page');
-      const page = await Page.findByIdAndViewer(pageId, req.user);
+      page = await Page.findByIdAndViewer(pageId, req.user);
 
 
       if (page == null) {
       if (page == null) {
         const isPageExist = await Page.count({ _id: pageId }) > 0;
         const isPageExist = await Page.count({ _id: pageId }) > 0;
@@ -758,8 +762,22 @@ module.exports = (crowi) => {
         }
         }
         return res.apiv3Err(new ErrorV3(`Page ${pageId} is not exist.`), 404);
         return res.apiv3Err(new ErrorV3(`Page ${pageId} is not exist.`), 404);
       }
       }
+    }
+    catch (err) {
+      logger.error('Failed to get page data', err);
+      return res.apiv3Err(err, 500);
+    }
+
+    // Normalize the latest revision which was borken by the migration script '20211227060705-revision-path-to-page-id-schema-migration--fixed-7549.js'
+    try {
+      await normalizeLatestRevisionIfBroken(pageId);
+    }
+    catch (err) {
+      logger.error('Error occurred in normalizing the latest revision');
+    }
 
 
-      const revisionIdForFind = revisionId || page.revision;
+    try {
+      const revisionIdForFind = revisionId ?? page.revision;
 
 
       revision = await Revision.findById(revisionIdForFind);
       revision = await Revision.findById(revisionIdForFind);
       pagePath = page.path;
       pagePath = page.path;
@@ -770,7 +788,7 @@ module.exports = (crowi) => {
       }
       }
     }
     }
     catch (err) {
     catch (err) {
-      logger.error('Failed to get page data', err);
+      logger.error('Failed to get revision data', err);
       return res.apiv3Err(err, 500);
       return res.apiv3Err(err, 500);
     }
     }
 
 

+ 11 - 0
apps/app/src/server/routes/apiv3/page/update-page.ts

@@ -18,6 +18,7 @@ import {
 } from '~/server/models';
 } from '~/server/models';
 import type { PageDocument, PageModel } from '~/server/models/page';
 import type { PageDocument, PageModel } from '~/server/models/page';
 import { preNotifyService } from '~/server/service/pre-notify';
 import { preNotifyService } from '~/server/service/pre-notify';
+import { normalizeLatestRevisionIfBroken } from '~/server/service/revision/normalize-latest-revision-if-broken';
 import { getYjsService } from '~/server/service/yjs';
 import { getYjsService } from '~/server/service/yjs';
 import { generalXssFilter } from '~/services/general-xss-filter';
 import { generalXssFilter } from '~/services/general-xss-filter';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
@@ -132,6 +133,16 @@ export const updatePageHandlersFactory: UpdatePageHandlersFactory = (crowi) => {
       // check revision
       // check revision
       const currentPage = await Page.findByIdAndViewer(pageId, req.user);
       const currentPage = await Page.findByIdAndViewer(pageId, req.user);
 
 
+      if (currentPage != null) {
+        // Normalize the latest revision which was borken by the migration script '20211227060705-revision-path-to-page-id-schema-migration--fixed-7549.js'
+        try {
+          await normalizeLatestRevisionIfBroken(pageId);
+        }
+        catch (err) {
+          logger.error('Error occurred in normalizing the latest revision');
+        }
+      }
+
       if (currentPage != null && !await currentPage.isUpdatable(sanitizeRevisionId, origin)) {
       if (currentPage != null && !await currentPage.isUpdatable(sanitizeRevisionId, origin)) {
         const latestRevision = await Revision.findById(currentPage.revision).populate('author');
         const latestRevision = await Revision.findById(currentPage.revision).populate('author');
         const returnLatestRevision = {
         const returnLatestRevision = {

+ 9 - 0
apps/app/src/server/routes/apiv3/revisions.js

@@ -1,6 +1,7 @@
 import { ErrorV3 } from '@growi/core/dist/models';
 import { ErrorV3 } from '@growi/core/dist/models';
 
 
 import { Revision } from '~/server/models/revision';
 import { Revision } from '~/server/models/revision';
+import { normalizeLatestRevisionIfBroken } from '~/server/service/revision/normalize-latest-revision-if-broken';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
@@ -121,6 +122,14 @@ module.exports = (crowi) => {
       return res.apiv3Err(new ErrorV3('Current user is not accessible to this page.', 'forbidden-page'), 403);
       return res.apiv3Err(new ErrorV3('Current user is not accessible to this page.', 'forbidden-page'), 403);
     }
     }
 
 
+    // Normalize the latest revision which was borken by the migration script '20211227060705-revision-path-to-page-id-schema-migration--fixed-7549.js'
+    try {
+      await normalizeLatestRevisionIfBroken(pageId);
+    }
+    catch (err) {
+      logger.error('Error occurred in normalizing the latest revision');
+    }
+
     try {
     try {
       const page = await Page.findOne({ _id: pageId });
       const page = await Page.findOne({ _id: pageId });
       const queryOpts = {
       const queryOpts = {

+ 3 - 2
apps/app/src/server/service/page-grant.ts

@@ -106,7 +106,7 @@ export interface IPageGrantService {
   getUserRelatedGrantedGroups: (page: PageDocument, user) => Promise<IGrantedGroup[]>,
   getUserRelatedGrantedGroups: (page: PageDocument, user) => Promise<IGrantedGroup[]>,
   getUserRelatedGrantedGroupsSyncronously: (userRelatedGroups: PopulatedGrantedGroup[], page: PageDocument) => IGrantedGroup[],
   getUserRelatedGrantedGroupsSyncronously: (userRelatedGroups: PopulatedGrantedGroup[], page: PageDocument) => IGrantedGroup[],
   getNonUserRelatedGrantedGroups: (page: PageDocument, user) => Promise<IGrantedGroup[]>,
   getNonUserRelatedGrantedGroups: (page: PageDocument, user) => Promise<IGrantedGroup[]>,
-  isUserGrantedPageAccess: (page: PageDocument, user, userRelatedGroups: PopulatedGrantedGroup[]) => boolean,
+  isUserGrantedPageAccess: (page: PageDocument, user, userRelatedGroups: PopulatedGrantedGroup[], allowAnyoneWithTheLink?: boolean) => boolean,
   getPageGroupGrantData: (page: PageDocument, user) => Promise<GroupGrantData>,
   getPageGroupGrantData: (page: PageDocument, user) => Promise<GroupGrantData>,
   calcApplicableGrantData: (page, user) => Promise<IRecordApplicableGrant>
   calcApplicableGrantData: (page, user) => Promise<IRecordApplicableGrant>
 }
 }
@@ -789,8 +789,9 @@ class PageGrantService implements IPageGrantService {
   /**
   /**
    * Check if user is granted access to page
    * Check if user is granted access to page
    */
    */
-  isUserGrantedPageAccess(page: PageDocument, user, userRelatedGroups: PopulatedGrantedGroup[]): boolean {
+  isUserGrantedPageAccess(page: PageDocument, user, userRelatedGroups: PopulatedGrantedGroup[], allowAnyoneWithTheLink = false): boolean {
     if (page.grant === PageGrant.GRANT_PUBLIC) return true;
     if (page.grant === PageGrant.GRANT_PUBLIC) return true;
+    if (page.grant === PageGrant.GRANT_RESTRICTED && allowAnyoneWithTheLink) return true;
     if (page.grant === PageGrant.GRANT_OWNER) return page.grantedUsers?.includes(user._id.toString()) ?? false;
     if (page.grant === PageGrant.GRANT_OWNER) return page.grantedUsers?.includes(user._id.toString()) ?? false;
     if (page.grant === PageGrant.GRANT_USER_GROUP) return this.getUserRelatedGrantedGroupsSyncronously(userRelatedGroups, page).length > 0;
     if (page.grant === PageGrant.GRANT_USER_GROUP) return this.getUserRelatedGrantedGroupsSyncronously(userRelatedGroups, page).length > 0;
     return false;
     return false;

+ 15 - 10
apps/app/src/server/service/passport.ts

@@ -571,43 +571,48 @@ class PassportService implements S2sMessageHandlable {
     // Prevent request timeout error on app init
     // Prevent request timeout error on app init
     const oidcIssuer = await this.getOIDCIssuerInstance(issuerHost);
     const oidcIssuer = await this.getOIDCIssuerInstance(issuerHost);
     if (oidcIssuer != null) {
     if (oidcIssuer != null) {
+      const oidcIssuerMetadata = oidcIssuer.metadata;
+
       logger.debug('Discovered issuer %s %O', oidcIssuer.issuer, oidcIssuer.metadata);
       logger.debug('Discovered issuer %s %O', oidcIssuer.issuer, oidcIssuer.metadata);
 
 
       const authorizationEndpoint = configManager.getConfig('crowi', 'security:passport-oidc:authorizationEndpoint');
       const authorizationEndpoint = configManager.getConfig('crowi', 'security:passport-oidc:authorizationEndpoint');
       if (authorizationEndpoint) {
       if (authorizationEndpoint) {
-        oidcIssuer.metadata.authorization_endpoint = authorizationEndpoint;
+        oidcIssuerMetadata.authorization_endpoint = authorizationEndpoint;
       }
       }
       const tokenEndpoint = configManager.getConfig('crowi', 'security:passport-oidc:tokenEndpoint');
       const tokenEndpoint = configManager.getConfig('crowi', 'security:passport-oidc:tokenEndpoint');
       if (tokenEndpoint) {
       if (tokenEndpoint) {
-        oidcIssuer.metadata.token_endpoint = tokenEndpoint;
+        oidcIssuerMetadata.token_endpoint = tokenEndpoint;
       }
       }
       const revocationEndpoint = configManager.getConfig('crowi', 'security:passport-oidc:revocationEndpoint');
       const revocationEndpoint = configManager.getConfig('crowi', 'security:passport-oidc:revocationEndpoint');
       if (revocationEndpoint) {
       if (revocationEndpoint) {
-        oidcIssuer.metadata.revocation_endpoint = revocationEndpoint;
+        oidcIssuerMetadata.revocation_endpoint = revocationEndpoint;
       }
       }
       const introspectionEndpoint = configManager.getConfig('crowi', 'security:passport-oidc:introspectionEndpoint');
       const introspectionEndpoint = configManager.getConfig('crowi', 'security:passport-oidc:introspectionEndpoint');
       if (introspectionEndpoint) {
       if (introspectionEndpoint) {
-        oidcIssuer.metadata.introspection_endpoint = introspectionEndpoint;
+        oidcIssuerMetadata.introspection_endpoint = introspectionEndpoint;
       }
       }
       const userInfoEndpoint = configManager.getConfig('crowi', 'security:passport-oidc:userInfoEndpoint');
       const userInfoEndpoint = configManager.getConfig('crowi', 'security:passport-oidc:userInfoEndpoint');
       if (userInfoEndpoint) {
       if (userInfoEndpoint) {
-        oidcIssuer.metadata.userinfo_endpoint = userInfoEndpoint;
+        oidcIssuerMetadata.userinfo_endpoint = userInfoEndpoint;
       }
       }
       const endSessionEndpoint = configManager.getConfig('crowi', 'security:passport-oidc:endSessionEndpoint');
       const endSessionEndpoint = configManager.getConfig('crowi', 'security:passport-oidc:endSessionEndpoint');
       if (endSessionEndpoint) {
       if (endSessionEndpoint) {
-        oidcIssuer.metadata.end_session_endpoint = endSessionEndpoint;
+        oidcIssuerMetadata.end_session_endpoint = endSessionEndpoint;
       }
       }
       const registrationEndpoint = configManager.getConfig('crowi', 'security:passport-oidc:registrationEndpoint');
       const registrationEndpoint = configManager.getConfig('crowi', 'security:passport-oidc:registrationEndpoint');
       if (registrationEndpoint) {
       if (registrationEndpoint) {
-        oidcIssuer.metadata.registration_endpoint = registrationEndpoint;
+        oidcIssuerMetadata.registration_endpoint = registrationEndpoint;
       }
       }
       const jwksUri = configManager.getConfig('crowi', 'security:passport-oidc:jwksUri');
       const jwksUri = configManager.getConfig('crowi', 'security:passport-oidc:jwksUri');
       if (jwksUri) {
       if (jwksUri) {
-        oidcIssuer.metadata.jwks_uri = jwksUri;
+        oidcIssuerMetadata.jwks_uri = jwksUri;
       }
       }
-      logger.debug('Configured issuer %s %O', oidcIssuer.issuer, oidcIssuer.metadata);
 
 
-      const client = new oidcIssuer.Client({
+      const newOidcIssuer = new OIDCIssuer(oidcIssuerMetadata);
+
+      logger.debug('Configured issuer %s %O', newOidcIssuer.issuer, newOidcIssuer.metadata);
+
+      const client = new newOidcIssuer.Client({
         client_id: clientId,
         client_id: clientId,
         client_secret: clientSecret,
         client_secret: clientSecret,
         redirect_uris: [redirectUri],
         redirect_uris: [redirectUri],

+ 124 - 0
apps/app/src/server/service/revision/normalize-latest-revision-if-broken.integ.ts

@@ -0,0 +1,124 @@
+import { getIdForRef } from '@growi/core';
+import type { HydratedDocument } from 'mongoose';
+import mongoose, { Types } from 'mongoose';
+
+import type { PageDocument, PageModel } from '~/server/models/page';
+import PageModelFactory from '~/server/models/page';
+import { Revision } from '~/server/models/revision';
+
+import { normalizeLatestRevisionIfBroken } from './normalize-latest-revision-if-broken';
+
+describe('normalizeLatestRevisionIfBroken', () => {
+
+  beforeAll(async() => {
+    await PageModelFactory(null);
+  });
+
+
+  test('should update the latest revision', async() => {
+    const Page = mongoose.model<HydratedDocument<PageDocument>, PageModel>('Page');
+
+    // == Arrange
+    const page = await Page.create({ path: '/foo' });
+    const revision = await Revision.create({ pageId: page._id, body: '' });
+    // connect the page and the revision
+    page.revision = revision._id;
+    await page.save();
+    // break the revision
+    await Revision.updateOne({ _id: revision._id }, { pageId: new Types.ObjectId() });
+
+    // spy
+    const updateOneSpy = vi.spyOn(Revision, 'updateOne');
+
+    // == Act
+    await normalizeLatestRevisionIfBroken(page._id);
+
+    // == Assert
+    // assert spy
+    expect(updateOneSpy).toHaveBeenCalled();
+
+    // assert revision
+    const revisionById = await Revision.findById(revision._id);
+    const revisionByPageId = await Revision.findOne({ pageId: page._id });
+    expect(revisionById).not.toBeNull();
+    expect(revisionByPageId).not.toBeNull();
+    assert(revisionById != null);
+    assert(revisionByPageId != null);
+    expect(revisionById._id).toEqual(revisionByPageId._id);
+    expect(getIdForRef(revisionById.pageId)).toEqual(page._id.toString());
+  });
+
+
+  describe('should returns without any operation', () => {
+    test('when the page has revisions at least one', async() => {
+      const Page = mongoose.model<HydratedDocument<PageDocument>, PageModel>('Page');
+
+      // Arrange
+      const page = await Page.create({ path: '/foo' });
+      await Revision.create({ pageId: page._id, body: '' });
+      // spy
+      const updateOneSpy = vi.spyOn(Revision, 'updateOne');
+
+      // Act
+      await normalizeLatestRevisionIfBroken(page._id);
+
+      // Assert
+      expect(updateOneSpy).not.toHaveBeenCalled();
+    });
+
+    test('when the page is not found', async() => {
+      // Arrange
+      const pageIdOfRevision = new Types.ObjectId();
+      // create an orphan revision
+      await Revision.create({ pageId: pageIdOfRevision, body: '' });
+
+      // spy
+      const updateOneSpy = vi.spyOn(Revision, 'updateOne');
+
+      // Act
+      await normalizeLatestRevisionIfBroken(pageIdOfRevision);
+
+      // Assert
+      expect(updateOneSpy).not.toHaveBeenCalled();
+    });
+
+    test('when the page.revision is null', async() => {
+      const Page = mongoose.model<HydratedDocument<PageDocument>, PageModel>('Page');
+
+      // Arrange
+      const page = await Page.create({ path: '/foo' });
+      // create an orphan revision
+      await Revision.create({ pageId: page._id, body: '' });
+
+      // spy
+      const updateOneSpy = vi.spyOn(Revision, 'updateOne');
+
+      // Act
+      await normalizeLatestRevisionIfBroken(page._id);
+
+      // Assert
+      expect(updateOneSpy).not.toHaveBeenCalled();
+    });
+
+    test('when the page.revision does not exist', async() => {
+      const Page = mongoose.model<HydratedDocument<PageDocument>, PageModel>('Page');
+
+      // Arrange
+      const revisionNonExistent = new Types.ObjectId();
+      const page = await Page.create({ path: '/foo', revision: revisionNonExistent });
+      // create an orphan revision
+      await Revision.create({ pageId: page._id, body: '' });
+
+      // spy
+      const updateOneSpy = vi.spyOn(Revision, 'updateOne');
+
+      // Act
+      await normalizeLatestRevisionIfBroken(page._id);
+
+      // Assert
+      expect(updateOneSpy).not.toHaveBeenCalled();
+    });
+
+  });
+
+});

+ 38 - 0
apps/app/src/server/service/revision/normalize-latest-revision-if-broken.ts

@@ -0,0 +1,38 @@
+import type { HydratedDocument, Types } from 'mongoose';
+import mongoose from 'mongoose';
+
+import type { PageDocument, PageModel } from '~/server/models/page';
+import { Revision } from '~/server/models/revision';
+import loggerFactory from '~/utils/logger';
+
+
+const logger = loggerFactory('growi:service:revision:normalize-latest-revision');
+
+/**
+ * Normalize the latest revision which was borken by the migration script '20211227060705-revision-path-to-page-id-schema-migration--fixed-7549.js'
+ *
+ * @ref https://github.com/weseek/growi/pull/8998
+ */
+export const normalizeLatestRevisionIfBroken = async(pageId: string | Types.ObjectId): Promise<void> => {
+
+  if (await Revision.exists({ pageId: { $eq: pageId } })) {
+    return;
+  }
+
+  logger.info(`The page ('${pageId}') does not have any revisions. Normalization of the latest revision will be started.`);
+
+  const Page = mongoose.model<HydratedDocument<PageDocument>, PageModel>('Page');
+  const page = await Page.findOne({ _id: { $eq: pageId } }, { revision: 1 }).exec();
+
+  if (page == null) {
+    logger.warn(`Normalization has been canceled since the page ('${pageId}') could not be found.`);
+    return;
+  }
+  if (page.revision == null || !(await Revision.exists({ _id: page.revision }))) {
+    logger.warn(`Normalization has been canceled since the Page.revision of the page ('${pageId}') could not be found.`);
+    return;
+  }
+
+  // update Revision.pageId
+  await Revision.updateOne({ _id: page.revision }, { $set: { pageId } }).exec();
+};

+ 4 - 0
apps/app/src/server/service/yjs/sync-ydoc.ts

@@ -4,6 +4,7 @@ import type { Document } from 'y-socket.io/dist/server';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import { Revision } from '../../models/revision';
 import { Revision } from '../../models/revision';
+import { normalizeLatestRevisionIfBroken } from '../revision/normalize-latest-revision-if-broken';
 
 
 import type { MongodbPersistence } from './extended/mongodb-persistence';
 import type { MongodbPersistence } from './extended/mongodb-persistence';
 
 
@@ -26,6 +27,9 @@ type Context = {
 export const syncYDoc = async(mdb: MongodbPersistence, doc: Document, context: true | Context): Promise<void> => {
 export const syncYDoc = async(mdb: MongodbPersistence, doc: Document, context: true | Context): Promise<void> => {
   const pageId = doc.name;
   const pageId = doc.name;
 
 
+  // Normalize the latest revision which was borken by the migration script '20211227060705-revision-path-to-page-id-schema-migration--fixed-7549.js'
+  await normalizeLatestRevisionIfBroken(pageId);
+
   const revision = await Revision
   const revision = await Revision
     .findOne(
     .findOne(
       // filter
       // filter

+ 3 - 0
apps/app/src/server/service/yjs/yjs.integ.ts

@@ -17,6 +17,9 @@ vi.mock('y-socket.io/dist/server', () => {
   return { YSocketIO };
   return { YSocketIO };
 });
 });
 
 
+vi.mock('../revision/normalize-latest-revision-if-broken', () => ({
+  normalizeLatestRevisionIfBroken: vi.fn(),
+}));
 
 
 const ObjectId = Types.ObjectId;
 const ObjectId = Types.ObjectId;
 
 

+ 4 - 0
apps/app/src/server/service/yjs/yjs.ts

@@ -15,6 +15,7 @@ import loggerFactory from '~/utils/logger';
 
 
 import type { PageModel } from '../../models/page';
 import type { PageModel } from '../../models/page';
 import { Revision } from '../../models/revision';
 import { Revision } from '../../models/revision';
+import { normalizeLatestRevisionIfBroken } from '../revision/normalize-latest-revision-if-broken';
 
 
 import { createIndexes } from './create-indexes';
 import { createIndexes } from './create-indexes';
 import { createMongoDBPersistence } from './create-mongodb-persistence';
 import { createMongoDBPersistence } from './create-mongodb-persistence';
@@ -141,6 +142,9 @@ class YjsService implements IYjsService {
       logger.debug(`getYDocStatus('${pageId}') detected '${status}'`, args ?? {});
       logger.debug(`getYDocStatus('${pageId}') detected '${status}'`, args ?? {});
     };
     };
 
 
+    // Normalize the latest revision which was borken by the migration script '20211227060705-revision-path-to-page-id-schema-migration--fixed-7549.js'
+    await normalizeLatestRevisionIfBroken(pageId);
+
     // get the latest revision createdAt
     // get the latest revision createdAt
     const result = await Revision
     const result = await Revision
       .findOne(
       .findOne(

+ 1 - 1
apps/app/src/styles/_fonts.scss

@@ -1,7 +1,7 @@
 :root {
 :root {
   --font-family-sans-serif: var(--grw-font-family-lato), -apple-system, blinkmacsystemfont, 'Hiragino Kaku Gothic ProN', meiryo, sans-serif;
   --font-family-sans-serif: var(--grw-font-family-lato), -apple-system, blinkmacsystemfont, 'Hiragino Kaku Gothic ProN', meiryo, sans-serif;
   --font-family-serif: georgia, 'Times New Roman', times, serif;
   --font-family-serif: georgia, 'Times New Roman', times, serif;
-  --font-family-monospace: monospace, var(--grw-font-family-source-han-code-jp-subset-main), var(--grw-font-family-source-han-code-jp-subset-jis2);
+  --font-family-monospace: Menlo, Consolas, DejaVu Sans Mono, var(--grw-font-family-source-han-code-jp-subset-main), var(--grw-font-family-source-han-code-jp-subset-jis2), monospace;
 }
 }
 
 
 .material-symbols-outlined {
 .material-symbols-outlined {

+ 0 - 175
apps/app/test/cypress/e2e/23-editor/23-editor--with-navigation.cy.ts

@@ -1,175 +0,0 @@
-import path from 'path-browserify';
-
-function openEditor() {
-  cy.get('#grw-page-editor-mode-manager').as('pageEditorModeManager').should('be.visible');
-  cy.getByTestid('editor-button').click();
-  cy.getByTestid('grw-editor-navbar-bottom').should('be.visible');
-  cy.get('.cm-content').should('be.visible');
-}
-
-context('Editor while uploading to a new page', () => {
-
-  const ssPrefix = 'editor-while-uploading-';
-
-  beforeEach(() => {
-    // login
-    cy.fixture("user-admin.json").then(user => {
-      cy.login(user.username, user.password);
-    });
-  });
-
-  /**
-   * for the issues:
-   * @see https://redmine.weseek.co.jp/issues/122040
-   * @see https://redmine.weseek.co.jp/issues/124281
-   */
-  it('should not be cleared and should prevent GrantSelector from modified', { scrollBehavior: false }, () => {
-    cy.visit('/Sandbox/for-122040');
-
-    openEditor();
-
-    cy.screenshot(`${ssPrefix}-prevent-grantselector-modified-1`);
-
-    // input the body
-    const body = 'Hello World!';
-    cy.get('.cm-content').should('be.visible').type(body, { force: true });
-    cy.getByTestid('page-editor-preview-body').should('contain.text', body);
-
-    // open GrantSelector
-    cy.waitUntil(() => {
-      // do
-      cy.getByTestid('grw-grant-selector').within(() => {
-        cy.get('button.dropdown-toggle').click({force: true});
-      });
-      // wait until
-      return cy.getByTestid('grw-grant-selector-dropdown-menu').then($elem => $elem.is(':visible'))
-    });
-
-    // Select "Only me"
-    cy.getByTestid('grw-grant-selector-dropdown-menu').find('.dropdown-item').should('have.length', 4).then((menuItems) => {
-      // click "Only me"
-      menuItems[2].click();
-    })
-
-    cy.getByTestid('grw-grant-selector').find('.dropdown-toggle').should('contain.text', 'Only me');
-    cy.screenshot(`${ssPrefix}-prevent-grantselector-modified-2`);
-
-    // intercept API req/res for fixing labels
-    const dummyAttachmentId = '64b000000000000000000000';
-    let uploadedAttachmentId = '';
-    cy.intercept('POST', '/_api/v3/attachment', (req) => {
-      req.continue((res) => {
-        // store the attachment id
-        uploadedAttachmentId = res.body.attachment._id;
-        // overwrite filePathProxied
-        res.body.attachment.filePathProxied = `/attachment/${dummyAttachmentId}`;
-      });
-    }).as('attachmentsAdd');
-    cy.intercept('GET', `/_api/v3/attachment?attachmentId=${dummyAttachmentId}`, (req) => {
-      // replace attachmentId query
-      req.url = req.url.replace(dummyAttachmentId, uploadedAttachmentId);
-      req.continue((res) => {
-        // overwrite the attachment createdAt
-        res.body.attachment.createdAt = new Date('2023-07-01T00:00:00');
-      });
-    });
-
-    // drag-drop a file
-    const filePath = path.relative('/', path.resolve(Cypress.spec.relative, '../assets/example.txt'));
-    cy.get('.dropzone').eq(0).selectFile(filePath, { action: 'drag-drop' });
-    cy.wait('@attachmentsAdd');
-
-    cy.screenshot(`${ssPrefix}-prevent-grantselector-modified-3`);
-
-    // Update page using shortcut keys
-    cy.get('.cm-content').click({force: true}).type('{ctrl+s}');
-
-    cy.screenshot(`${ssPrefix}-prevent-grantselector-modified-4`);
-
-    // expect
-    cy.get('.Toastify__toast').should('contain.text', 'Saved successfully');
-    cy.get('.cm-content').should('contain.text', body);
-    cy.get('.cm-content').should('contain.text', '[example.txt](/attachment/64b000000000000000000000');
-    cy.getByTestid('grw-grant-selector').find('.dropdown-toggle').should('contain.text', 'Only me');
-    cy.screenshot(`${ssPrefix}-prevent-grantselector-modified-5`);
-  });
-
-});
-
-context('Editor while navigation', () => {
-
-  const ssPrefix = 'editor-while-navigation-';
-
-  beforeEach(() => {
-    // login
-    cy.fixture("user-admin.json").then(user => {
-      cy.login(user.username, user.password);
-    });
-  });
-
-  /**
-   * for the issue:
-   * @see https://redmine.weseek.co.jp/issues/115285
-   */
-  it('Successfully updating the page body', { scrollBehavior: false }, () => {
-    const page1Path = '/Sandbox/for-115285/page1';
-    const page2Path = '/Sandbox/for-115285/page2';
-
-    cy.visit(page1Path);
-
-    openEditor();
-
-    // page1
-    const bodyHello = 'hello';
-    cy.get('.cm-content').should('be.visible').type(bodyHello, { force: true });
-    cy.getByTestid('page-editor-preview-body').should('contain.text', bodyHello);
-    cy.get('.cm-content').screenshot(`${ssPrefix}-editor-for-page1`);
-
-    // save page1
-    cy.getByTestid('save-page-btn').click();
-
-    // open duplicate modal
-    cy.waitUntil(() => {
-      // do
-      cy.getByTestid('grw-contextual-sub-nav').within(() => {
-        cy.getByTestid('open-page-item-control-btn').find('button').click({force: true});
-      });
-      // wait until
-      return cy.getByTestid('page-item-control-menu').then($elem => $elem.is(':visible'))
-    });
-    cy.getByTestid('open-page-duplicate-modal-btn').filter(':visible').click({force: true});
-
-    // duplicate and navigate to page1
-    cy.getByTestid('page-duplicate-modal').should('be.visible').within(() => {
-      cy.get('input.form-control').clear();
-      cy.get('input.form-control').type(page2Path);
-      cy.getByTestid('btn-duplicate').click();
-    })
-
-    openEditor();
-    cy.get('.cm-content').screenshot(`${ssPrefix}-editor-for-page2`);
-
-    // type (without save)
-    const bodyWorld = ' world!!'
-    cy.get('.cm-content').should('be.visible').type(`{moveToEnd}${bodyWorld}`, { force: true });
-    cy.getByTestid('page-editor-preview-body').should('contain.text', `${bodyHello}${bodyWorld}`);
-    cy.get('.cm-content').screenshot(`${ssPrefix}-editor-for-page2-modified`);
-
-    // create a link to page1
-    cy.get('.cm-content').type('\n\n[page1](./page1)');
-
-    // go to page1
-    cy.getByTestid('page-editor-preview-body').within(() => {
-      cy.get("a:contains('page1')").click();
-    });
-
-    openEditor();
-
-    cy.get('.cm-content').screenshot(`${ssPrefix}-editor-for-page1-returned`);
-
-    // expect
-    cy.get('.cm-content').should('contain.text', bodyHello);
-    cy.get('.cm-content').should('not.contain.text', bodyWorld); // text that added to page2
-    cy.get('.cm-content').should('not.contain.text', 'page1'); // text that added to page2
-  });
-});

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

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

+ 1 - 1
package.json

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

+ 5 - 7
packages/editor/src/client/components-internal/CodeMirrorEditor/CodeMirrorEditor.tsx

@@ -34,10 +34,13 @@ const CodeMirrorEditorContainer = forwardRef<HTMLDivElement, DetailedHTMLProps<R
 );
 );
 
 
 export type CodeMirrorEditorProps = {
 export type CodeMirrorEditorProps = {
+  /**
+   * Specity the props for the react-codemirror component. **This must be a memolized object.**
+   */
+  cmProps?: ReactCodeMirrorProps,
   acceptedUploadFileType?: AcceptedUploadFileType,
   acceptedUploadFileType?: AcceptedUploadFileType,
   indentSize?: number,
   indentSize?: number,
   editorSettings?: EditorSettings,
   editorSettings?: EditorSettings,
-  onChange?: (value: string) => void,
   onSave?: () => void,
   onSave?: () => void,
   onUpload?: (files: File[]) => void,
   onUpload?: (files: File[]) => void,
   onScroll?: () => void,
   onScroll?: () => void,
@@ -53,10 +56,10 @@ export const CodeMirrorEditor = (props: Props): JSX.Element => {
     editorKey,
     editorKey,
     hideToolbar,
     hideToolbar,
 
 
+    cmProps,
     acceptedUploadFileType = AcceptedUploadFileType.NONE,
     acceptedUploadFileType = AcceptedUploadFileType.NONE,
     indentSize,
     indentSize,
     editorSettings,
     editorSettings,
-    onChange,
     onSave,
     onSave,
     onUpload,
     onUpload,
     onScroll,
     onScroll,
@@ -64,11 +67,6 @@ export const CodeMirrorEditor = (props: Props): JSX.Element => {
 
 
   const containerRef = useRef(null);
   const containerRef = useRef(null);
 
 
-  const cmProps = useMemo<ReactCodeMirrorProps>(() => {
-    return {
-      onChange,
-    };
-  }, [onChange]);
   const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(editorKey, containerRef.current, cmProps);
   const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(editorKey, containerRef.current, cmProps);
 
 
   useDefaultExtensions(codeMirrorEditor);
   useDefaultExtensions(codeMirrorEditor);

+ 7 - 2
packages/editor/src/client/components-internal/playground/Playground.tsx

@@ -1,8 +1,9 @@
 import {
 import {
-  useCallback, useEffect, useState,
+  useCallback, useEffect, useMemo, useState,
 } from 'react';
 } from 'react';
 
 
 import { AcceptedUploadFileType } from '@growi/core';
 import { AcceptedUploadFileType } from '@growi/core';
+import type { ReactCodeMirrorProps } from '@uiw/react-codemirror';
 import { toast } from 'react-toastify';
 import { toast } from 'react-toastify';
 
 
 import { GlobalCodeMirrorEditorKey } from '../../../consts';
 import { GlobalCodeMirrorEditorKey } from '../../../consts';
@@ -62,6 +63,10 @@ export const Playground = (): JSX.Element => {
 
 
   }, [codeMirrorEditor]);
   }, [codeMirrorEditor]);
 
 
+  const cmProps = useMemo<ReactCodeMirrorProps>(() => ({
+    onChange: setMarkdownToPreview,
+  }), []);
+
   return (
   return (
     <div className="d-flex flex-column vw-100 flex-expand-vh-100">
     <div className="d-flex flex-column vw-100 flex-expand-vh-100">
       <div className="flex-expand-vert justify-content-center align-items-center bg-dark" style={{ minHeight: '83px' }}>
       <div className="flex-expand-vert justify-content-center align-items-center bg-dark" style={{ minHeight: '83px' }}>
@@ -72,11 +77,11 @@ export const Playground = (): JSX.Element => {
           <CodeMirrorEditorMain
           <CodeMirrorEditorMain
             isEditorMode
             isEditorMode
             onSave={saveHandler}
             onSave={saveHandler}
-            onChange={setMarkdownToPreview}
             onUpload={uploadHandler}
             onUpload={uploadHandler}
             indentSize={4}
             indentSize={4}
             acceptedUploadFileType={AcceptedUploadFileType.ALL}
             acceptedUploadFileType={AcceptedUploadFileType.ALL}
             editorSettings={editorSettings}
             editorSettings={editorSettings}
+            cmProps={cmProps}
           />
           />
         </div>
         </div>
         <div className="flex-expand-vert d-none d-lg-flex bg-light text-dark border-start border-dark-subtle p-3">
         <div className="flex-expand-vert d-none d-lg-flex bg-light text-dark border-start border-dark-subtle p-3">

+ 15 - 2
packages/editor/src/client/components/CodeMirrorEditorMain.tsx

@@ -1,8 +1,10 @@
-import { useEffect } from 'react';
+import { useEffect, useMemo } from 'react';
 
 
 import { type Extension } from '@codemirror/state';
 import { type Extension } from '@codemirror/state';
 import { keymap, scrollPastEnd } from '@codemirror/view';
 import { keymap, scrollPastEnd } from '@codemirror/view';
 import type { IUserHasId } from '@growi/core/dist/interfaces';
 import type { IUserHasId } from '@growi/core/dist/interfaces';
+import type { ReactCodeMirrorProps } from '@uiw/react-codemirror';
+import deepmerge from 'ts-deepmerge';
 
 
 import { GlobalCodeMirrorEditorKey } from '../../consts';
 import { GlobalCodeMirrorEditorKey } from '../../consts';
 import { CodeMirrorEditor, type CodeMirrorEditorProps } from '../components-internal/CodeMirrorEditor';
 import { CodeMirrorEditor, type CodeMirrorEditorProps } from '../components-internal/CodeMirrorEditor';
@@ -28,7 +30,7 @@ type Props = CodeMirrorEditorProps & {
 
 
 export const CodeMirrorEditorMain = (props: Props): JSX.Element => {
 export const CodeMirrorEditorMain = (props: Props): JSX.Element => {
   const {
   const {
-    user, pageId, initialValue, isEditorMode,
+    user, pageId, initialValue, isEditorMode, cmProps,
     onSave, onEditorsUpdated, ...otherProps
     onSave, onEditorsUpdated, ...otherProps
   } = props;
   } = props;
 
 
@@ -66,10 +68,21 @@ export const CodeMirrorEditorMain = (props: Props): JSX.Element => {
     return cleanupFunction;
     return cleanupFunction;
   }, [codeMirrorEditor, onSave]);
   }, [codeMirrorEditor, onSave]);
 
 
+  const cmPropsOverride = useMemo<ReactCodeMirrorProps>(() => deepmerge(
+    cmProps ?? {},
+    {
+      // Disable the basic history configuration since this component uses Y.UndoManager instead
+      basicSetup: {
+        history: false,
+      },
+    },
+  ), [cmProps]);
+
   return (
   return (
     <CodeMirrorEditor
     <CodeMirrorEditor
       editorKey={GlobalCodeMirrorEditorKey.MAIN}
       editorKey={GlobalCodeMirrorEditorKey.MAIN}
       onSave={onSave}
       onSave={onSave}
+      cmProps={cmPropsOverride}
       {...otherProps}
       {...otherProps}
     />
     />
   );
   );

+ 18 - 22
packages/editor/src/client/services/use-codemirror-editor/use-codemirror-editor.ts

@@ -40,29 +40,25 @@ export type UseCodeMirrorEditor = {
 
 
 export const useCodeMirrorEditor = (props?: UseCodeMirror): UseCodeMirrorEditor => {
 export const useCodeMirrorEditor = (props?: UseCodeMirror): UseCodeMirrorEditor => {
 
 
-  const mergedProps = useMemo(() => {
-    return deepmerge(
-      props ?? {},
-      {
-        // Reset settings of react-codemirror.
-        // Extensions are defined first will be used if they have the same priority.
-        // If extensions conflict, disable them here.
-        // And add them to defaultExtensions: Extension[] with a lower priority.
-        // ref: https://codemirror.net/examples/config/
-        // ------- Start -------
-        indentWithTab: false,
-        basicSetup: {
-          defaultKeymap: false,
-          dropCursor: false,
-          highlightActiveLine: false,
-          highlightActiveLineGutter: false,
-          // Disabled react-codemirror history for Y.UndoManager
-          history: false,
-        },
-        // ------- End -------
+  const mergedProps = useMemo(() => deepmerge(
+    {
+      // Reset settings of react-codemirror.
+      // Extensions are defined first will be used if they have the same priority.
+      // If extensions conflict, disable them here.
+      // And add them to defaultExtensions: Extension[] with a lower priority.
+      // ref: https://codemirror.net/examples/config/
+      // ------- Start -------
+      indentWithTab: false,
+      basicSetup: {
+        defaultKeymap: false,
+        dropCursor: false,
+        highlightActiveLine: false,
+        highlightActiveLineGutter: false,
       },
       },
-    );
-  }, [props]);
+      // ------- End -------
+    },
+    props ?? {},
+  ), [props]);
 
 
   const { state, view } = useCodeMirror(mergedProps);
   const { state, view } = useCodeMirror(mergedProps);
 
 

+ 4 - 8
packages/editor/src/client/stores/codemirror-editor.ts

@@ -26,14 +26,10 @@ export const useCodeMirrorEditorIsolated = (
   const currentData = ref.current;
   const currentData = ref.current;
 
 
   const swrKey = key != null ? `codeMirrorEditor_${key}` : null;
   const swrKey = key != null ? `codeMirrorEditor_${key}` : null;
-  const mergedProps = useMemo<UseCodeMirror>(() => {
-    return deepmerge(
-      props ?? {},
-      {
-        container,
-      },
-    );
-  }, [container, props]);
+  const mergedProps = useMemo<UseCodeMirror>(() => deepmerge(
+    { container },
+    props ?? {},
+  ), [container, props]);
 
 
   const newData = useCodeMirrorEditor(mergedProps);
   const newData = useCodeMirrorEditor(mergedProps);