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

Merge remote-tracking branch 'origin/master' into support/144273-view-responsive

Yuki Takei 2 лет назад
Родитель
Сommit
f867d576ca
58 измененных файлов с 810 добавлено и 583 удалено
  1. 4 2
      apps/app/public/static/locales/en_US/commons.json
  2. 8 1
      apps/app/public/static/locales/en_US/translation.json
  3. 4 2
      apps/app/public/static/locales/ja_JP/commons.json
  4. 9 2
      apps/app/public/static/locales/ja_JP/translation.json
  5. 4 2
      apps/app/public/static/locales/zh_CN/commons.json
  6. 8 1
      apps/app/public/static/locales/zh_CN/translation.json
  7. 8 12
      apps/app/src/client/util/bookmark-utils.ts
  8. 4 2
      apps/app/src/components/AuthorInfo/AuthorInfo.tsx
  9. 31 29
      apps/app/src/components/Bookmarks/BookmarkFolderItem.tsx
  10. 1 1
      apps/app/src/components/Bookmarks/BookmarkFolderMenu.tsx
  11. 4 4
      apps/app/src/components/Bookmarks/BookmarkFolderTree.module.scss
  12. 2 2
      apps/app/src/components/Bookmarks/BookmarkFolderTree.tsx
  13. 3 3
      apps/app/src/components/Bookmarks/BookmarkItem.tsx
  14. 2 1
      apps/app/src/components/Common/CopyDropdown/CopyDropdown.jsx
  15. 0 10
      apps/app/src/components/Common/PagePathNav/PagePathNav.module.scss
  16. 62 15
      apps/app/src/components/Common/PagePathNav/PagePathNav.tsx
  17. 2 2
      apps/app/src/components/ContentLinkButtons.tsx
  18. 2 2
      apps/app/src/components/Icons/FolderIcon.tsx
  19. 8 2
      apps/app/src/components/Navbar/GrowiNavbarBottom.tsx
  20. 15 6
      apps/app/src/components/PageAlert/FixPageGrantAlert.tsx
  21. 21 4
      apps/app/src/components/PageControls/PageControls.tsx
  22. 10 9
      apps/app/src/components/PageEditor/PageEditor.tsx
  23. 1 0
      apps/app/src/components/PageEditor/Preview.tsx
  24. 1 1
      apps/app/src/components/PageHistory/Revision.tsx
  25. 2 2
      apps/app/src/components/PageList/PageListItemS.tsx
  26. 1 1
      apps/app/src/components/PageSideContents/PageSideContents.tsx
  27. 2 19
      apps/app/src/components/SavePageControls.tsx
  28. 91 53
      apps/app/src/components/SavePageControls/GrantSelector/GrantSelector.tsx
  29. 0 38
      apps/app/src/components/SavePageControls/GrantSelector/use-my-user-groups.ts
  30. 2 6
      apps/app/src/components/SearchPage/SearchControl.tsx
  31. 31 0
      apps/app/src/components/SearchPage/SearchModalTriggerinput.tsx
  32. 1 1
      apps/app/src/components/Sidebar/Bookmarks.tsx
  33. 2 2
      apps/app/src/components/Sidebar/Bookmarks/BookmarkContents.tsx
  34. 2 1
      apps/app/src/components/Sidebar/InAppNotification/InAppNotificationSubstance.tsx
  35. 2 2
      apps/app/src/components/UsersHomepageFooter.tsx
  36. 4 12
      apps/app/src/features/external-user-group/client/stores/external-user-group.ts
  37. 7 1
      apps/app/src/features/search/client/components/SearchModal.tsx
  38. 9 4
      apps/app/src/features/search/client/stores/search.ts
  39. 1 1
      apps/app/src/interfaces/bookmark-info.ts
  40. 30 4
      apps/app/src/interfaces/page.ts
  41. 2 1
      apps/app/src/interfaces/user-ui-settings.ts
  42. 3 44
      apps/app/src/pages/[[...path]].page.tsx
  43. 8 9
      apps/app/src/server/models/bookmark-folder.ts
  44. 1 1
      apps/app/src/server/models/user-group-relation.ts
  45. 5 1
      apps/app/src/server/models/user-ui-settings.ts
  46. 8 8
      apps/app/src/server/routes/apiv3/bookmark-folder.ts
  47. 0 2
      apps/app/src/server/routes/apiv3/index.js
  48. 0 54
      apps/app/src/server/routes/apiv3/me.ts
  49. 24 43
      apps/app/src/server/routes/apiv3/page/index.ts
  50. 127 43
      apps/app/src/server/service/page-grant.ts
  51. 3 3
      apps/app/src/stores/page.tsx
  52. 18 3
      apps/app/src/stores/ui.tsx
  53. 2 10
      apps/app/src/stores/user-group.tsx
  54. 13 36
      apps/app/test/cypress/e2e/23-editor/23-editor--template-modal.cy.ts
  55. 27 41
      apps/app/test/cypress/e2e/23-editor/23-editor--with-navigation.cy.ts
  56. 164 18
      apps/app/test/integration/service/page-grant.test.ts
  57. 1 1
      packages/editor/src/components/CodeMirrorEditor/Toolbar/TemplateButton.tsx
  58. 3 3
      yarn.lock

+ 4 - 2
apps/app/public/static/locales/en_US/commons.json

@@ -61,7 +61,8 @@
     "no_notification": "You don't have any notificatios.",
     "all": "All",
     "unopend": "Unread",
-    "mark_all_as_read": "Mark all as read"
+    "mark_all_as_read": "Mark all as read",
+    "only_unread": "Only unread"
   },
 
   "personal_dropdown": {
@@ -93,7 +94,8 @@
     "Page URL": "Page URL",
     "Permanent link": "Permanent link",
     "Page path and permanent link": "Page path and permanent link",
-    "Markdown link": "Markdown link"
+    "Markdown link": "Markdown link",
+    "Append params": "Append params"
   },
 
   "crop_image_modal": {

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

@@ -87,6 +87,7 @@
   "Go to this version": "View this version",
   "View diff": "View diff",
   "No diff": "No diff",
+  "Latest": "Latest",
   "User ID": "User ID",
   "User Information": "User information",
   "User Activation": "User Activation",
@@ -126,6 +127,7 @@
   "Only me": "Only me",
   "Only inside the group": "Only inside the group",
   "page_list": "Page List",
+  "comments": "Comments",
   "Reselect the group": "Reselect the group",
   "Shareable link": "Shareable link",
   "The whitelist of registration permission E-mail address": "The whitelist of registration permission E-mail address",
@@ -147,6 +149,7 @@
   "wide_view": "Wide View",
   "Recent Changes": "Recent Changes",
   "Page Tree": "Page Tree",
+  "Bookmarks": "Bookmarks",
   "In-App Notification": "Notifications",
   "original_path": "Original path",
   "new_path": "New path",
@@ -173,6 +176,10 @@
   "custom_navigation": {
     "no_pages_under_this_page": "There are no pages under this page."
   },
+  "author_info": {
+    "created_at": "Created at",
+    "last_revision_posted_at": "Last revision posted at"
+  },
   "installer": {
     "tab": "Create account",
     "title": "Installer",
@@ -785,7 +792,7 @@
     "paths_recovered": "Paths recovered successfully",
     "path_recovery_failed": "Path recovery failed"
   },
-  "footer": {
+  "user_home_page": {
     "bookmarks": "Bookmarks",
     "recently_created": "Recently Created"
   },

+ 4 - 2
apps/app/public/static/locales/ja_JP/commons.json

@@ -63,7 +63,8 @@
     "no_notification": "通知はありません",
     "all": "全て",
     "unopend": "未読",
-    "mark_all_as_read": "全て既読にする"
+    "mark_all_as_read": "全て既読にする",
+    "only_unread": "未読のみ"
   },
 
   "personal_dropdown": {
@@ -95,7 +96,8 @@
     "Page URL": "ページURL",
     "Permanent link": "パーマリンク",
     "Page path and permanent link": "ページ名とパーマリンク",
-    "Markdown link": "マークダウン形式のリンク"
+    "Markdown link": "マークダウン形式のリンク",
+    "Append params": "パラメータの追加"
   },
 
   "crop_image_modal": {

+ 9 - 2
apps/app/public/static/locales/ja_JP/translation.json

@@ -57,7 +57,7 @@
   "Timeline View": "タイムライン",
   "History": "更新履歴",
   "attachment_data": "添付データ",
-  "No_attachments_yet": "No attachments yet.",
+  "No_attachments_yet": "添付データはありません",
   "Presentation Mode": "プレゼンテーション",
   "Not available for guest": "ゲストユーザーは利用できません",
   "Not available in this version": "このバージョンでは利用できません",
@@ -83,6 +83,7 @@
   "Go to this version": "このバージョンを見る",
   "View diff": "差分を表示",
   "No diff": "差分なし",
+  "Latest": "最新",
   "User ID": "ユーザーID",
   "User Settings": "ユーザー設定",
   "User Information": "ユーザー情報",
@@ -125,6 +126,7 @@
   "Only me": "自分のみ",
   "Only inside the group": "特定グループのみ",
   "page_list": "ページリスト",
+  "comments": "コメント",
   "Reselect the group": "グループの再選択",
   "Shareable link": "このページの共有用URL",
   "The whitelist of registration permission E-mail address": "登録許可メールアドレスの<br>ホワイトリスト",
@@ -148,6 +150,7 @@
   "wide_view": "ワイドビュー",
   "Recent Changes": "最新の変更",
   "Page Tree": "ページツリー",
+  "Bookmarks": "ブックマーク",
   "In-App Notification": "通知",
   "original_path": "元のパス",
   "new_path": "新しいパス",
@@ -174,6 +177,10 @@
   "custom_navigation": {
     "no_pages_under_this_page": "このページの配下にはページが存在しません。"
   },
+  "author_info": {
+    "created_at": "作成日",
+    "last_revision_posted_at": "最終更新日"
+  },
   "installer": {
     "tab": "アカウント作成",
     "title": "インストーラー",
@@ -818,7 +825,7 @@
     "paths_recovered": "パスを修復しました",
     "path_recovery_failed": "パスを修復できませんでした"
   },
-  "footer": {
+  "user_home_page": {
     "bookmarks": "ブックマーク",
     "recently_created": "最近作成したページ"
   },

+ 4 - 2
apps/app/public/static/locales/zh_CN/commons.json

@@ -64,7 +64,8 @@
     "no_notification": "您没有任何通知",
     "all": "全部",
     "unopend": "未读",
-    "mark_all_as_read" : "标记为已读"
+    "mark_all_as_read" : "标记为已读",
+    "only_unread": "Only unread"
   },
 
   "personal_dropdown": {
@@ -96,7 +97,8 @@
 		"Page URL": "页面Url",
 		"Parmanent link": "参数化链接",
 		"Page path and parmanent link": "页面路径及参数化链接",
-		"Markdown link": "Markdown链接"
+		"Markdown link": "Markdown链接",
+    "Append params": "Append params"
 	},
 
   "crop_image_modal": {

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

@@ -85,6 +85,7 @@
   "Go to this version": "查看此版本",
   "View diff": "查看差异",
   "No diff": "无差异",
+  "Latest": "最新",
   "User ID": "用户ID",
   "Home": "首页",
   "My Drafts": "My Drafts",
@@ -133,6 +134,7 @@
   "Only me": "只有我",
   "Only inside the group": "仅组内",
   "page_list": "Page List",
+  "comments": "Comments",
   "Reselect the group": "重新选择组",
   "Shareable link": "可分享链接",
   "The whitelist of registration permission E-mail address": "注册许可电子邮件地址的白名单",
@@ -153,6 +155,7 @@
   "wide_view": "视野开阔",
   "Recent Changes": "最新修改",
   "Page Tree": "页面树",
+  "Bookmarks": "书签",
   "In-App Notification": "通知",
   "original_path": "Original path",
   "new_path": "New path",
@@ -180,6 +183,10 @@
   "custom_navigation": {
     "no_pages_under_this_page": "There are no pages under this page."
   },
+  "author_info": {
+    "created_at": "Created at",
+    "last_revision_posted_at": "Last revision posted at"
+  },
   "installer": {
     "tab": "创建账户",
     "title": "安装",
@@ -788,7 +795,7 @@
     "paths_recovered": "成功恢复了页面路径",
     "path_recovery_failed": "路径恢复失败"
   },
-  "footer": {
+  "user_home_page": {
     "bookmarks": "书签",
     "recently_created": "最近创建页面"
   },

+ 8 - 12
apps/app/src/client/util/bookmark-utils.ts

@@ -1,18 +1,12 @@
 import type { IRevision, Ref } from '@growi/core';
 
-import type { BookmarkFolderItems } from '~/interfaces/bookmark-info';
+import type { BookmarkFolderItems, BookmarkedPage } from '~/interfaces/bookmark-info';
 
 import { apiv3Delete, apiv3Post, apiv3Put } from './apiv3-client';
 
-// Check if bookmark folder item has children
-export const hasChildren = (item: BookmarkFolderItems | BookmarkFolderItems[]): boolean => {
-  if (item === null) {
-    return false;
-  }
-  if (Array.isArray(item)) {
-    return item.length > 0;
-  }
-  return item.children && item.children.length > 0;
+// Check if bookmark folder item has childFolder or bookmarks
+export const hasChildren = ({ childFolder, bookmarks }: { childFolder?: BookmarkFolderItems[], bookmarks?: BookmarkedPage[] }): boolean => {
+  return !!((childFolder && childFolder.length > 0) || (bookmarks && bookmarks.length > 0));
 };
 
 // Add new folder helper
@@ -41,8 +35,10 @@ export const toggleBookmark = async(pageId: string, status: boolean): Promise<vo
 };
 
 // Update Bookmark folder
-export const updateBookmarkFolder = async(bookmarkFolderId: string, name: string, parent: string | null, children: BookmarkFolderItems[]): Promise<void> => {
+export const updateBookmarkFolder = async(
+    bookmarkFolderId: string, name: string, parent: string | null, childFolder: BookmarkFolderItems[],
+): Promise<void> => {
   await apiv3Put('/bookmark-folder', {
-    bookmarkFolderId, name, parent, children,
+    bookmarkFolderId, name, parent, childFolder,
   });
 };

+ 4 - 2
apps/app/src/components/AuthorInfo/AuthorInfo.tsx

@@ -4,6 +4,7 @@ import type { IUser } from '@growi/core';
 import { pagePathUtils } from '@growi/core/dist/utils';
 import { UserPicture } from '@growi/ui/dist/components';
 import { format } from 'date-fns';
+import { useTranslation } from 'next-i18next';
 import Link from 'next/link';
 
 
@@ -18,6 +19,7 @@ export type AuthorInfoProps = {
 }
 
 export const AuthorInfo = (props: AuthorInfoProps): JSX.Element => {
+  const { t } = useTranslation();
   const {
     date, user, mode = 'create', locate = 'subnav',
   } = props;
@@ -31,8 +33,8 @@ export const AuthorInfo = (props: AuthorInfoProps): JSX.Element => {
     ? 'Created by'
     : 'Updated by';
   const infoLabelForFooter = mode === 'create'
-    ? 'Created at'
-    : 'Last revision posted at';
+    ? t('author_info.created_at')
+    : t('author_info.last_revision_posted_at');
   const userLabel = user != null
     ? (
       <Link href={pagePathUtils.userHomepagePath(user)} prefetch={false}>

+ 31 - 29
apps/app/src/components/Bookmarks/BookmarkFolderItem.tsx

@@ -1,6 +1,5 @@
-import {
-  FC, useCallback, useState,
-} from 'react';
+import type { FC } from 'react';
+import { useCallback, useState } from 'react';
 
 import type { IPageToDeleteWithMeta } from '@growi/core';
 import { DropdownToggle } from 'reactstrap';
@@ -10,10 +9,9 @@ import {
 } from '~/client/util/bookmark-utils';
 import { toastError } from '~/client/util/toastr';
 import { FolderIcon } from '~/components/Icons/FolderIcon';
-import {
-  BookmarkFolderItems, DragItemDataType, DragItemType, DRAG_ITEM_TYPE,
-} from '~/interfaces/bookmark-info';
-import { onDeletedBookmarkFolderFunction } from '~/interfaces/ui';
+import type { BookmarkFolderItems, DragItemDataType, DragItemType } from '~/interfaces/bookmark-info';
+import { DRAG_ITEM_TYPE } from '~/interfaces/bookmark-info';
+import type { onDeletedBookmarkFolderFunction } from '~/interfaces/ui';
 import { useBookmarkFolderDeleteModal } from '~/stores/modal';
 
 import { BookmarkFolderItemControl } from './BookmarkFolderItemControl';
@@ -42,7 +40,7 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
   } = props;
 
   const {
-    name, _id: folderId, children, parent, bookmarks,
+    name, _id: folderId, childFolder, parent, bookmarks,
   } = bookmarkFolder;
 
   const [targetFolder, setTargetFolder] = useState<string | null>(folderId);
@@ -52,7 +50,7 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
 
   const { open: openDeleteBookmarkFolderModal } = useBookmarkFolderDeleteModal();
 
-  const childrenExists = hasChildren(children);
+  const childrenExists = hasChildren({ childFolder, bookmarks });
 
   const paddingLeft = BASE_FOLDER_PADDING * level;
 
@@ -65,14 +63,14 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
   const onPressEnterHandlerForRename = useCallback(async(folderName: string) => {
     try {
       // TODO: do not use any type
-      await updateBookmarkFolder(folderId, folderName, parent as any, children);
+      await updateBookmarkFolder(folderId, folderName, parent as any, childFolder);
       bookmarkFolderTreeMutation();
       setIsRenameAction(false);
     }
     catch (err) {
       toastError(err);
     }
-  }, [bookmarkFolderTreeMutation, children, folderId, parent]);
+  }, [bookmarkFolderTreeMutation, childFolder, folderId, parent]);
 
   // Create new folder / subfolder handler
   const onPressEnterHandlerForCreate = useCallback(async(folderName: string) => {
@@ -99,7 +97,7 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
     if (dragItemType === DRAG_ITEM_TYPE.FOLDER) {
       try {
         if (item.bookmarkFolder != null) {
-          await updateBookmarkFolder(item.bookmarkFolder._id, item.bookmarkFolder.name, bookmarkFolder._id, item.bookmarkFolder.children);
+          await updateBookmarkFolder(item.bookmarkFolder._id, item.bookmarkFolder.name, bookmarkFolder._id, item.bookmarkFolder.childFolder);
           bookmarkFolderTreeMutation();
         }
       }
@@ -129,7 +127,7 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
       // Maximum folder hierarchy of 2 levels
       // If the drop source folder has child folders, the drop source folder cannot be moved because the drop source folder hierarchy is already 2.
       // If the destination folder has a parent, the source folder cannot be moved because the destination folder hierarchy is already 2.
-      if (item.bookmarkFolder.children.length !== 0 || bookmarkFolder.parent != null) {
+      if (item.bookmarkFolder.childFolder.length !== 0 || bookmarkFolder.parent != null) {
         return false;
       }
 
@@ -142,9 +140,15 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
     return true;
   };
 
+  const triangleBtnClassName = (isOpen: boolean, childrenExists: boolean): string => {
+    if (!childrenExists) {
+      return 'grw-foldertree-triangle-btn btn px-0 opacity-25';
+    }
+    return `grw-foldertree-triangle-btn btn px-0 ${isOpen ? 'grw-foldertree-open' : ''}`;
+  };
 
   const renderChildFolder = () => {
-    return isOpen && children?.map((childFolder) => {
+    return isOpen && childFolder?.map((childFolder) => {
       return (
         <div key={childFolder._id} className="grw-foldertree-item-children">
           <BookmarkFolderItem
@@ -201,13 +205,13 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
 
   const onClickMoveToRootHandlerForBookmarkFolderItemControl = useCallback(async() => {
     try {
-      await updateBookmarkFolder(bookmarkFolder._id, bookmarkFolder.name, null, bookmarkFolder.children);
+      await updateBookmarkFolder(bookmarkFolder._id, bookmarkFolder.name, null, bookmarkFolder.childFolder);
       bookmarkFolderTreeMutation();
     }
     catch (err) {
       toastError(err);
     }
-  }, [bookmarkFolder._id, bookmarkFolder.children, bookmarkFolder.name, bookmarkFolderTreeMutation]);
+  }, [bookmarkFolder._id, bookmarkFolder.childFolder, bookmarkFolder.name, bookmarkFolderTreeMutation]);
 
   return (
     <div id={`grw-bookmark-folder-item-${folderId}`} className="grw-foldertree-item-container">
@@ -221,22 +225,20 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
         isDropable={isDropable}
       >
         <li
-          className="list-group-item list-group-item-action border-0 py-0 pe-3 d-flex align-items-center"
+          className="list-group-item list-group-item-action border-0 py-2 d-flex align-items-center rounded"
           onClick={loadChildFolder}
           style={{ paddingLeft }}
         >
           <div className="grw-triangle-container d-flex justify-content-center">
-            {childrenExists && (
-              <button
-                type="button"
-                className={`grw-foldertree-triangle-btn btn ${isOpen ? 'grw-foldertree-open' : ''}`}
-                onClick={loadChildFolder}
-              >
-                <div className="d-flex justify-content-center">
-                  <span className="material-symbols-outlined">arrow_right</span>
-                </div>
-              </button>
-            )}
+            <button
+              type="button"
+              className={triangleBtnClassName(isOpen, childrenExists)}
+              onClick={loadChildFolder}
+            >
+              <div className="d-flex justify-content-center">
+                <span className="material-symbols-outlined">arrow_right</span>
+              </div>
+            </button>
           </div>
           <div>
             <FolderIcon isOpen={isOpen} />
@@ -249,7 +251,7 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
             />
           ) : (
             <>
-              <div className="grw-foldertree-title-anchor ps-2">
+              <div className="grw-foldertree-title-anchor ps-1">
                 <p className="text-truncate m-auto ">{name}</p>
               </div>
             </>

+ 1 - 1
apps/app/src/components/Bookmarks/BookmarkFolderMenu.tsx

@@ -156,7 +156,7 @@ export const BookmarkFolderMenu = (props: BookmarkFolderMenuProps): JSX.Element
                     isSelected={selectedItem === folder._id}
                   />
                 </div>
-                {folder.children?.map(child => (
+                {folder.childFolder?.map(child => (
                   <div key={child._id}>
                     <div
                       className="dropdown-item grw-bookmark-folder-menu-item grw-bookmark-folder-menu-item-folder-second list-group-item list-group-item-action"

+ 4 - 4
apps/app/src/components/Bookmarks/BookmarkFolderTree.module.scss

@@ -33,6 +33,9 @@ $grw-bookmark-item-padding-left: 35px;
       .grw-visible-on-hover {
         display: block;
       }
+      .page-list-meta {
+        display: none;
+      }
     }
 
     .grw-foldertree-triangle-btn {
@@ -54,10 +57,7 @@ $grw-bookmark-item-padding-left: 35px;
 
   .grw-foldertree-item-container {
     .grw-triangle-container {
-      // TODO: ignore width frickering
-      // https://redmine.weseek.co.jp/issues/130828
-      // min-width: 35px;
-      height: 40px;
+      height:30px;
     }
 
     .grw-bookmark-item-list{

+ 2 - 2
apps/app/src/components/Bookmarks/BookmarkFolderTree.tsx

@@ -6,7 +6,7 @@ import { useTranslation } from 'next-i18next';
 import { useRouter } from 'next/router';
 
 import { toastSuccess } from '~/client/util/toastr';
-import { OnDeletedFunction } from '~/interfaces/ui';
+import type { OnDeletedFunction } from '~/interfaces/ui';
 import {
   useSWRxUserBookmarks, useSWRMUTxCurrentUserBookmarks,
 } from '~/stores/bookmark';
@@ -103,7 +103,7 @@ export const BookmarkFolderTree: React.FC<Props> = (props: Props) => {
 
   return (
     <div className={`grw-folder-tree-container ${styles['grw-folder-tree-container']}`}>
-      <ul className={`grw-foldertree ${styles['grw-foldertree']} list-group px-2 py-2`}>
+      <ul className={`grw-foldertree ${styles['grw-foldertree']} list-group py-2`}>
         {bookmarkFolders?.map((bookmarkFolder) => {
           return (
             <BookmarkFolderItem

+ 3 - 3
apps/app/src/components/Bookmarks/BookmarkItem.tsx

@@ -39,7 +39,7 @@ type Props = {
 
 export const BookmarkItem = (props: Props): JSX.Element => {
   const BASE_FOLDER_PADDING = 15;
-  const BASE_BOOKMARK_PADDING = 20;
+  const BASE_BOOKMARK_PADDING = 16;
 
   const { t } = useTranslation();
   const router = useRouter();
@@ -56,7 +56,7 @@ export const BookmarkItem = (props: Props): JSX.Element => {
   const dPagePath = new DevidedPagePath(bookmarkedPage.path, false, true);
   const { latter: pageTitle, former: formerPagePath } = dPagePath;
   const bookmarkItemId = `bookmark-item-${bookmarkedPage._id}`;
-  const paddingLeft = BASE_BOOKMARK_PADDING + (BASE_FOLDER_PADDING * (level + 1));
+  const paddingLeft = BASE_BOOKMARK_PADDING + (BASE_FOLDER_PADDING * (level));
   const dragItem: Partial<DragItemDataType> = {
     ...bookmarkedPage, parentFolder,
   };
@@ -148,7 +148,7 @@ export const BookmarkItem = (props: Props): JSX.Element => {
       useDragMode={isOperable}
     >
       <li
-        className="grw-bookmark-item-list list-group-item list-group-item-action border-0 py-0 me-auto d-flex align-items-center"
+        className="grw-bookmark-item-list list-group-item list-group-item-action border-0 py-0 pe-1 me-auto d-flex align-items-center rounded"
         key={bookmarkedPage._id}
         id={bookmarkItemId}
         style={{ paddingLeft }}

+ 2 - 1
apps/app/src/components/Common/CopyDropdown/CopyDropdown.jsx

@@ -120,6 +120,7 @@ export const CopyDropdown = (props) => {
 
         <DropdownMenu
           strategy="fixed"
+          container="body"
         >
           <div className="d-flex align-items-center justify-content-between">
             <DropdownItem header className="px-3">
@@ -134,7 +135,7 @@ export const CopyDropdown = (props) => {
                   checked={isParamsAppended}
                   onChange={toggleAppendParams}
                 />
-                <label className="form-label form-check-label small" htmlFor={customSwitchForParamsId}>Append params</label>
+                <label className="form-label form-check-label small" htmlFor={customSwitchForParamsId}>{ t('copy_to_clipboard.Append params') }</label>
               </div>
             ) }
           </div>

+ 0 - 10
apps/app/src/components/Common/PagePathNav/PagePathNav.module.scss

@@ -21,16 +21,6 @@
       font-size: 1.75rem !important;
     }
   }
-  // avoid sticky-top nav to turnate page path
-  .is-collapse-with-top {
-    max-width: calc(100% - 100px);
-    @include bs.media-breakpoint-up(sm) {
-      max-width: calc(100% - 350px);
-    }
-    @include bs.media-breakpoint-up(md) {
-      max-width: calc(100% - 500px);
-    }
-  }
 }
 
 .grw-page-path-nav :global {

+ 62 - 15
apps/app/src/components/Common/PagePathNav/PagePathNav.tsx

@@ -1,5 +1,8 @@
-import type { FC } from 'react';
-import React from 'react';
+import React, {
+  useEffect,
+  useRef,
+  useState,
+} from 'react';
 
 import { DevidedPagePath } from '@growi/core/dist/models';
 import { pagePathUtils } from '@growi/core/dist/utils';
@@ -7,6 +10,9 @@ import dynamic from 'next/dynamic';
 import Sticky from 'react-stickynode';
 
 import { useIsNotFound } from '~/stores/page';
+import {
+  usePageControlsX, useCurrentProductNavWidth, useSidebarMode,
+} from '~/stores/ui';
 
 import LinkedPagePath from '../../../models/linked-page-path';
 import { PagePathHierarchicalLink } from '../PagePathHierarchicalLink';
@@ -25,6 +31,7 @@ type Props = {
   isCollapseParents?: boolean,
   formerLinkClassName?: string,
   latterLinkClassName?: string,
+  maxWidth?: number,
 }
 
 const CopyDropdown = dynamic(() => import('../CopyDropdown').then(mod => mod.CopyDropdown), { ssr: false });
@@ -33,10 +40,10 @@ const Separator = ({ className }: {className?: string}): JSX.Element => {
   return <span className={`separator ${className ?? ''} ${styles['grw-mx-02em']}`}>/</span>;
 };
 
-export const PagePathNav: FC<Props> = (props: Props) => {
+export const PagePathNav = (props: Props): JSX.Element => {
   const {
     pageId, pagePath, isWipPage, isSingleLineMode, isCollapseParents,
-    formerLinkClassName, latterLinkClassName,
+    formerLinkClassName, latterLinkClassName, maxWidth,
   } = props;
   const dPagePath = new DevidedPagePath(pagePath, false, true);
 
@@ -82,7 +89,7 @@ export const PagePathNav: FC<Props> = (props: Props) => {
   const copyDropdownId = `copydropdown-${pageId}`;
 
   return (
-    <div>
+    <div style={{ maxWidth }}>
       <span className={`${formerLinkClassName ?? ''} ${styles['grw-former-link']}`}>{formerLink}</span>
       <div className="d-flex align-items-center">
         <h1 className={`m-0 ${latterLinkClassName}`}>
@@ -103,25 +110,65 @@ export const PagePathNav: FC<Props> = (props: Props) => {
   );
 };
 
+PagePathNav.displayName = 'PagePathNav';
+
 
 type PagePathNavStickyProps = Omit<Props, 'isCollapseParents'>;
 
 export const PagePathNavSticky = (props: PagePathNavStickyProps): JSX.Element => {
+
+  const { data: pageControlsX } = usePageControlsX();
+  const { data: sidebarWidth } = useCurrentProductNavWidth();
+  const { data: sidebarMode } = useSidebarMode();
+  const pagePathNavRef = useRef<HTMLDivElement>(null);
+
+  const [navMaxWidth, setNavMaxWidth] = useState<number | undefined>();
+
+  useEffect(() => {
+    if (pageControlsX == null || pagePathNavRef.current == null || sidebarWidth == null) {
+      return;
+    }
+    setNavMaxWidth(pageControlsX - pagePathNavRef.current.getBoundingClientRect().x - 10);
+  }, [pageControlsX, pagePathNavRef, sidebarWidth]);
+
+  useEffect(() => {
+    // wait for the end of the animation of the opening and closing of the sidebar
+    const timeout = setTimeout(() => {
+      if (pageControlsX == null || pagePathNavRef.current == null || sidebarMode == null) {
+        return;
+      }
+      setNavMaxWidth(pageControlsX - pagePathNavRef.current.getBoundingClientRect().x - 10);
+    }, 200);
+    return () => {
+      clearTimeout(timeout);
+    };
+  }, [pageControlsX, pagePathNavRef, sidebarMode]);
+
   return (
     // Controlling pointer-events
     //  1. disable pointer-events with 'pe-none'
-    <Sticky className={`${styles['grw-page-path-nav-sticky']} mb-4`} innerClass="mt-1 pe-none" innerActiveClass="active">
-      {({ status }: { status: boolean }) => {
-        const isCollapseParents = status === Sticky.STATUS_FIXED;
-        return (
+    <div ref={pagePathNavRef}>
+      <Sticky className={`${styles['grw-page-path-nav-sticky']} mb-4`} innerClass="mt-1 pe-none" innerActiveClass="active">
+        {({ status }: { status: boolean }) => {
+          const isCollapseParents = status === Sticky.STATUS_FIXED;
+          return (
           // Controlling pointer-events
           //  2. enable pointer-events with 'pe-auto' only against the children
           //      which width is minimized by 'd-inline-block'
-          <div className={`d-inline-block pe-auto ${isCollapseParents ? 'is-collapse-with-top' : ''}`}>
-            <PagePathNav {...props} isCollapseParents={isCollapseParents} latterLinkClassName={isCollapseParents ? 'fs-3  text-truncate' : 'fs-2'} />
-          </div>
-        );
-      }}
-    </Sticky>
+          //
+            <div className="d-inline-block pe-auto">
+              <PagePathNav
+                {...props}
+                isCollapseParents={isCollapseParents}
+                latterLinkClassName={isCollapseParents ? 'fs-3  text-truncate' : 'fs-2'}
+                maxWidth={isCollapseParents ? navMaxWidth : undefined}
+              />
+            </div>
+          );
+        }}
+      </Sticky>
+    </div>
   );
 };
+
+PagePathNavSticky.displayName = 'PagePathNavSticky';

+ 2 - 2
apps/app/src/components/ContentLinkButtons.tsx

@@ -13,7 +13,7 @@ const BookMarkLinkButton = React.memo(() => {
         className="btn btn-sm btn-outline-neutral-secondary rounded-pill d-flex align-items-center w-100"
       >
         <span className="material-symbols-outlined p-0">bookmark</span>
-        <span>{t('footer.bookmarks')}</span>
+        <span>{t('user_home_page.bookmarks')}</span>
       </button>
     </ScrollLink>
   );
@@ -30,7 +30,7 @@ const RecentlyCreatedLinkButton = React.memo(() => {
         className="btn btn-sm btn-outline-neutral-secondary rounded-pill d-flex align-items-center w-100"
       >
         <span className="growi-custom-icons mx-1">recently_created</span>
-        <span>{t('footer.recently_created')}</span>
+        <span>{t('user_home_page.recently_created')}</span>
       </button>
     </ScrollLink>
   );

+ 2 - 2
apps/app/src/components/Icons/FolderIcon.tsx

@@ -9,10 +9,10 @@ export const FolderIcon = (props: Props): JSX.Element => {
   return (
     <>
       {!isOpen ? (
-        <span className="material-symbols-outlined">folder_open</span>
+        <span className="material-symbols-outlined">folder</span>
 
       ) : (
-        <span className="material-symbols-outlined">folder</span>
+        <span className="material-symbols-outlined">folder_open</span>
       )
       }
     </>

+ 8 - 2
apps/app/src/components/Navbar/GrowiNavbarBottom.tsx

@@ -1,4 +1,6 @@
-import React from 'react';
+import React, {
+  useCallback,
+} from 'react';
 
 import { useSearchModal } from '~/features/search/client/stores/search';
 import { useIsSearchPage } from '~/stores/context';
@@ -19,6 +21,10 @@ export const GrowiNavbarBottom = (): JSX.Element => {
   const { data: isSearchPage } = useIsSearchPage();
   const { open: openSearchModal } = useSearchModal();
 
+  const searchButtonClickHandler = useCallback(() => {
+    openSearchModal();
+  }, [openSearchModal]);
+
   return (
     <GroundGlassBar className={`
       ${styles['grw-navbar-bottom']}
@@ -54,7 +60,7 @@ export const GrowiNavbarBottom = (): JSX.Element => {
                 <a
                   role="button"
                   className="nav-link btn-lg"
-                  onClick={openSearchModal}
+                  onClick={searchButtonClickHandler}
                 >
                   <span className="material-symbols-outlined fs-2">search</span>
                 </a>

+ 15 - 6
apps/app/src/components/PageAlert/FixPageGrantAlert.tsx

@@ -8,10 +8,10 @@ import {
 
 import { apiv3Put } from '~/client/util/apiv3-client';
 import { toastError, toastSuccess } from '~/client/util/toastr';
-import type { IPageGrantData } from '~/interfaces/page';
+import { UserGroupPageGrantStatus, type IPageGrantData } from '~/interfaces/page';
 import type { PopulatedGrantedGroup, IRecordApplicableGrant, IResIsGrantNormalizedGrantData } from '~/interfaces/page-grant';
 import { useCurrentUser } from '~/stores/context';
-import { useSWRxApplicableGrant, useSWRxIsGrantNormalized, useSWRxCurrentPage } from '~/stores/page';
+import { useSWRxApplicableGrant, useSWRxCurrentGrantData, useSWRxCurrentPage } from '~/stores/page';
 
 type ModalProps = {
   isOpen: boolean
@@ -99,10 +99,19 @@ const FixPageGrantModal = (props: ModalProps): JSX.Element => {
     }
 
     if (grantData.grant === 5) {
-      if (grantData.userRelatedGrantedGroups == null || grantData.userRelatedGrantedGroups.length === 0) {
-        return t('fix_page_grant.modal.grant_label.isForbidden');
+      const groupGrantData = grantData.groupGrantData;
+      if (groupGrantData != null) {
+        const userRelatedGrantedGroups = groupGrantData.userRelatedGroups.filter(group => group.status === UserGroupPageGrantStatus.isGranted);
+        if (userRelatedGrantedGroups.length > 0) {
+          const grantedGroupNames = [
+            ...userRelatedGrantedGroups.map(group => group.name),
+            ...groupGrantData.nonUserRelatedGrantedGroups.map(group => group.name),
+          ];
+          return `${t('fix_page_grant.modal.radio_btn.grant_group')} (${grantedGroupNames.join(', ')})`;
+        }
       }
-      return `${t('fix_page_grant.modal.radio_btn.grant_group')} (${grantData.userRelatedGrantedGroups.map(g => g.name).join(', ')})`;
+
+      return t('fix_page_grant.modal.grant_label.isForbidden');
     }
 
     throw Error('cannot get grant label'); // this error can't be throwed
@@ -278,7 +287,7 @@ export const FixPageGrantAlert = (): JSX.Element => {
 
   const [isOpen, setOpen] = useState<boolean>(false);
 
-  const { data: dataIsGrantNormalized } = useSWRxIsGrantNormalized(currentUser != null ? pageId : null);
+  const { data: dataIsGrantNormalized } = useSWRxCurrentGrantData(currentUser != null ? pageId : null);
   const { data: dataApplicableGrant } = useSWRxApplicableGrant(currentUser != null ? pageId : null);
 
   // Dependencies

+ 21 - 4
apps/app/src/components/PageControls/PageControls.tsx

@@ -1,4 +1,6 @@
-import React, { memo, useCallback, useMemo } from 'react';
+import React, {
+  memo, useCallback, useEffect, useMemo, useRef,
+} from 'react';
 
 import type {
   IPageInfoForOperation, IPageToDeleteWithMeta, IPageToRenameWithMeta,
@@ -6,6 +8,7 @@ import type {
 import {
   isIPageInfoForEntity, isIPageInfoForOperation,
 } from '@growi/core';
+import { useRect } from '@growi/ui/dist/utils';
 import { useTranslation } from 'next-i18next';
 import { DropdownItem } from 'reactstrap';
 
@@ -15,7 +18,9 @@ import {
 import { toastError } from '~/client/util/toastr';
 import { useIsGuestUser, useIsReadOnlyUser } from '~/stores/context';
 import { useTagEditModal, type IPageForPageDuplicateModal } from '~/stores/modal';
-import { EditorMode, useEditorMode, useIsDeviceLargerThanMd } from '~/stores/ui';
+import {
+  EditorMode, useEditorMode, useIsDeviceLargerThanMd, usePageControlsX,
+} from '~/stores/ui';
 import loggerFactory from '~/utils/logger';
 
 import { useSWRxPageInfo, useSWRxTagsInfo } from '../../stores/page';
@@ -132,6 +137,19 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
   const likerIds = isIPageInfoForEntity(pageInfo) ? (pageInfo.likerIds ?? []).slice(0, 15) : [];
   const seenUserIds = isIPageInfoForEntity(pageInfo) ? (pageInfo.seenUserIds ?? []).slice(0, 15) : [];
 
+  const { mutateAndSave: mutatePageControlsX } = usePageControlsX();
+
+  const pageControlsRef = useRef<HTMLDivElement>(null);
+  const [pageControlsRect] = useRect(pageControlsRef);
+
+  useEffect(() => {
+    if (pageControlsRect?.x == null) {
+      return;
+    }
+    mutatePageControlsX(pageControlsRect.x);
+  }, [pageControlsRect?.x, mutatePageControlsX]);
+
+
   // Put in a mixture of seenUserIds and likerIds data to make the cache work
   const { data: usersList } = useSWRxUsersList([...likerIds, ...seenUserIds]);
   const likers = usersList != null ? usersList.filter(({ _id }) => likerIds.includes(_id)).slice(0, 15) : [];
@@ -253,7 +271,7 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
   const isViewMode = editorMode === EditorMode.View;
 
   return (
-    <div className={`grw-page-controls ${styles['grw-page-controls']} d-flex`} style={{ gap: '2px' }}>
+    <div className={`grw-page-controls ${styles['grw-page-controls']} d-flex`} style={{ gap: '2px' }} ref={pageControlsRef}>
       { isDeviceLargerThanMd && (
         <SearchButton />
       )}
@@ -292,7 +310,6 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
       ) }
       { showPageControlDropdown && _isIPageInfoForOperation && (
         <PageItemControl
-          alignEnd
           pageId={pageId}
           pageInfo={pageInfo}
           isEnableActions={!isGuestUser}

+ 10 - 9
apps/app/src/components/PageEditor/PageEditor.tsx

@@ -36,7 +36,7 @@ import {
   useWaitingSaveProcessing,
 } from '~/stores/editor';
 import {
-  useCurrentPagePath, useSWRxCurrentPage, useCurrentPageId, useIsNotFound, useTemplateBodyData,
+  useCurrentPagePath, useSWRxCurrentPage, useCurrentPageId, useIsNotFound, useTemplateBodyData, useSWRxCurrentGrantData,
 } from '~/stores/page';
 import { mutatePageTree } from '~/stores/page-listing';
 import { usePreviewOptions } from '~/stores/renderer';
@@ -93,7 +93,7 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: currentPathname } = useCurrentPathname();
   const { data: currentPage } = useSWRxCurrentPage();
-  const { data: grantData } = useSelectedGrant();
+  const { data: selectedGrant } = useSelectedGrant();
   const { data: editingMarkdown } = useEditingMarkdown();
   const { data: isEnabledAttachTitleHeader } = useIsEnabledAttachTitleHeader();
   const { data: templateBodyData } = useTemplateBodyData();
@@ -105,6 +105,7 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
   const { data: defaultIndentSize } = useDefaultIndentSize();
   const { data: acceptedUploadFileType } = useAcceptedUploadFileType();
   const { data: editorSettings } = useEditorSettings();
+  const { mutate: mutateIsGrantNormalized } = useSWRxCurrentGrantData(currentPage?._id);
   const { data: user } = useCurrentUser();
   const { onEditorsUpdated } = useEditingUsers();
   const onConflict = useConflictResolver();
@@ -166,9 +167,9 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
   const scrollPreviewHandlerThrottle = useMemo(() => throttle(25, scrollPreviewHandler), [scrollPreviewHandler]);
 
   const save: Save = useCallback(async(revisionId, markdown, opts, onConflict) => {
-    if (pageId == null || grantData == null) {
+    if (pageId == null || selectedGrant == null) {
       logger.error('Some materials to save are invalid', {
-        pageId, grantData,
+        pageId, selectedGrant,
       });
       throw new Error('Some materials to save are invalid');
     }
@@ -181,16 +182,16 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
         revisionId,
         wip: opts?.wip,
         body: markdown ?? '',
-        grant: grantData?.grant,
+        grant: selectedGrant?.grant,
         origin: Origin.Editor,
-        userRelatedGrantUserGroupIds: grantData?.userRelatedGrantedGroups?.map((group) => {
-          return { item: group.id, type: group.type };
-        }),
+        userRelatedGrantUserGroupIds: selectedGrant?.userRelatedGrantedGroups,
         ...(opts ?? {}),
       });
 
       // to sync revision id with page tree: https://github.com/weseek/growi/pull/7227
       mutatePageTree();
+      // sync current grant data after update
+      mutateIsGrantNormalized();
 
       return page;
     }
@@ -210,7 +211,7 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
     finally {
       mutateWaitingSaveProcessing(false);
     }
-  }, [pageId, grantData, mutateWaitingSaveProcessing, t]);
+  }, [pageId, selectedGrant, mutateWaitingSaveProcessing, t, mutateIsGrantNormalized]);
 
   const saveAndReturnToViewHandler = useCallback(async(opts: SaveOptions) => {
     const markdown = codeMirrorEditor?.getDoc();

+ 1 - 0
apps/app/src/components/PageEditor/Preview.tsx

@@ -32,6 +32,7 @@ const Preview = (props: Props): JSX.Element => {
 
   return (
     <div
+      data-testid="page-editor-preview-body"
       className={`${moduleClass} ${fluidLayoutClass} ${pagePath === '/Sidebar' ? 'preview-sidebar' : ''}`}
       style={style}
     >

+ 1 - 1
apps/app/src/components/PageHistory/Revision.tsx

@@ -64,7 +64,7 @@ export const Revision = (props: RevisionProps): JSX.Element => {
         <div className="ms-2">
           <div className="revision-history-author mb-1">
             <strong><Username user={author}></Username></strong>
-            { isLatestRevision && <span className="badge bg-info ms-2">Latest</span> }
+            { isLatestRevision && <span className="badge bg-info ms-2">{t('Latest')}</span> }
           </div>
           <div className="mb-1">
             <UserDate dateTime={revision.createdAt} />

+ 2 - 2
apps/app/src/components/PageList/PageListItemS.tsx

@@ -35,14 +35,14 @@ export const PageListItemS = (props: PageListItemSProps): JSX.Element => {
       <UserPicture user={page.lastUpdateUser} noLink={noLink} />
       {isNarrowView ? (
         <Clamp lines={2}>
-          <div className={`mx-2 ${styles['page-title']} ${noLink ? 'text-break' : ''}`}>
+          <div className={`mx-1 ${styles['page-title']} ${noLink ? 'text-break' : ''}`}>
             {pagePathElement}
           </div>
         </Clamp>
       ) : (
         pagePathElement
       )}
-      <span className="ms-2">
+      <span className="ms-1">
         <PageListMeta page={page} shouldSpaceOutIcon />
       </span>
     </>

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

@@ -122,7 +122,7 @@ export const PageSideContents = (props: PageSideContentsProps): JSX.Element => {
           <div className="d-flex" data-testid="page-comment-button">
             <PageAccessoriesControl
               icon={<span className="material-symbols-outlined">chat</span>}
-              label="Comments"
+              label={t('comments')}
               count={pageInfo != null ? (pageInfo as IPageInfoForOperation).commentCount : undefined}
               onClick={() => scroller.scrollTo('comments-container', { smooth: false, offset: -120 })}
             />

+ 2 - 19
apps/app/src/components/SavePageControls.tsx

@@ -10,7 +10,6 @@ import {
   DropdownToggle, DropdownMenu, DropdownItem, Modal,
 } from 'reactstrap';
 
-import type { IPageGrantData } from '~/interfaces/page';
 import {
   useIsEditable, useIsAclEnabled,
   useIsSlackConfigured,
@@ -18,7 +17,6 @@ import {
 import { useWaitingSaveProcessing, useSWRxSlackChannels, useIsSlackEnabled } from '~/stores/editor';
 import { useSWRxCurrentPage, useCurrentPagePath } from '~/stores/page';
 import {
-  useSelectedGrant,
   useEditorMode, useIsDeviceLargerThanMd,
 
 } from '~/stores/ui';
@@ -131,7 +129,6 @@ export const SavePageControls = (): JSX.Element | null => {
   const { data: currentPage } = useSWRxCurrentPage();
   const { data: isEditable } = useIsEditable();
   const { data: isAclEnabled } = useIsAclEnabled();
-  const { data: grantData, mutate: mutateGrant } = useSelectedGrant();
 
   const { data: editorMode } = useEditorMode();
   const { data: currentPagePath } = useCurrentPagePath();
@@ -161,11 +158,7 @@ export const SavePageControls = (): JSX.Element | null => {
     setSlackChannels(slackChannels);
   }, []);
 
-  const updateGrantHandler = useCallback((grantData: IPageGrantData): void => {
-    mutateGrant(grantData);
-  }, [mutateGrant]);
-
-  if (isEditable == null || isAclEnabled == null || grantData == null) {
+  if (isEditable == null || isAclEnabled == null) {
     return null;
   }
 
@@ -173,8 +166,6 @@ export const SavePageControls = (): JSX.Element | null => {
     return null;
   }
 
-  const { grant, userRelatedGrantedGroups } = grantData;
-
   const isGrantSelectorDisabledPage = isTopPage(currentPage?.path ?? '') || isUsersProtectedPages(currentPage?.path ?? '');
 
   return (
@@ -201,12 +192,7 @@ export const SavePageControls = (): JSX.Element | null => {
             {
               isAclEnabled && (
                 <div className="me-2">
-                  <GrantSelector
-                    grant={grant}
-                    disabled={isGrantSelectorDisabledPage}
-                    userRelatedGrantedGroups={userRelatedGrantedGroups}
-                    onUpdateGrant={updateGrantHandler}
-                  />
+                  <GrantSelector disabled={isGrantSelectorDisabledPage} />
                 </div>
               )
             }
@@ -233,11 +219,8 @@ export const SavePageControls = (): JSX.Element | null => {
                   isAclEnabled && (
                     <>
                       <GrantSelector
-                        grant={grant}
                         disabled={isGrantSelectorDisabledPage}
                         openInModal
-                        userRelatedGrantedGroups={userRelatedGrantedGroups}
-                        onUpdateGrant={updateGrantHandler}
                       />
                     </>
                   )

+ 91 - 53
apps/app/src/components/SavePageControls/GrantSelector/GrantSelector.tsx

@@ -1,7 +1,7 @@
-import React, { useCallback, useState } from 'react';
+import React, { useCallback, useEffect, useState } from 'react';
 
 import {
-  PageGrant, isPopulated, GroupType, type IGrantedGroup,
+  PageGrant, GroupType, getIdForRef,
 } from '@growi/core';
 import { LoadingSpinner } from '@growi/ui/dist/components';
 import { useTranslation } from 'next-i18next';
@@ -12,10 +12,12 @@ import {
   Modal, ModalHeader, ModalBody,
 } from 'reactstrap';
 
-import type { IPageGrantData } from '~/interfaces/page';
+import type { UserRelatedGroupsData } from '~/interfaces/page';
+import { UserGroupPageGrantStatus } from '~/interfaces/page';
 import { useCurrentUser } from '~/stores/context';
+import { useCurrentPageId, useSWRxCurrentGrantData } from '~/stores/page';
+import { useSelectedGrant } from '~/stores/ui';
 
-import { useMyUserGroups } from './use-my-user-groups';
 
 const AVAILABLE_GRANTS = [
   {
@@ -41,14 +43,6 @@ const AVAILABLE_GRANTS = [
 type Props = {
   disabled?: boolean,
   openInModal?: boolean,
-  grant: PageGrant,
-  userRelatedGrantedGroups?: {
-    id: string,
-    name: string,
-    type: GroupType,
-  }[]
-
-  onUpdateGrant?: (grantData: IPageGrantData) => void,
 }
 
 /**
@@ -60,9 +54,6 @@ export const GrantSelector = (props: Props): JSX.Element => {
   const {
     disabled,
     openInModal,
-    userRelatedGrantedGroups,
-    onUpdateGrant,
-    grant: currentGrant,
   } = props;
 
 
@@ -71,12 +62,35 @@ export const GrantSelector = (props: Props): JSX.Element => {
   const { data: currentUser } = useCurrentUser();
 
   const shouldFetch = isSelectGroupModalShown;
-  const { data: myUserGroups, update: updateMyUserGroups } = useMyUserGroups(shouldFetch);
+  const { data: selectedGrant, mutate: mutateSelectedGrant } = useSelectedGrant();
+  const { data: currentPageId } = useCurrentPageId();
+  const { data: grantData } = useSWRxCurrentGrantData(currentPageId);
+
+  const currentPageGrantData = grantData?.grantData.currentPageGrant;
+  const groupGrantData = currentPageGrantData?.groupGrantData;
+
+  const applyCurrentPageGrantToSelectedGrant = useCallback(() => {
+    const currentPageGrant = grantData?.grantData.currentPageGrant;
+    if (currentPageGrant == null) return;
+
+    const userRelatedGrantedGroups = currentPageGrant.groupGrantData
+      ?.userRelatedGroups.filter(group => group.status === UserGroupPageGrantStatus.isGranted)?.map((group) => {
+        return { item: group.id, type: group.type };
+      }) ?? [];
+    mutateSelectedGrant({
+      grant: currentPageGrant.grant,
+      userRelatedGrantedGroups,
+    });
+  }, [grantData?.grantData.currentPageGrant, mutateSelectedGrant]);
+
+  // sync grant data
+  useEffect(() => {
+    applyCurrentPageGrantToSelectedGrant();
+  }, [applyCurrentPageGrantToSelectedGrant]);
 
   const showSelectGroupModal = useCallback(() => {
-    updateMyUserGroups();
     setIsSelectGroupModalShown(true);
-  }, [updateMyUserGroups]);
+  }, []);
 
   /**
    * change event handler for grant selector
@@ -84,28 +98,27 @@ export const GrantSelector = (props: Props): JSX.Element => {
   const changeGrantHandler = useCallback((grant: PageGrant) => {
     // select group
     if (grant === 5) {
+      if (selectedGrant?.grant !== 5) applyCurrentPageGrantToSelectedGrant();
       showSelectGroupModal();
       return;
     }
 
-    if (onUpdateGrant != null) {
-      onUpdateGrant({ grant, userRelatedGrantedGroups: undefined });
+    mutateSelectedGrant({ grant, userRelatedGrantedGroups: undefined });
+  }, [mutateSelectedGrant, showSelectGroupModal, applyCurrentPageGrantToSelectedGrant, selectedGrant?.grant]);
+
+  const groupListItemClickHandler = useCallback((clickedGroup: UserRelatedGroupsData) => {
+    const userRelatedGrantedGroups = selectedGrant?.userRelatedGrantedGroups ?? [];
+
+    let userRelatedGrantedGroupsCopy = [...userRelatedGrantedGroups];
+    if (userRelatedGrantedGroupsCopy.find(group => getIdForRef(group.item) === clickedGroup.id) == null) {
+      const grantGroupInfo = { item: clickedGroup.id, type: clickedGroup.type };
+      userRelatedGrantedGroupsCopy.push(grantGroupInfo);
     }
-  }, [onUpdateGrant, showSelectGroupModal]);
-
-  const groupListItemClickHandler = useCallback((grantGroup: IGrantedGroup) => {
-    if (onUpdateGrant != null && isPopulated(grantGroup.item)) {
-      let userRelatedGrantedGroupsCopy = userRelatedGrantedGroups != null ? [...userRelatedGrantedGroups] : [];
-      const grantGroupInfo = { id: grantGroup.item._id, name: grantGroup.item.name, type: grantGroup.type };
-      if (userRelatedGrantedGroupsCopy.find(group => group.id === grantGroupInfo.id) == null) {
-        userRelatedGrantedGroupsCopy.push(grantGroupInfo);
-      }
-      else {
-        userRelatedGrantedGroupsCopy = userRelatedGrantedGroupsCopy.filter(group => group.id !== grantGroupInfo.id);
-      }
-      onUpdateGrant({ grant: 5, userRelatedGrantedGroups: userRelatedGrantedGroupsCopy });
+    else {
+      userRelatedGrantedGroupsCopy = userRelatedGrantedGroupsCopy.filter(group => getIdForRef(group.item) !== clickedGroup.id);
     }
-  }, [onUpdateGrant, userRelatedGrantedGroups]);
+    mutateSelectedGrant({ grant: 5, userRelatedGrantedGroups: userRelatedGrantedGroupsCopy });
+  }, [mutateSelectedGrant, selectedGrant?.userRelatedGrantedGroups]);
 
   /**
    * Render grant selector DOM.
@@ -115,8 +128,13 @@ export const GrantSelector = (props: Props): JSX.Element => {
     let dropdownToggleBtnColor;
     let dropdownToggleLabelElm;
 
+    const userRelatedGrantedGroups = groupGrantData?.userRelatedGroups.filter((group) => {
+      return selectedGrant?.userRelatedGrantedGroups?.some(grantedGroup => getIdForRef(grantedGroup.item) === group.id);
+    }) ?? [];
+    const nonUserRelatedGrantedGroups = groupGrantData?.nonUserRelatedGrantedGroups ?? [];
+
     const dropdownMenuElems = AVAILABLE_GRANTS.map((opt) => {
-      const label = ((opt.grant === 5 && opt.reselectLabel != null) && userRelatedGrantedGroups != null && userRelatedGrantedGroups.length > 0)
+      const label = ((opt.grant === 5 && opt.reselectLabel != null) && userRelatedGrantedGroups.length > 0)
         ? opt.reselectLabel // when grantGroup is selected
         : opt.label;
 
@@ -128,7 +146,7 @@ export const GrantSelector = (props: Props): JSX.Element => {
       );
 
       // set dropdownToggleBtnColor, dropdownToggleLabelElm
-      if (opt.grant === 1 || opt.grant === currentGrant) {
+      if (opt.grant === 1 || opt.grant === selectedGrant?.grant) {
         dropdownToggleBtnColor = opt.btnStyleClass;
         dropdownToggleLabelElm = labelElm;
       }
@@ -137,19 +155,20 @@ export const GrantSelector = (props: Props): JSX.Element => {
     });
 
     // add specified group option
-    if (userRelatedGrantedGroups != null && userRelatedGrantedGroups.length > 0) {
+    if (selectedGrant?.grant === PageGrant.GRANT_USER_GROUP && (userRelatedGrantedGroups.length > 0 || nonUserRelatedGrantedGroups.length > 0)) {
+      const grantedGroupNames = [...userRelatedGrantedGroups.map(group => group.name), ...nonUserRelatedGrantedGroups.map(group => group.name)];
       const labelElm = (
         <span>
           <span className="material-symbols-outlined me-1">account_tree</span>
           <span className="label">
-            {userRelatedGrantedGroups.length > 1
+            {grantedGroupNames.length > 1
               ? (
               // substring for group name truncate
                 <span>
-                  {`${userRelatedGrantedGroups[0].name.substring(0, 30)}, ... `}
-                  <span className="badge bg-primary">+{userRelatedGrantedGroups.length - 1}</span>
+                  {`${grantedGroupNames[0].substring(0, 30)}, ... `}
+                  <span className="badge bg-primary">+{grantedGroupNames.length - 1}</span>
                 </span>
-              ) : userRelatedGrantedGroups[0].name.substring(0, 30)}
+              ) : grantedGroupNames[0].substring(0, 30)}
           </span>
         </span>
       );
@@ -171,13 +190,13 @@ export const GrantSelector = (props: Props): JSX.Element => {
           >
             {dropdownToggleLabelElm}
           </DropdownToggle>
-          <DropdownMenu container={openInModal ? '' : 'body'}>
+          <DropdownMenu data-testid="grw-grant-selector-dropdown-menu" container={openInModal ? '' : 'body'}>
             {dropdownMenuElems}
           </DropdownMenu>
         </UncontrolledDropdown>
       </div>
     );
-  }, [changeGrantHandler, currentGrant, disabled, userRelatedGrantedGroups, t, openInModal]);
+  }, [changeGrantHandler, disabled, groupGrantData, selectedGrant, t, openInModal]);
 
   /**
    * Render select grantgroup modal.
@@ -188,7 +207,7 @@ export const GrantSelector = (props: Props): JSX.Element => {
     }
 
     // show spinner
-    if (myUserGroups == null) {
+    if (groupGrantData == null) {
       return (
         <div className="my-3 text-center">
           <LoadingSpinner className="mx-auto text-muted fs-4" />
@@ -196,7 +215,9 @@ export const GrantSelector = (props: Props): JSX.Element => {
       );
     }
 
-    if (myUserGroups.length === 0) {
+    const { userRelatedGroups, nonUserRelatedGrantedGroups } = groupGrantData;
+
+    if (userRelatedGroups.length === 0) {
       return (
         <div>
           <h4>{t('user_group.belonging_to_no_group')}</h4>
@@ -209,20 +230,37 @@ export const GrantSelector = (props: Props): JSX.Element => {
 
     return (
       <div className="d-flex flex-column">
-        { myUserGroups.map((group) => {
-          const groupIsGranted = userRelatedGrantedGroups?.find(g => g.id === group.item._id) != null;
-          const activeClass = groupIsGranted ? 'active' : '';
+        { userRelatedGroups.map((group) => {
+          const isGroupGranted = selectedGrant?.userRelatedGrantedGroups?.some(grantedGroup => getIdForRef(grantedGroup.item) === group.id);
+          const cannotGrantGroup = group.status === UserGroupPageGrantStatus.cannotGrant;
+          const activeClass = isGroupGranted ? 'active' : '';
 
           return (
             <button
               className={`btn btn-outline-primary d-flex justify-content-start mb-3 mx-4 align-items-center p-3 ${activeClass}`}
               type="button"
-              key={group.item._id}
+              key={group.id}
               onClick={() => groupListItemClickHandler(group)}
+              disabled={cannotGrantGroup}
+            >
+              <input type="checkbox" checked={isGroupGranted} disabled={cannotGrantGroup} />
+              <p className="ms-3 mb-0">{group.name}</p>
+              {group.type === GroupType.externalUserGroup && <span className="ms-2 badge badge-pill badge-info">{group.provider}</span>}
+              {/* TODO: Replace <div className="small">(TBD) List group members</div> */}
+            </button>
+          );
+        }) }
+        { nonUserRelatedGrantedGroups.map((group) => {
+          return (
+            <button
+              className="btn btn-outline-primary d-flex justify-content-start mb-3 mx-4 align-items-center p-3 active"
+              type="button"
+              key={group.id}
+              disabled
             >
-              <input type="checkbox" checked={groupIsGranted} />
-              <p className="ms-3 mb-0">{group.item.name}</p>
-              {group.type === GroupType.externalUserGroup && <span className="ms-2 badge badge-pill badge-info">{group.item.provider}</span>}
+              <input type="checkbox" checked disabled />
+              <p className="ms-3 mb-0">{group.name}</p>
+              {group.type === GroupType.externalUserGroup && <span className="ms-2 badge badge-pill badge-info">{group.provider}</span>}
               {/* TODO: Replace <div className="small">(TBD) List group members</div> */}
             </button>
           );
@@ -231,7 +269,7 @@ export const GrantSelector = (props: Props): JSX.Element => {
       </div>
     );
 
-  }, [currentUser?.admin, groupListItemClickHandler, myUserGroups, shouldFetch, t, userRelatedGrantedGroups]);
+  }, [currentUser?.admin, groupListItemClickHandler, shouldFetch, t, groupGrantData, selectedGrant?.userRelatedGrantedGroups]);
 
   const renderModalCloseButton = useCallback(() => {
     return (

+ 0 - 38
apps/app/src/components/SavePageControls/GrantSelector/use-my-user-groups.ts

@@ -1,38 +0,0 @@
-import { GroupType } from '@growi/core';
-
-import { useSWRxMyExternalUserGroups } from '~/features/external-user-group/client/stores/external-user-group';
-import { useSWRxMyUserGroups } from '~/stores/user-group';
-
-// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
-export const useMyUserGroups = (shouldFetch: boolean) => {
-  const { data: myUserGroups, mutate: mutateMyUserGroups } = useSWRxMyUserGroups(shouldFetch);
-  const { data: myExternalUserGroups, mutate: mutateMyExternalUserGroups } = useSWRxMyExternalUserGroups(shouldFetch);
-
-  const update = () => {
-    mutateMyUserGroups();
-    mutateMyExternalUserGroups();
-  };
-
-  if (myUserGroups == null || myExternalUserGroups == null) {
-    return { data: null, update };
-  }
-
-  const myUserGroupsData = myUserGroups
-    .map((group) => {
-      return {
-        item: group,
-        type: GroupType.userGroup,
-      };
-    });
-  const myExternalUserGroupsData = myExternalUserGroups
-    .map((group) => {
-      return {
-        item: group,
-        type: GroupType.externalUserGroup,
-      };
-    });
-
-  const data = [...myUserGroupsData, ...myExternalUserGroupsData];
-
-  return { data, update };
-};

+ 2 - 6
apps/app/src/components/SearchPage/SearchControl.tsx

@@ -7,8 +7,7 @@ import { useTranslation } from 'next-i18next';
 import { SORT_AXIS, SORT_ORDER } from '~/interfaces/search';
 import type { ISearchConditions, ISearchConfigurations } from '~/stores/search';
 
-import SearchForm from '../SearchForm';
-
+import { SearchModalTriggerinput } from './SearchModalTriggerinput';
 import SearchOptionModal from './SearchOptionModal';
 import SortControl from './SortControl';
 
@@ -88,11 +87,8 @@ const SearchControl = React.memo((props: Props): JSX.Element => {
     <div className="shadow-sm">
       <div className="grw-search-page-nav d-flex py-3 align-items-center">
         <div className="flex-grow-1 mx-4">
-          <SearchForm
-            isSearchServiceReachable={isSearchServiceReachable}
+          <SearchModalTriggerinput
             keywordOnInit={keyword}
-            disableIncrementalSearch
-            onSubmit={searchFormSubmittedHandler}
           />
         </div>
       </div>

+ 31 - 0
apps/app/src/components/SearchPage/SearchModalTriggerinput.tsx

@@ -0,0 +1,31 @@
+import React, {
+  useCallback,
+} from 'react';
+
+import { useSearchModal } from '../../features/search/client/stores/search';
+
+type Props = {
+  keywordOnInit: string,
+};
+
+export const SearchModalTriggerinput: React.FC<Props> = (props: Props) => {
+  const { keywordOnInit } = props;
+
+  const { open: openSearchModal } = useSearchModal();
+
+  const inputClickHandler = useCallback(() => {
+    openSearchModal(keywordOnInit);
+  }, [openSearchModal, keywordOnInit]);
+
+  return (
+    <div>
+      <input
+        className="form-control"
+        type="input"
+        value={keywordOnInit}
+        onClick={inputClickHandler}
+        readOnly
+      />
+    </div>
+  );
+};

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

@@ -17,7 +17,7 @@ export const Bookmarks = () : JSX.Element => {
         <h4 className="mb-0 py-4">{t('Bookmarks')}</h4>
       </div>
       {isGuestUser ? (
-        <h4 className="ps-3">
+        <h4 className="fs-6">
           { t('Not available for guest') }
         </h4>
       ) : (

+ 2 - 2
apps/app/src/components/Sidebar/Bookmarks/BookmarkContents.tsx

@@ -37,8 +37,8 @@ export const BookmarkContents = (): JSX.Element => {
   }, [mutateBookmarkFolders]);
 
   return (
-    <div className="ms-3">
-      <div className="col-8 mb-2">
+    <div>
+      <div className="mb-2">
         <button
           type="button"
           className="btn btn-outline-secondary rounded-pill d-flex justify-content-start align-middle"

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

@@ -12,11 +12,12 @@ type InAppNotificationFormsProps = {
 }
 export const InAppNotificationForms = (props: InAppNotificationFormsProps): JSX.Element => {
   const { onChangeUnopendNotificationsVisible } = props;
+  const { t } = useTranslation('commons');
 
   return (
     <div className="my-2">
       <div className="form-check form-switch">
-        <label className="form-check-label" htmlFor="flexSwitchCheckDefault">Only unread</label>
+        <label className="form-check-label" htmlFor="flexSwitchCheckDefault">{t('in_app_notification.only_unread')}</label>
         <input
           id="flexSwitchCheckDefault"
           className="form-check-input"

+ 2 - 2
apps/app/src/components/UsersHomepageFooter.tsx

@@ -25,7 +25,7 @@ export const UsersHomepageFooter = (props: UsersHomepageFooterProps): JSX.Elemen
       <div className="grw-user-page-list-m d-edit-none">
         <h2 id="bookmarks-list" className="grw-user-page-header border-bottom pb-2 mb-3 d-flex">
           <span style={{ fontSize: '1.3em' }} className="material-symbols-outlined">bookmark</span>
-          {t('footer.bookmarks')}
+          {t('user_home_page.bookmarks')}
           <span className="ms-auto ps-2 ">
             <button type="button" className={`btn btn-sm grw-expand-compress-btn ${isExpanded ? 'active' : ''}`} onClick={() => setIsExpanded(!isExpanded)}>
               {isExpanded ? <span className="material-symbols-outlined">expand</span> : <span className="material-symbols-outlined">compress</span>}
@@ -40,7 +40,7 @@ export const UsersHomepageFooter = (props: UsersHomepageFooterProps): JSX.Elemen
       <div className="grw-user-page-list-m mt-5 d-edit-none">
         <h2 id="recently-created-list" className="grw-user-page-header border-bottom pb-2 mb-3 d-flex">
           <span className="growi-custom-icons me-1">recently_created</span>
-          {t('footer.recently_created')}
+          {t('user_home_page.recently_created')}
         </h2>
         <div id="user-created-list" className={`page-list ${styles['page-list']}`}>
           <RecentCreated userId={creatorId} />

+ 4 - 12
apps/app/src/features/external-user-group/client/stores/external-user-group.ts

@@ -1,14 +1,13 @@
 import { type SWRResponseWithUtils, withUtils } from '@growi/core/dist/swr';
-import useSWR, { SWRResponse } from 'swr';
+import type { SWRResponse } from 'swr';
+import useSWR from 'swr';
 import useSWRImmutable from 'swr/immutable';
 
 import { apiv3Get, apiv3Put } from '~/client/util/apiv3-client';
-import {
+import type {
   IExternalUserGroupHasId, IExternalUserGroupRelationHasId, KeycloakGroupSyncSettings, LdapGroupSyncSettings,
 } from '~/features/external-user-group/interfaces/external-user-group';
-import {
-  ChildUserGroupListResult, IUserGroupRelationHasIdPopulatedUser, UserGroupListResult, UserGroupRelationListResult,
-} from '~/interfaces/user-group-response';
+import type { ChildUserGroupListResult, IUserGroupRelationHasIdPopulatedUser, UserGroupRelationListResult } from '~/interfaces/user-group-response';
 
 export const useSWRxLdapGroupSyncSettings = (): SWRResponse<LdapGroupSyncSettings, Error> => {
   return useSWR(
@@ -28,13 +27,6 @@ export const useSWRxKeycloakGroupSyncSettings = (): SWRResponse<KeycloakGroupSyn
   );
 };
 
-export const useSWRxMyExternalUserGroups = (shouldFetch: boolean): SWRResponse<IExternalUserGroupHasId[], Error> => {
-  return useSWR(
-    shouldFetch ? '/me/external-user-groups' : null,
-    endpoint => apiv3Get<UserGroupListResult<IExternalUserGroupHasId>>(endpoint).then(result => result.data.userGroups),
-  );
-};
-
 export const useSWRxExternalUserGroup = (groupId: string | null): SWRResponse<IExternalUserGroupHasId, Error> => {
   return useSWRImmutable(
     groupId != null ? `/external-user-groups/${groupId}` : null,

+ 7 - 1
apps/app/src/features/search/client/components/SearchModal.tsx

@@ -50,9 +50,15 @@ const SearchModal = (): JSX.Element => {
 
   useEffect(() => {
     if (!searchModalData?.isOpened) {
+      return;
+    }
+    if (searchModalData?.searchKeyword == null) {
       setSearchKeyword('');
     }
-  }, [searchModalData?.isOpened]);
+    else {
+      setSearchKeyword(searchModalData.searchKeyword);
+    }
+  }, [searchModalData?.isOpened, searchModalData?.searchKeyword]);
 
   return (
     <Modal size="lg" isOpen={searchModalData?.isOpened ?? false} toggle={closeSearchModal} data-testid="search-modal">

+ 9 - 4
apps/app/src/features/search/client/stores/search.ts

@@ -1,13 +1,16 @@
-import { SWRResponse } from 'swr';
+import { useCallback } from 'react';
+
+import type { SWRResponse } from 'swr';
 
 import { useStaticSWR } from '~/stores/use-static-swr';
 
 type SearchModalStatus = {
   isOpened: boolean,
+  searchKeyword?: string,
 }
 
 type SearchModalUtils = {
-  open(): void
+  open(keywordOnInit?: string): void
   close(): void
 }
 export const useSearchModal = (status?: SearchModalStatus): SWRResponse<SearchModalStatus, Error> & SearchModalUtils => {
@@ -16,7 +19,9 @@ export const useSearchModal = (status?: SearchModalStatus): SWRResponse<SearchMo
 
   return {
     ...swrResponse,
-    open: () => swrResponse.mutate({ isOpened: true }),
-    close: () => swrResponse.mutate({ isOpened: false }),
+    open: useCallback((keywordOnInit?: string) => {
+      swrResponse.mutate({ isOpened: true, searchKeyword: keywordOnInit });
+    }, [swrResponse]),
+    close: useCallback(() => swrResponse.mutate({ isOpened: false }), [swrResponse]),
   };
 };

+ 1 - 1
apps/app/src/interfaces/bookmark-info.ts

@@ -24,7 +24,7 @@ export interface IBookmarkFolder {
 
 export interface BookmarkFolderItems extends IBookmarkFolder {
   _id: string;
-  children: BookmarkFolderItems[];
+  childFolder: BookmarkFolderItems[];
   bookmarks: BookmarkedPage[];
 }
 

+ 30 - 4
apps/app/src/interfaces/page.ts

@@ -2,6 +2,8 @@ import type {
   GroupType, IGrantedGroup, IPageHasId, Nullable, PageGrant, Origin,
 } from '@growi/core';
 
+import type { ExternalGroupProviderType } from '~/features/external-user-group/interfaces/external-user-group';
+
 import type { IPageOperationProcessData } from './page-operation';
 
 export {
@@ -10,13 +12,37 @@ export {
 
 export type IPageForItem = Partial<IPageHasId & {isTarget?: boolean, processData?: IPageOperationProcessData}>;
 
-export type IPageGrantData = {
-  grant: PageGrant,
-  userRelatedGrantedGroups?: {
+export const UserGroupPageGrantStatus = {
+  isGranted: 'isGranted',
+  notGranted: 'notGranted',
+  cannotGrant: 'cannotGrant',
+};
+type UserGroupPageGrantStatus = typeof UserGroupPageGrantStatus[keyof typeof UserGroupPageGrantStatus];
+export type UserRelatedGroupsData = {
+  id: string,
+  name: string,
+  type: GroupType,
+  provider?: ExternalGroupProviderType,
+  status: UserGroupPageGrantStatus,
+}
+export type GroupGrantData = {
+  userRelatedGroups: UserRelatedGroupsData[],
+  nonUserRelatedGrantedGroups: {
     id: string,
     name: string,
     type: GroupType,
-  }[]
+    provider?: ExternalGroupProviderType,
+  }[],
+}
+// current grant data of page
+export type IPageGrantData = {
+  grant: PageGrant,
+  groupGrantData?: GroupGrantData,
+}
+// grant selected by user which is not yet applied
+export type IPageSelectedGrant = {
+  grant: PageGrant,
+  userRelatedGrantedGroups?: IGrantedGroup[]
 }
 
 export type IDeleteSinglePageApiv1Result = {

+ 2 - 1
apps/app/src/interfaces/user-ui-settings.ts

@@ -1,7 +1,8 @@
-import { SidebarContentsType } from './ui';
+import type { SidebarContentsType } from './ui';
 
 export interface IUserUISettings {
   currentSidebarContents: SidebarContentsType,
+  currentPageControlsX: number,
   currentProductNavWidth: number,
   preferCollapsedModeByUser: boolean,
 }

+ 3 - 44
apps/app/src/pages/[[...path]].page.tsx

@@ -3,9 +3,8 @@ import React, { useEffect } from 'react';
 
 import EventEmitter from 'events';
 
-import { isIPageInfoForEntity, isPopulated } from '@growi/core';
+import { isIPageInfoForEntity } from '@growi/core';
 import type {
-  GroupType,
   IDataWithMeta, IPageInfoForEntity, IPagePopulatedToShowRevision,
 } from '@growi/core';
 import {
@@ -26,7 +25,6 @@ import { PageView } from '~/components/Page/PageView';
 import { DrawioViewerScript } from '~/components/Script/DrawioViewerScript';
 import { SupportedAction, type SupportedActionType } from '~/interfaces/activity';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
-import type { IPageGrantData } from '~/interfaces/page';
 import type { RendererConfig } from '~/interfaces/services/renderer';
 import type { PageModel, PageDocument } from '~/server/models/page';
 import type { PageRedirectModel } from '~/server/models/page-redirect';
@@ -44,7 +42,7 @@ import {
 } from '~/stores/context';
 import { useEditingMarkdown } from '~/stores/editor';
 import {
-  useSWRxCurrentPage, useSWRMUTxCurrentPage, useSWRxIsGrantNormalized, useCurrentPageId,
+  useSWRxCurrentPage, useSWRMUTxCurrentPage, useCurrentPageId,
   useIsNotFound, useIsLatestRevision, useTemplateTagData, useTemplateBodyData,
 } from '~/stores/page';
 import { useRedirectFrom } from '~/stores/page-redirect';
@@ -177,8 +175,6 @@ type Props = CommonProps & {
   skipSSR: boolean,
   ssrMaxRevisionBodyLength: number,
 
-  grantData?: IPageGrantData,
-
   rendererConfig: RendererConfig,
 };
 
@@ -246,9 +242,6 @@ const Page: NextPageWithLayout<Props> = (props: Props) => {
 
   const { mutate: mutateIsLatestRevision } = useIsLatestRevision();
 
-  const { data: grantData } = useSWRxIsGrantNormalized(pageId);
-  const { mutate: mutateSelectedGrant } = useSelectedGrant();
-
   const { mutate: mutateRemoteRevisionId } = useRemoteRevisionId();
 
   const { mutate: mutateTemplateTagData } = useTemplateTagData();
@@ -275,12 +268,6 @@ const Page: NextPageWithLayout<Props> = (props: Props) => {
     }
   }, [currentPageId, mutateCurrentPage, mutateEditingMarkdown, props.isNotFound, props.skipSSR]);
 
-  // sync grant data
-  useEffect(() => {
-    const grantDataToApply = props.grantData ? props.grantData : grantData?.grantData.currentPageGrant;
-    mutateSelectedGrant(grantDataToApply);
-  }, [grantData?.grantData.currentPageGrant, mutateSelectedGrant, props.grantData]);
-
   // sync pathname by Shallow Routing https://nextjs.org/docs/routing/shallow-routing
   useEffect(() => {
     const decodedURI = decodeURI(window.location.pathname);
@@ -420,7 +407,7 @@ async function injectPageData(context: GetServerSidePropsContext, props: Props):
 
   const Page = crowi.model('Page') as PageModel;
   const PageRedirect = mongooseModel('PageRedirect') as PageRedirectModel;
-  const { pageService, configManager, pageGrantService } = crowi;
+  const { pageService, configManager } = crowi;
 
   let currentPathname = props.currentPathname;
 
@@ -464,34 +451,6 @@ async function injectPageData(context: GetServerSidePropsContext, props: Props):
     await page.populateDataToShowRevision(props.skipSSR); // shouldExcludeBody = skipSSR
   }
 
-  if (page == null && user != null) {
-    const templateData = await Page.findTemplate(props.currentPathname);
-    if (templateData != null) {
-      props.templateTagData = templateData.templateTags as string[];
-      props.templateBodyData = templateData.templateBody as string;
-    }
-
-    // apply parent page grant, without groups that user isn't related to
-    const ancestor = await Page.findAncestorByPathAndViewer(currentPathname, user);
-    if (ancestor != null) {
-      ancestor.populate('grantedGroups.item');
-      const userRelatedGrantedGroups = (await pageGrantService.getUserRelatedGrantedGroups(ancestor, user)).map((group) => {
-        if (isPopulated(group.item)) {
-          return {
-            id: group.item._id,
-            name: group.item.name,
-            type: group.type,
-          };
-        }
-        return null;
-      }).filter((info): info is NonNullable<{id: string, name: string, type: GroupType}> => info != null);
-      props.grantData = {
-        grant: ancestor.grant,
-        userRelatedGrantedGroups,
-      };
-    }
-  }
-
   props.pageWithMeta = pageWithMeta;
 }
 

+ 8 - 9
apps/app/src/server/models/bookmark-folder.ts

@@ -1,10 +1,9 @@
 import type { IPageHasId } from '@growi/core';
 import { objectIdUtils } from '@growi/core/dist/utils';
-import monggoose, {
-  Types, Document, Model, Schema,
-} from 'mongoose';
+import type { Types, Document, Model } from 'mongoose';
+import monggoose, { Schema } from 'mongoose';
 
-import { BookmarkFolderItems, IBookmarkFolder } from '~/interfaces/bookmark-info';
+import type { BookmarkFolderItems, IBookmarkFolder } from '~/interfaces/bookmark-info';
 
 import loggerFactory from '../../utils/logger';
 import { getOrCreateModel } from '../util/mongoose-utils';
@@ -21,13 +20,13 @@ export interface BookmarkFolderDocument extends Document {
   owner: Types.ObjectId
   parent?: Types.ObjectId | undefined
   bookmarks?: Types.ObjectId[],
-  children?: BookmarkFolderDocument[]
+  childFolder?: BookmarkFolderDocument[]
 }
 
 export interface BookmarkFolderModel extends Model<BookmarkFolderDocument>{
   createByParameters(params: IBookmarkFolder): Promise<BookmarkFolderDocument>
   deleteFolderAndChildren(bookmarkFolderId: Types.ObjectId | string): Promise<{deletedCount: number}>
-  updateBookmarkFolder(bookmarkFolderId: string, name: string, parent: string | null, children: BookmarkFolderItems[]): Promise<BookmarkFolderDocument>
+  updateBookmarkFolder(bookmarkFolderId: string, name: string, parent: string | null, childFolder: BookmarkFolderItems[]): Promise<BookmarkFolderDocument>
   insertOrUpdateBookmarkedPage(pageId: IPageHasId, userId: Types.ObjectId | string, folderId: string | null): Promise<BookmarkFolderDocument | null>
   updateBookmark(pageId: Types.ObjectId | string, status: boolean, userId: Types.ObjectId| string): Promise<BookmarkFolderDocument | null>
 }
@@ -51,7 +50,7 @@ const bookmarkFolderSchema = new Schema<BookmarkFolderDocument, BookmarkFolderMo
   toObject: { virtuals: true },
 });
 
-bookmarkFolderSchema.virtual('children', {
+bookmarkFolderSchema.virtual('childFolder', {
   ref: 'BookmarkFolder',
   localField: '_id',
   foreignField: 'parent',
@@ -108,7 +107,7 @@ bookmarkFolderSchema.statics.updateBookmarkFolder = async function(
     bookmarkFolderId: string,
     name: string,
     parentId: string | null,
-    children: BookmarkFolderItems[],
+    childFolder: BookmarkFolderItems[],
 ):
  Promise<BookmarkFolderDocument> {
   const updateFields: {name: string, parent: Types.ObjectId | null} = {
@@ -127,7 +126,7 @@ bookmarkFolderSchema.statics.updateBookmarkFolder = async function(
     if (parentFolder?.parent != null) {
       throw new Error('Update bookmark folder failed');
     }
-    if (children.length !== 0) {
+    if (childFolder.length !== 0) {
       throw new Error('Update bookmark folder failed');
     }
   }

+ 1 - 1
apps/app/src/server/models/user-group-relation.ts

@@ -1,5 +1,5 @@
 import {
-  getIdForRef, isPopulated, type IUserGroupHasId, type IUserGroupRelation,
+  getIdForRef, isPopulated, type IUserGroupRelation,
 } from '@growi/core';
 import type { Model, Document } from 'mongoose';
 import mongoose, { Schema } from 'mongoose';

+ 5 - 1
apps/app/src/server/models/user-ui-settings.ts

@@ -1,6 +1,7 @@
 import type { Ref, IUser } from '@growi/core';
+import type { Model, Document } from 'mongoose';
 import {
-  Schema, Model, Document,
+  Schema,
 } from 'mongoose';
 
 
@@ -22,6 +23,9 @@ const schema = new Schema<UserUISettingsDocument, UserUISettingsModel>({
     enum: SidebarContentsType,
     default: SidebarContentsType.RECENT,
   },
+  currentPageControlsX: {
+    type: Number,
+  },
   currentProductNavWidth: { type: Number },
   preferCollapsedModeByUser: { type: Boolean, default: false },
 });

+ 8 - 8
apps/app/src/server/routes/apiv3/bookmark-folder.ts

@@ -1,8 +1,8 @@
 import { ErrorV3 } from '@growi/core/dist/models';
 import { body } from 'express-validator';
-import { Types } from 'mongoose';
+import type { Types } from 'mongoose';
 
-import { BookmarkFolderItems } from '~/interfaces/bookmark-info';
+import type { BookmarkFolderItems } from '~/interfaces/bookmark-info';
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
 import { InvalidParentBookmarkFolderError } from '~/server/models/errors';
 import { serializeBookmarkSecurely } from '~/server/models/serializers/bookmark-serializer';
@@ -25,7 +25,7 @@ const validator = {
           throw new Error('Maximum folder hierarchy of 2 levels');
         }
       }),
-    body('children').optional().isArray().withMessage('Children must be an array'),
+    body('childFolder').optional().isArray().withMessage('Children must be an array'),
     body('bookmarkFolderId').optional().isMongoId().withMessage('Bookark Folder ID must be a valid mongo ID'),
   ],
   bookmarkPage: [
@@ -73,7 +73,7 @@ module.exports = (crowi) => {
         parentFolderId?: Types.ObjectId | string,
     ) => {
       const folders = await BookmarkFolder.find({ owner: userId, parent: parentFolderId })
-        .populate('children')
+        .populate('childFolder')
         .populate({
           path: 'bookmarks',
           model: 'Bookmark',
@@ -90,7 +90,7 @@ module.exports = (crowi) => {
       const returnValue: BookmarkFolderItems[] = [];
 
       const promises = folders.map(async(folder: BookmarkFolderItems) => {
-        const children = await getBookmarkFolders(userId, folder._id);
+        const childFolder = await getBookmarkFolders(userId, folder._id);
 
         // !! DO NOT THIS SERIALIZING OUTSIDE OF PROMISES !! -- 05.23.2023 ryoji-s
         // Serializing outside of promises will cause not populated.
@@ -101,7 +101,7 @@ module.exports = (crowi) => {
           name: folder.name,
           owner: folder.owner,
           bookmarks,
-          children,
+          childFolder,
           parent: folder.parent,
         };
         return res;
@@ -139,10 +139,10 @@ module.exports = (crowi) => {
 
   router.put('/', accessTokenParser, loginRequiredStrictly, validator.bookmarkFolder, async(req, res) => {
     const {
-      bookmarkFolderId, name, parent, children,
+      bookmarkFolderId, name, parent, childFolder,
     } = req.body;
     try {
-      const bookmarkFolder = await BookmarkFolder.updateBookmarkFolder(bookmarkFolderId, name, parent, children);
+      const bookmarkFolder = await BookmarkFolder.updateBookmarkFolder(bookmarkFolderId, name, parent, childFolder);
       return res.apiv3({ bookmarkFolder });
     }
     catch (err) {

+ 0 - 2
apps/app/src/server/routes/apiv3/index.js

@@ -118,7 +118,5 @@ module.exports = (crowi, app) => {
   router.use('/questionnaire', require('~/features/questionnaire/server/routes/apiv3/questionnaire')(crowi));
   router.use('/templates', require('~/features/templates/server/routes/apiv3')(crowi));
 
-  router.use('/me', require('./me')(crowi));
-
   return [router, routerForAdmin, routerForAuth];
 };

+ 0 - 54
apps/app/src/server/routes/apiv3/me.ts

@@ -1,54 +0,0 @@
-import { type IUserHasId } from '@growi/core';
-import { Router, Request } from 'express';
-
-import ExternalUserGroupRelation from '~/features/external-user-group/server/models/external-user-group-relation';
-import loggerFactory from '~/utils/logger';
-
-import UserGroupRelation from '../../models/user-group-relation';
-
-import { ApiV3Response } from './interfaces/apiv3-response';
-
-const logger = loggerFactory('growi:routes:apiv3:me');
-
-const router = Router();
-
-interface AuthorizedRequest extends Request {
-  user?: IUserHasId
-}
-
-module.exports = function(crowi) {
-  const accessTokenParser = require('../../middlewares/access-token-parser')(crowi);
-  const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
-
-  const ApiResponse = require('../../util/apiResponse');
-
-  /**
-   * retrieve user-group documents
-   */
-  router.get('/user-groups', accessTokenParser, loginRequiredStrictly, async(req: AuthorizedRequest, res: ApiV3Response) => {
-    try {
-      const userGroups = await UserGroupRelation.findAllGroupsForUser(req.user);
-      return res.json(ApiResponse.success({ userGroups }));
-    }
-    catch (e) {
-      logger.error(e);
-      return res.apiv3Err(e, 500);
-    }
-  });
-
-  /**
-   * retrieve external-user-group-relation documents
-   */
-  router.get('/external-user-groups', accessTokenParser, loginRequiredStrictly, async(req: AuthorizedRequest, res: ApiV3Response) => {
-    try {
-      const userGroups = await ExternalUserGroupRelation.findAllGroupsForUser(req.user);
-      return res.json(ApiResponse.success({ userGroups }));
-    }
-    catch (e) {
-      logger.error(e);
-      return res.apiv3Err(e, 500);
-    }
-  });
-
-  return router;
-};

+ 24 - 43
apps/app/src/server/routes/apiv3/page/index.js → apps/app/src/server/routes/apiv3/page/index.ts

@@ -1,23 +1,25 @@
 import path from 'path';
 
+import type { IPage } from '@growi/core';
 import {
   AllSubscriptionStatusType, SubscriptionStatusType,
 } from '@growi/core';
 import { ErrorV3 } from '@growi/core/dist/models';
 import { convertToNewAffiliationPath } from '@growi/core/dist/utils/page-path-utils';
+import mongoose from 'mongoose';
 import sanitize from 'sanitize-filename';
 
-import ExternalUserGroup from '~/features/external-user-group/server/models/external-user-group';
 import { SupportedAction, SupportedTargetModel } from '~/interfaces/activity';
+import type { IPageGrantData } from '~/interfaces/page';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
 import { excludeReadOnlyUser } from '~/server/middlewares/exclude-read-only-user';
 import { GlobalNotificationSettingEvent } from '~/server/models';
+import type { PageModel } from '~/server/models/page';
 import Subscription from '~/server/models/subscription';
-import UserGroup from '~/server/models/user-group';
 import { configManager } from '~/server/service/config-manager';
+import type { IPageGrantService } from '~/server/service/page-grant';
 import { preNotifyService } from '~/server/service/pre-notify';
-import { divideByType } from '~/server/util/granted-group';
 import loggerFactory from '~/utils/logger';
 
 import { checkPageExistenceHandlersFactory } from './check-page-existence';
@@ -466,8 +468,7 @@ module.exports = (crowi) => {
       return res.apiv3Err(err, 500);
     }
 
-    const result = { page };
-    result.seenUser = page.seenUsers;
+    const result = { page, seenUser: page.seenUsers };
 
     const parameters = {
       targetModel: SupportedTargetModel.MODEL_PAGE,
@@ -538,12 +539,12 @@ module.exports = (crowi) => {
   /**
    * @swagger
    *
-   *    /page/is-grant-normalized:
+   *    /page/grant-data:
    *      get:
    *        tags: [Page]
    *        summary: /page/info
-   *        description: Retrieve current page's isGrantNormalized value
-   *        operationId: getIsGrantNormalized
+   *        description: Retrieve current page's grant data
+   *        operationId: getPageGrantData
    *        parameters:
    *          - name: pageId
    *            in: query
@@ -552,7 +553,7 @@ module.exports = (crowi) => {
    *              $ref: '#/components/schemas/Page/properties/_id'
    *        responses:
    *          200:
-   *            description: Successfully retrieved current isGrantNormalized.
+   *            description: Successfully retrieved current grant data.
    *            content:
    *              application/json:
    *                schema:
@@ -565,10 +566,12 @@ module.exports = (crowi) => {
    *          500:
    *            description: Internal server error.
    */
-  router.get('/is-grant-normalized', loginRequiredStrictly, validator.isGrantNormalized, apiV3FormValidator, async(req, res) => {
+  router.get('/grant-data', loginRequiredStrictly, validator.isGrantNormalized, apiV3FormValidator, async(req, res) => {
     const { pageId } = req.query;
 
-    const Page = crowi.model('Page');
+    const Page = mongoose.model<IPage, PageModel>('Page');
+    const pageGrantService = crowi.pageGrantService as IPageGrantService;
+
     const page = await Page.findByIdAndViewer(pageId, req.user, null, false);
 
     if (page == null) {
@@ -579,29 +582,19 @@ module.exports = (crowi) => {
     const {
       path, grant, grantedUsers, grantedGroups,
     } = page;
-
-    let isGrantNormalized;
+    let isGrantNormalized = false;
     try {
-      isGrantNormalized = await crowi.pageGrantService.isGrantNormalized(req.user, path, grant, grantedUsers, grantedGroups, false, false);
+      isGrantNormalized = await pageGrantService.isGrantNormalized(req.user, path, grant, grantedUsers, grantedGroups, false, false);
     }
     catch (err) {
       logger.error('Error occurred while processing isGrantNormalized.', err);
       return res.apiv3Err(err, 500);
     }
 
-    const userRelatedGrantedGroups = await crowi.pageGrantService.getUserRelatedGrantedGroups(page, req.user);
-    const { grantedUserGroups, grantedExternalUserGroups } = divideByType(userRelatedGrantedGroups);
-    const currentPageUserGroups = await UserGroup.find({ _id: { $in: grantedUserGroups } });
-    const currentPageExternalUserGroups = await ExternalUserGroup.find({ _id: { $in: grantedExternalUserGroups } });
-    const grantedUserGroupData = currentPageUserGroups.map((group) => {
-      return { id: group._id, name: group.name, type: 'UserGroup' };
-    });
-    const grantedExternalUserGroupData = currentPageExternalUserGroups.map((group) => {
-      return { id: group._id, name: group.name, type: 'ExternalUserGroup' };
-    });
-    const currentPageGrant = {
-      grant,
-      userRelatedGrantedGroups: [...grantedUserGroupData, ...grantedExternalUserGroupData],
+    const currentPageGroupGrantData = await pageGrantService.getPageGroupGrantData(page, req.user);
+    const currentPageGrant: IPageGrantData = {
+      grant: page.grant,
+      groupGrantData: currentPageGroupGrantData,
     };
 
     // page doesn't have parent page
@@ -626,22 +619,10 @@ module.exports = (crowi) => {
       return res.apiv3({ isGrantNormalized, grantData });
     }
 
-    const userRelatedParentGrantedGroups = await crowi.pageGrantService.getUserRelatedGrantedGroups(parentPage, req.user);
-    const {
-      grantedUserGroups: parentGrantedUserGroupIds,
-      grantedExternalUserGroups: parentGrantedExternalUserGroupIds,
-    } = divideByType(userRelatedParentGrantedGroups);
-    const parentPageUserGroups = await UserGroup.find({ _id: { $in: parentGrantedUserGroupIds } });
-    const parentPageExternalUserGroups = await ExternalUserGroup.find({ _id: { $in: parentGrantedExternalUserGroupIds } });
-    const parentGrantedUserGroupData = parentPageUserGroups.map((group) => {
-      return { id: group._id, name: group.name };
-    });
-    const parentGrantedExternalUserGroupData = parentPageExternalUserGroups.map((group) => {
-      return { id: group._id, name: group.name };
-    });
-    const parentPageGrant = {
-      grant: parentPage.grant,
-      userRelatedGrantedGroups: [...parentGrantedUserGroupData, ...parentGrantedExternalUserGroupData],
+    const parentPageGroupGrantData = await pageGrantService.getPageGroupGrantData(parentPage, req.user);
+    const parentPageGrant: IPageGrantData = {
+      grant,
+      groupGrantData: parentPageGroupGrantData,
     };
 
     const grantData = {

+ 127 - 43
apps/app/src/server/service/page-grant.ts

@@ -1,3 +1,4 @@
+import type { IPage } from '@growi/core';
 import {
   type IGrantedGroup,
   PageGrant, GroupType, getIdForRef, isPopulated,
@@ -8,8 +9,11 @@ import {
 import escapeStringRegexp from 'escape-string-regexp';
 import mongoose from 'mongoose';
 
+import type { ExternalGroupProviderType } from '~/features/external-user-group/interfaces/external-user-group';
 import ExternalUserGroup from '~/features/external-user-group/server/models/external-user-group';
 import ExternalUserGroupRelation from '~/features/external-user-group/server/models/external-user-group-relation';
+import type { UserRelatedGroupsData } from '~/interfaces/page';
+import { UserGroupPageGrantStatus, type GroupGrantData } from '~/interfaces/page';
 import type { IRecordApplicableGrant, PopulatedGrantedGroup } from '~/interfaces/page-grant';
 import type { PageDocument, PageModel } from '~/server/models/page';
 import UserGroup from '~/server/models/user-group';
@@ -98,9 +102,12 @@ export interface IPageGrantService {
     userRelatedGroups: PopulatedGrantedGroup[], previousGrantedGroups: IGrantedGroup[], grant?: PageGrant, grantedGroups?: IGrantedGroup[],
   ) => boolean,
   getUserRelatedGroups: (user) => Promise<PopulatedGrantedGroup[]>,
+  getPopulatedGrantedGroups: (grantedGroups: IGrantedGroup[]) => Promise<PopulatedGrantedGroup[]>,
   getUserRelatedGrantedGroups: (page: PageDocument, user) => Promise<IGrantedGroup[]>,
   getUserRelatedGrantedGroupsSyncronously: (userRelatedGroups: PopulatedGrantedGroup[], page: PageDocument) => IGrantedGroup[],
-  isUserGrantedPageAccess: (page: PageDocument, user, userRelatedGroups: PopulatedGrantedGroup[]) => boolean
+  isUserGrantedPageAccess: (page: PageDocument, user, userRelatedGroups: PopulatedGrantedGroup[]) => boolean,
+  getPageGroupGrantData: (page: PageDocument, user) => Promise<GroupGrantData>,
+  calcApplicableGrantData: (page, user) => Promise<IRecordApplicableGrant>
 }
 
 class PageGrantService implements IPageGrantService {
@@ -112,7 +119,7 @@ class PageGrantService implements IPageGrantService {
   }
 
   private validateComparableTarget(comparable: ComparableTarget) {
-    const Page = mongoose.model('Page') as unknown as PageModel;
+    const Page = mongoose.model<IPage, PageModel>('Page');
 
     const { grant, grantedUserIds, grantedGroupIds } = comparable;
 
@@ -134,7 +141,7 @@ class PageGrantService implements IPageGrantService {
      */
     this.validateComparableTarget(target);
 
-    const Page = mongoose.model('Page') as unknown as PageModel;
+    const Page = mongoose.model<IPage, PageModel>('Page');
 
     /*
      * ancestor side
@@ -284,53 +291,45 @@ class PageGrantService implements IPageGrantService {
    * Prepare ComparableTarget
    * @returns Promise<ComparableAncestor>
    */
-  private async generateComparableTarget(
-      grant: PageGrant | undefined, grantedUserIds: ObjectIdLike[] | undefined, grantedGroupIds: IGrantedGroup[] | undefined, includeApplicable: boolean,
+  private async generateComparableTargetWithApplicableData(
+      grant: PageGrant | undefined, grantedUserIds: ObjectIdLike[] | undefined, grantedGroupIds: IGrantedGroup[] | undefined,
   ): Promise<ComparableTarget> {
-    if (includeApplicable) {
-      const Page = mongoose.model('Page') as unknown as PageModel;
+    const Page = mongoose.model<IPage, PageModel>('Page');
 
-      let applicableUserIds: ObjectIdLike[] | undefined;
-      let applicableGroupIds: ObjectIdLike[] | undefined;
-
-      if (grant === Page.GRANT_USER_GROUP) {
-        if (grantedGroupIds == null || grantedGroupIds.length === 0) {
-          throw Error('Target user group is not given');
-        }
+    let applicableUserIds: ObjectIdLike[] | undefined;
+    let applicableGroupIds: ObjectIdLike[] | undefined;
 
-        const { grantedUserGroups: grantedUserGroupIds, grantedExternalUserGroups: grantedExternalUserGroupIds } = divideByType(grantedGroupIds);
-        const targetUserGroups = await UserGroup.find({ _id: { $in: grantedUserGroupIds } });
-        const targetExternalUserGroups = await ExternalUserGroup.find({ _id: { $in: grantedExternalUserGroupIds } });
-        if (targetUserGroups.length === 0 && targetExternalUserGroups.length === 0) {
-          throw Error('Target user group does not exist');
-        }
+    if (grant === Page.GRANT_USER_GROUP) {
+      if (grantedGroupIds == null || grantedGroupIds.length === 0) {
+        throw Error('Target user group is not given');
+      }
 
-        const userGroupRelations = await UserGroupRelation.find({ relatedGroup: { $in: targetUserGroups.map(g => g._id) } });
-        const externalUserGroupRelations = await ExternalUserGroupRelation.find({ relatedGroup: { $in: targetExternalUserGroups.map(g => g._id) } });
-        applicableUserIds = Array.from(new Set([...userGroupRelations, ...externalUserGroupRelations].map(u => u.relatedUser as ObjectIdLike)));
-
-        const applicableUserGroups = (await Promise.all(targetUserGroups.map((group) => {
-          return UserGroup.findGroupsWithDescendantsById(group._id);
-        }))).flat();
-        const applicableExternalUserGroups = (await Promise.all(targetExternalUserGroups.map((group) => {
-          return ExternalUserGroup.findGroupsWithDescendantsById(group._id);
-        }))).flat();
-        applicableGroupIds = [...applicableUserGroups, ...applicableExternalUserGroups].map(g => g._id);
+      const { grantedUserGroups: grantedUserGroupIds, grantedExternalUserGroups: grantedExternalUserGroupIds } = divideByType(grantedGroupIds);
+      const targetUserGroups = await UserGroup.find({ _id: { $in: grantedUserGroupIds } });
+      const targetExternalUserGroups = await ExternalUserGroup.find({ _id: { $in: grantedExternalUserGroupIds } });
+      if (targetUserGroups.length === 0 && targetExternalUserGroups.length === 0) {
+        throw Error('Target user group does not exist');
       }
 
-      return {
-        grant,
-        grantedUserIds,
-        grantedGroupIds,
-        applicableUserIds,
-        applicableGroupIds,
-      };
+      const userGroupRelations = await UserGroupRelation.find({ relatedGroup: { $in: targetUserGroups.map(g => g._id) } });
+      const externalUserGroupRelations = await ExternalUserGroupRelation.find({ relatedGroup: { $in: targetExternalUserGroups.map(g => g._id) } });
+      applicableUserIds = Array.from(new Set([...userGroupRelations, ...externalUserGroupRelations].map(u => u.relatedUser as ObjectIdLike)));
+
+      const applicableUserGroups = (await Promise.all(targetUserGroups.map((group) => {
+        return UserGroup.findGroupsWithDescendantsById(group._id);
+      }))).flat();
+      const applicableExternalUserGroups = (await Promise.all(targetExternalUserGroups.map((group) => {
+        return ExternalUserGroup.findGroupsWithDescendantsById(group._id);
+      }))).flat();
+      applicableGroupIds = [...applicableUserGroups, ...applicableExternalUserGroups].map(g => g._id);
     }
 
     return {
       grant,
       grantedUserIds,
       grantedGroupIds,
+      applicableUserIds,
+      applicableGroupIds,
     };
   }
 
@@ -340,7 +339,7 @@ class PageGrantService implements IPageGrantService {
    * @returns Promise<ComparableAncestor>
    */
   private async generateComparableAncestor(targetPath: string, includeNotMigratedPages: boolean): Promise<ComparableAncestor> {
-    const Page = mongoose.model('Page') as unknown as PageModel;
+    const Page = mongoose.model<IPage, PageModel>('Page');
     const { PageQueryBuilder } = Page;
 
     let applicableUserIds: ObjectIdLike[] | undefined;
@@ -395,7 +394,7 @@ class PageGrantService implements IPageGrantService {
    * @returns ComparableDescendants
    */
   private async generateComparableDescendants(targetPath: string, user, includeNotMigratedPages = false): Promise<ComparableDescendants> {
-    const Page = mongoose.model('Page') as unknown as PageModel;
+    const Page = mongoose.model<IPage, PageModel>('Page');
 
     // Build conditions
     const $match: {$or: any} = {
@@ -515,11 +514,11 @@ class PageGrantService implements IPageGrantService {
     const comparableAncestor = await this.generateComparableAncestor(targetPath, includeNotMigratedPages);
 
     if (!shouldCheckDescendants) { // checking the parent is enough
-      const comparableTarget = await this.generateComparableTarget(grant, grantedUserIds, grantedGroupIds, false);
+      const comparableTarget: ComparableTarget = { grant, grantedUserIds, grantedGroupIds };
       return this.validateGrant(comparableTarget, comparableAncestor);
     }
 
-    const comparableTarget = await this.generateComparableTarget(grant, grantedUserIds, grantedGroupIds, true);
+    const comparableTarget = await this.generateComparableTargetWithApplicableData(grant, grantedUserIds, grantedGroupIds);
     const comparableDescendants = await this.generateComparableDescendants(targetPath, user, includeNotMigratedPages);
 
     return this.validateGrant(comparableTarget, comparableAncestor, comparableDescendants);
@@ -564,7 +563,7 @@ class PageGrantService implements IPageGrantService {
   }
 
   async calcApplicableGrantData(page, user): Promise<IRecordApplicableGrant> {
-    const Page = mongoose.model('Page') as unknown as PageModel;
+    const Page = mongoose.model<IPage, PageModel>('Page');
 
     // -- Public only if top page
     const isOnlyPublicApplicable = isTopPage(page.path);
@@ -655,6 +654,77 @@ class PageGrantService implements IPageGrantService {
     return data;
   }
 
+  /**
+   * Get the group grant data of page.
+   * To calculate if a group can be granted to page, the same logic as isGrantNormalized will be executed, except only the ancestor info will be used.
+   */
+  async getPageGroupGrantData(page: PageDocument, user): Promise<GroupGrantData> {
+    if (isTopPage(page.path)) {
+      return { userRelatedGroups: [], nonUserRelatedGrantedGroups: [] };
+    }
+
+    const userRelatedGroups = await this.getUserRelatedGroups(user);
+    let userRelatedGroupsData: UserRelatedGroupsData[] = userRelatedGroups.map((group) => {
+      const provider = group.type === GroupType.externalUserGroup ? group.item.provider : undefined;
+      return {
+        // default status as notGranted
+        id: group.item._id.toString(), name: group.item.name, type: group.type, provider, status: UserGroupPageGrantStatus.notGranted,
+      };
+    });
+
+    const nonUserRelatedGrantedGroups: {
+      id: string,
+      name: string,
+      type: GroupType,
+      provider?: ExternalGroupProviderType,
+    }[] = [];
+
+    const populatedGrantedGroups = await this.getPopulatedGrantedGroups(page.grantedGroups);
+
+    // Set the status of user-related granted groups as isGranted
+    // Append non-user-related granted groups to nonUserRelatedGrantedGroups
+    populatedGrantedGroups.forEach((group) => {
+      const userRelatedGrantedGroup = userRelatedGroupsData.find((userRelatedGroup) => {
+        return userRelatedGroup.id === group.item._id.toString();
+      });
+      if (userRelatedGrantedGroup != null) {
+        userRelatedGrantedGroup.status = UserGroupPageGrantStatus.isGranted;
+      }
+      else {
+        const provider = group.type === GroupType.externalUserGroup ? group.item.provider : undefined;
+        nonUserRelatedGrantedGroups.push({
+          id: group.item._id.toString(), name: group.item.name, type: group.type, provider,
+        });
+      }
+    });
+
+    // Check if group can be granted to page for non-granted groups
+    const grantedUserIds = page.grantedUsers?.map(user => getIdForRef(user)) ?? [];
+    const comparableAncestor = await this.generateComparableAncestor(page.path, false);
+    userRelatedGroupsData = userRelatedGroupsData.map((groupData) => {
+      if (groupData.status === UserGroupPageGrantStatus.isGranted) {
+        return groupData;
+      }
+      const groupsToGrant = [...(page.grantedGroups ?? []), { item: groupData.id, type: groupData.type }];
+      const comparableTarget: ComparableTarget = {
+        grant: PageGrant.GRANT_USER_GROUP,
+        grantedUserIds,
+        grantedGroupIds: groupsToGrant,
+      };
+      const status = this.validateGrant(comparableTarget, comparableAncestor) ? UserGroupPageGrantStatus.notGranted : UserGroupPageGrantStatus.cannotGrant;
+      return { ...groupData, status };
+    });
+
+    const statusPriority = {
+      [UserGroupPageGrantStatus.notGranted]: 0,
+      [UserGroupPageGrantStatus.isGranted]: 1,
+      [UserGroupPageGrantStatus.cannotGrant]: 2,
+    };
+    userRelatedGroupsData.sort((a, b) => statusPriority[a.status] - statusPriority[b.status]);
+
+    return { userRelatedGroups: userRelatedGroupsData, nonUserRelatedGrantedGroups };
+  }
+
   /*
    * get all groups that user is related to
    */
@@ -671,6 +741,20 @@ class PageGrantService implements IPageGrantService {
     ];
   }
 
+  async getPopulatedGrantedGroups(grantedGroups: IGrantedGroup[]): Promise<PopulatedGrantedGroup[]> {
+    const { grantedUserGroups, grantedExternalUserGroups } = divideByType(grantedGroups);
+    const userGroupDocuments = await UserGroup.find({ _id: { $in: grantedUserGroups } });
+    const externalUserGroupDocuments = await ExternalUserGroup.find({ _id: { $in: grantedExternalUserGroups } });
+    return [
+      ...(userGroupDocuments.map((group) => {
+        return { type: GroupType.userGroup, item: group };
+      })),
+      ...(externalUserGroupDocuments.map((group) => {
+        return { type: GroupType.externalUserGroup, item: group };
+      })),
+    ];
+  }
+
   /*
    * get all groups of Page that user is related to
    */

+ 3 - 3
apps/app/src/stores/page.tsx

@@ -265,9 +265,9 @@ export const useSWRxInfinitePageRevisions = (
 };
 
 /*
- * Grant normalization fetching hooks
+ * Grant data fetching hooks
  */
-export const useSWRxIsGrantNormalized = (
+export const useSWRxCurrentGrantData = (
     pageId: string | null | undefined,
 ): SWRResponse<IResIsGrantNormalized, Error> => {
 
@@ -276,7 +276,7 @@ export const useSWRxIsGrantNormalized = (
   const { data: isNotFound } = useIsNotFound();
 
   const key = !isGuestUser && !isReadOnlyUser && !isNotFound && pageId != null
-    ? ['/page/is-grant-normalized', pageId]
+    ? ['/page/grant-data', pageId]
     : null;
 
   return useSWRImmutable(

+ 18 - 3
apps/app/src/stores/ui.tsx

@@ -17,7 +17,7 @@ import useSWRImmutable from 'swr/immutable';
 
 import type { IFocusable } from '~/client/interfaces/focusable';
 import { scheduleToPut } from '~/client/services/user-ui-settings';
-import type { IPageGrantData } from '~/interfaces/page';
+import type { IPageGrantData, IPageSelectedGrant } from '~/interfaces/page';
 import { SidebarContentsType, SidebarMode } from '~/interfaces/ui';
 import type { UpdateDescCountData } from '~/interfaces/websocket';
 import {
@@ -271,6 +271,21 @@ export const useCurrentSidebarContents = (
   return withUtils(swrResponse, { mutateAndSave });
 };
 
+export const usePageControlsX = (
+    initialData?: number,
+): SWRResponseWithUtils<MutateAndSaveUserUISettingsUtils<number>, number> => {
+  const swrResponse = useSWRStatic('pageControlsX', initialData, { fallbackData: 1000 });
+
+  const { mutate } = swrResponse;
+
+  const mutateAndSave: MutateAndSaveUserUISettings<number> = useCallback((data, opt?) => {
+    scheduleToPut({ currentPageControlsX: data });
+    return mutate(data, opt);
+  }, [mutate]);
+
+  return withUtils(swrResponse, { mutateAndSave });
+};
+
 export const useCurrentProductNavWidth = (initialData?: number): SWRResponseWithUtils<MutateAndSaveUserUISettingsUtils<number>, number> => {
   const swrResponse = useSWRStatic('productNavWidth', initialData, { fallbackData: 320 });
 
@@ -348,8 +363,8 @@ export const useSidebarMode = (): SWRResponseWithUtils<DetectSidebarModeUtils, S
   };
 };
 
-export const useSelectedGrant = (initialData?: Nullable<IPageGrantData>): SWRResponse<Nullable<IPageGrantData>, Error> => {
-  return useStaticSWR<Nullable<IPageGrantData>, Error>('selectedGrant', initialData, { fallbackData: { grant: PageGrant.GRANT_PUBLIC } });
+export const useSelectedGrant = (initialData?: Nullable<IPageSelectedGrant>): SWRResponse<Nullable<IPageSelectedGrant>, Error> => {
+  return useSWRStatic<Nullable<IPageSelectedGrant>, Error>('selectedGrant', initialData, { fallbackData: { grant: PageGrant.GRANT_PUBLIC } });
 };
 
 type PageTreeDescCountMapUtils = {

+ 2 - 10
apps/app/src/stores/user-group.tsx

@@ -2,24 +2,16 @@ import type {
   IPageHasId, IUserGroupHasId, IUserGroupRelationHasId,
 } from '@growi/core';
 import { type SWRResponseWithUtils, withUtils } from '@growi/core/dist/swr';
-import useSWR, { SWRResponse } from 'swr';
+import type { SWRResponse } from 'swr';
 import useSWRImmutable from 'swr/immutable';
 
 import { apiv3Get, apiv3Put } from '~/client/util/apiv3-client';
-import {
+import type {
   IUserGroupRelationHasIdPopulatedUser,
   UserGroupResult, UserGroupListResult, ChildUserGroupListResult, UserGroupRelationListResult, UserGroupRelationsResult,
   UserGroupPagesResult, SelectableParentUserGroupsResult, SelectableUserChildGroupsResult, AncestorUserGroupsResult,
 } from '~/interfaces/user-group-response';
 
-
-export const useSWRxMyUserGroups = (shouldFetch: boolean): SWRResponse<IUserGroupHasId[], Error> => {
-  return useSWR(
-    shouldFetch ? '/me/user-groups' : null,
-    endpoint => apiv3Get<UserGroupListResult>(endpoint).then(result => result.data.userGroups),
-  );
-};
-
 export const useSWRxUserGroup = (groupId: string | null): SWRResponse<IUserGroupHasId, Error> => {
   return useSWRImmutable(
     groupId != null ? `/user-groups/${groupId}` : null,

+ 13 - 36
apps/app/test/cypress/e2e/23-editor/23-editor--template-modal.cy.ts

@@ -15,30 +15,18 @@ context('TemplateModal', () => {
 
     // move to edit mode
     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');
+    cy.getByTestid('editor-button').click();
+    cy.getByTestid('grw-editor-navbar-bottom').should('be.visible');
 
-    // show TemplateModal
-    cy.waitUntil(() => {
-      // do
-      cy.get('.navbar-editor > ul > li:nth-child(16) > button').click({force: true});
-      // wait until
-      return cy.getByTestid('template-modal').then($elem => $elem.is(':visible'));
-    });
+    // open TemplateModal
+    cy.getByTestid('open-template-button').click();
+    cy.getByTestid('template-modal').should('be.visible');
+    cy.screenshot(`${ssPrefix}opened`);
 
     // close TemplateModal
     cy.getByTestid('template-modal').should('be.visible').within(() => {
-      cy.screenshot(`${ssPrefix}opened`);
-      cy.get('button.close').click();
+      cy.get('.btn-close').click();
     });
-
     cy.screenshot(`${ssPrefix}close`);
   });
 
@@ -48,30 +36,19 @@ context('TemplateModal', () => {
 
     // move to edit mode
     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');
+    cy.getByTestid('editor-button').click();
+    cy.getByTestid('grw-editor-navbar-bottom').should('be.visible');
 
-    // show TemplateModal
-    cy.waitUntil(() => {
-      // do
-      cy.get('.navbar-editor > ul > li:nth-child(16) > button').click({force: true});
-      // wait until
-      return cy.getByTestid('template-modal').then($elem => $elem.is(':visible'));
-    });
+    // open TemplateModal
+    cy.getByTestid('open-template-button').click();
+    cy.getByTestid('template-modal').should('be.visible');
 
     // select template and template locale
     cy.getByTestid('template-modal').should('be.visible').within(() => {
       // select first template
       cy.get('.list-group > .list-group-item:nth-child(1)').click();
       // check preview exist
-      cy.get('.card-body > .page-editor-preview-body > .wiki').should('exist');
+      cy.get('.card-body').should('be.visible');
       cy.screenshot(`${ssPrefix}select-template`);
 
       // change template locale

+ 27 - 41
apps/app/test/cypress/e2e/23-editor/23-editor--with-navigation.cy.ts

@@ -2,15 +2,9 @@ import path from 'path-browserify';
 
 function openEditor() {
   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('.CodeMirror').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', () => {
@@ -38,8 +32,8 @@ context('Editor while uploading to a new page', () => {
 
     // input the body
     const body = 'Hello World!';
-    cy.get('.CodeMirror textarea').type(body + '\n\n', { force: true });
-    cy.get('.CodeMirror-code').should('contain.text', body);
+    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(() => {
@@ -48,18 +42,14 @@ context('Editor while uploading to a new page', () => {
         cy.get('button.dropdown-toggle').click({force: true});
       });
       // wait until
-      return cy.getByTestid('grw-grant-selector').within(() => {
-        return Cypress.$('.dropdown-menu.show').is(':visible');
-      });
+      return cy.getByTestid('grw-grant-selector-dropdown-menu').then($elem => $elem.is(':visible'))
     });
 
     // Select "Only me"
-    cy.getByTestid('grw-grant-selector').within(() => {
+    cy.getByTestid('grw-grant-selector-dropdown-menu').find('.dropdown-item').should('have.length', 4).then((menuItems) => {
       // click "Only me"
-      cy.get('.dropdown-menu.show').find('.dropdown-item').should('have.length', 4).then((menuItems) => {
-        menuItems[2].click();
-      });
-    });
+      menuItems[2].click();
+    })
 
     cy.getByTestid('grw-grant-selector').find('.dropdown-toggle').should('contain.text', 'Only me');
     cy.screenshot(`${ssPrefix}-prevent-grantselector-modified-2`);
@@ -67,7 +57,7 @@ context('Editor while uploading to a new page', () => {
     // intercept API req/res for fixing labels
     const dummyAttachmentId = '64b000000000000000000000';
     let uploadedAttachmentId = '';
-    cy.intercept('POST', '/_api/attachments.add', (req) => {
+    cy.intercept('POST', '/_api/v3/attachment', (req) => {
       req.continue((res) => {
         // store the attachment id
         uploadedAttachmentId = res.body.attachment._id;
@@ -86,20 +76,20 @@ context('Editor while uploading to a new page', () => {
 
     // drag-drop a file
     const filePath = path.relative('/', path.resolve(Cypress.spec.relative, '../assets/example.txt'));
-    cy.get('.dropzone').selectFile(filePath, { action: 'drag-drop' });
+    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('.CodeMirror').click().type('{ctrl+s}');
+    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('.CodeMirror-code').should('contain.text', body);
-    cy.get('.CodeMirror-code').should('contain.text', '[example.txt](/attachment/64b000000000000000000000');
+    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`);
   });
@@ -131,11 +121,9 @@ context('Editor while navigation', () => {
 
     // page1
     const bodyHello = 'hello';
-    cy.get('.CodeMirror').type(bodyHello);
-    cy.get('.CodeMirror').should('contain.text', bodyHello);
-    cy.get('.page-editor-preview-body').should('contain.text', bodyHello);
-    cy.getByTestid('page-editor').should('be.visible');
-    cy.get('.CodeMirror').screenshot(`${ssPrefix}-editor-for-page1`);
+    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();
@@ -159,31 +147,29 @@ context('Editor while navigation', () => {
     })
 
     openEditor();
-
-    cy.get('.CodeMirror').screenshot(`${ssPrefix}-editor-for-page2`);
+    cy.get('.cm-content').screenshot(`${ssPrefix}-editor-for-page2`);
 
     // type (without save)
     const bodyWorld = ' world!!'
-    cy.get('.CodeMirror').type(`${bodyWorld}`);
-    cy.get('.CodeMirror').should('contain.text', `${bodyHello}${bodyWorld}`);
-    cy.get('.page-editor-preview-body').should('contain.text', `${bodyHello}${bodyWorld}`);
-    cy.get('.CodeMirror').screenshot(`${ssPrefix}-editor-for-page2-modified`);
+    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('.CodeMirror').type('\n\n[page1](./page1)');
+    cy.get('.cm-content').type('\n\n[page1](./page1)');
 
     // go to page1
-    cy.get('.page-editor-preview-body').within(() => {
+    cy.getByTestid('page-editor-preview-body').within(() => {
       cy.get("a:contains('page1')").click();
     });
 
     openEditor();
 
-    cy.get('.CodeMirror').screenshot(`${ssPrefix}-editor-for-page1-returned`);
+    cy.get('.cm-content').screenshot(`${ssPrefix}-editor-for-page1-returned`);
 
     // expect
-    cy.get('.CodeMirror').should('contain.text', bodyHello);
-    cy.get('.CodeMirror').should('not.contain.text', bodyWorld); // text that added to page2
-    cy.get('.CodeMirror').should('not.contain.text', 'page1'); // text that added to page2
+    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
   });
 });

+ 164 - 18
apps/app/test/integration/service/page-grant.test.js → apps/app/test/integration/service/page-grant.test.ts

@@ -1,14 +1,15 @@
 import { GroupType, PageGrant } from '@growi/core';
 import mongoose from 'mongoose';
 
-import { ExternalGroupProviderType } from '~/features/external-user-group/interfaces/external-user-group';
-import ExternalUserGroup from '~/features/external-user-group/server/models/external-user-group';
-import ExternalUserGroupRelation from '~/features/external-user-group/server/models/external-user-group-relation';
-import UserGroup from '~/server/models/user-group';
-
+import { ExternalGroupProviderType } from '../../../src/features/external-user-group/interfaces/external-user-group';
+import ExternalUserGroup from '../../../src/features/external-user-group/server/models/external-user-group';
+import ExternalUserGroupRelation from '../../../src/features/external-user-group/server/models/external-user-group-relation';
+import { UserGroupPageGrantStatus } from '../../../src/interfaces/page';
+import UserGroup from '../../../src/server/models/user-group';
+import UserGroupRelation from '../../../src/server/models/user-group-relation';
+import type { IPageGrantService } from '../../../src/server/service/page-grant';
 import { getInstance } from '../setup-crowi';
 
-
 /*
  * There are 3 grant types to test.
  * GRANT_PUBLIC, GRANT_OWNER, GRANT_USER_GROUP
@@ -19,13 +20,12 @@ describe('PageGrantService', () => {
    */
   let User;
   let Page;
-  let UserGroupRelation;
 
   /*
    * global instances
    */
   let crowi;
-  let pageGrantService;
+  let pageGrantService: IPageGrantService;
   let xssSpy;
 
   let user1;
@@ -86,6 +86,14 @@ describe('PageGrantService', () => {
   const pageE3GroupChildPath = '/E3/GroupChild';
   const pageE3User1Path = '/E3/User1';
 
+  // getPageGroupGrantData test data
+  let user3;
+  let groupGrantDataTestChildPagePath;
+  let groupGrantDataTestParentUserGroupId;
+  let groupGrantDataTestChildUserGroupId;
+  let groupGrantDataTestExternalUserGroupId;
+  let groupGrantDataTestExternalUserGroupId2;
+
   const createDocumentsToTestIsGrantNormalized = async() => {
     // Users
     await User.insertMany([
@@ -375,6 +383,97 @@ describe('PageGrantService', () => {
     pageE3User1 = await Page.findOne({ path: pageE3User1Path });
   };
 
+  const createDocumentsToTestGetPageGroupGrantData = async() => {
+    await User.insertMany([
+      { name: 'User3', username: 'User3', email: 'user3@example.com' },
+    ]);
+    user3 = await User.findOne({ username: 'User3' });
+
+    groupGrantDataTestParentUserGroupId = new mongoose.Types.ObjectId();
+    groupGrantDataTestChildUserGroupId = new mongoose.Types.ObjectId();
+    await UserGroup.insertMany([
+      {
+        _id: groupGrantDataTestParentUserGroupId, // cannotGrant
+        name: 'groupGrantDataTestParentGroup',
+        parent: null,
+      },
+      {
+        _id: groupGrantDataTestChildUserGroupId, // isGranted
+        name: 'groupGrantDataTestChildGroup',
+        parent: groupGrantDataTestParentUserGroupId,
+      },
+    ]);
+
+    await UserGroupRelation.insertMany([
+      {
+        relatedGroup: groupGrantDataTestParentUserGroupId._id,
+        relatedUser: user3._id,
+      },
+      {
+        relatedGroup: groupGrantDataTestChildUserGroupId._id,
+        relatedUser: user3._id,
+      },
+    ]);
+
+    groupGrantDataTestExternalUserGroupId = new mongoose.Types.ObjectId();
+    groupGrantDataTestExternalUserGroupId2 = new mongoose.Types.ObjectId();
+    await ExternalUserGroup.insertMany([
+      {
+        _id: groupGrantDataTestExternalUserGroupId,
+        name: 'groupGrantDataTestExternalGroup',
+        externalId: 'groupGrantDataTestExternalGroup',
+        provider: ExternalGroupProviderType.ldap,
+        parent: null,
+      },
+      {
+        _id: groupGrantDataTestExternalUserGroupId2,
+        name: 'groupGrantDataTestExternalGroup2',
+        externalId: 'groupGrantDataTestExternalGroup2',
+        provider: ExternalGroupProviderType.ldap,
+        parent: null,
+      },
+    ]);
+
+    await ExternalUserGroupRelation.insertMany([
+      {
+        relatedGroup: groupGrantDataTestExternalUserGroupId._id,
+        relatedUser: user3._id,
+      },
+    ]);
+
+    const groupGrantDataTestParentPagePath = '/groupGrantDataTestParentPage';
+    const groupGrantDataTestParentPageId = new mongoose.Types.ObjectId();
+    groupGrantDataTestChildPagePath = '/groupGrantDataTestParentPage/groupGrantDataTestChildPagePath';
+    await Page.insertMany([
+      {
+        _id: groupGrantDataTestParentPageId,
+        path: groupGrantDataTestParentPagePath,
+        grant: Page.GRANT_USER_GROUP,
+        creator: user3._id,
+        lastUpdateUser: user3._id,
+        grantedUsers: null,
+        grantedGroups: [
+          { item: groupGrantDataTestChildUserGroupId._id, type: GroupType.userGroup },
+          { item: groupGrantDataTestExternalUserGroupId._id, type: GroupType.externalUserGroup },
+          { item: groupGrantDataTestExternalUserGroupId2._id, type: GroupType.externalUserGroup },
+        ],
+        parent: rootPage._id,
+      },
+      {
+        path: groupGrantDataTestChildPagePath,
+        grant: Page.GRANT_USER_GROUP,
+        creator: user3._id,
+        lastUpdateUser: user3._id,
+        grantedUsers: null,
+        grantedGroups: [
+          { item: groupGrantDataTestChildUserGroupId._id, type: GroupType.userGroup },
+          { item: groupGrantDataTestExternalUserGroupId2._id, type: GroupType.externalUserGroup },
+        ],
+        parent: groupGrantDataTestParentPageId,
+      },
+    ]);
+  };
+
   /*
    * prepare before all tests
    */
@@ -385,11 +484,11 @@ describe('PageGrantService', () => {
 
     User = mongoose.model('User');
     Page = mongoose.model('Page');
-    UserGroupRelation = mongoose.model('UserGroupRelation');
 
     rootPage = await Page.findOne({ path: '/' });
 
     await createDocumentsToTestIsGrantNormalized();
+    await createDocumentsToTestGetPageGroupGrantData();
 
     xssSpy = jest.spyOn(crowi.xss, 'process').mockImplementation(path => path);
   });
@@ -398,7 +497,7 @@ describe('PageGrantService', () => {
     test('Should return true when Ancestor: root, Target: public', async() => {
       const targetPath = '/NEW';
       const grant = Page.GRANT_PUBLIC;
-      const grantedUserIds = null;
+      const grantedUserIds = undefined;
       const grantedGroupIds = [];
       const shouldCheckDescendants = false;
 
@@ -410,7 +509,7 @@ describe('PageGrantService', () => {
     test('Should return true when Ancestor: root, Target: GroupParent', async() => {
       const targetPath = '/NEW_GroupParent';
       const grant = Page.GRANT_USER_GROUP;
-      const grantedUserIds = null;
+      const grantedUserIds = undefined;
       const grantedGroupIds = [{ item: groupParent._id, type: GroupType.userGroup }, { item: externalGroupParent._id, type: GroupType.externalUserGroup }];
       const shouldCheckDescendants = false;
 
@@ -422,7 +521,7 @@ describe('PageGrantService', () => {
     test('Should return true when Ancestor: under-root public, Target: public', async() => {
       const targetPath = `${pageRootPublicPath}/NEW`;
       const grant = Page.GRANT_PUBLIC;
-      const grantedUserIds = null;
+      const grantedUserIds = undefined;
       const grantedGroupIds = [];
       const shouldCheckDescendants = false;
 
@@ -434,7 +533,7 @@ describe('PageGrantService', () => {
     test('Should return true when Ancestor: under-root GroupParent, Target: GroupParent', async() => {
       const targetPath = `${pageRootGroupParentPath}/NEW`;
       const grant = Page.GRANT_USER_GROUP;
-      const grantedUserIds = null;
+      const grantedUserIds = undefined;
       const grantedGroupIds = [{ item: groupParent._id, type: GroupType.userGroup }, { item: externalGroupParent._id, type: GroupType.externalUserGroup }];
       const shouldCheckDescendants = false;
 
@@ -446,7 +545,7 @@ describe('PageGrantService', () => {
     test('Should return true when Ancestor: public, Target: public', async() => {
       const targetPath = `${pageE1PublicPath}/NEW`;
       const grant = Page.GRANT_PUBLIC;
-      const grantedUserIds = null;
+      const grantedUserIds = undefined;
       const grantedGroupIds = [];
       const shouldCheckDescendants = false;
 
@@ -470,7 +569,7 @@ describe('PageGrantService', () => {
     test('Should return false when Ancestor: owned by GroupParent, Target: public', async() => {
       const targetPath = `${pageE3GroupParentPath}/NEW`;
       const grant = Page.GRANT_PUBLIC;
-      const grantedUserIds = null;
+      const grantedUserIds = undefined;
       const grantedGroupIds = [];
       const shouldCheckDescendants = false;
 
@@ -482,7 +581,7 @@ describe('PageGrantService', () => {
     test('Should return false when Ancestor: owned by GroupChild, Target: GroupParent', async() => {
       const targetPath = `${pageE3GroupChildPath}/NEW`;
       const grant = Page.GRANT_USER_GROUP;
-      const grantedUserIds = null;
+      const grantedUserIds = undefined;
       const grantedGroupIds = [{ item: groupParent._id, type: GroupType.userGroup }, { item: externalGroupParent._id, type: GroupType.externalUserGroup }];
       const shouldCheckDescendants = false;
 
@@ -496,7 +595,7 @@ describe('PageGrantService', () => {
     test('Should return true when Target: public, Descendant: public', async() => {
       const targetPath = emptyPagePath1;
       const grant = Page.GRANT_PUBLIC;
-      const grantedUserIds = null;
+      const grantedUserIds = undefined;
       const grantedGroupIds = [];
       const shouldCheckDescendants = true;
 
@@ -520,7 +619,7 @@ describe('PageGrantService', () => {
     test('Should return true when Target: owned by GroupParent, Descendant: GroupParent, GroupChild and User1', async() => {
       const targetPath = emptyPagePath3;
       const grant = Page.GRANT_USER_GROUP;
-      const grantedUserIds = null;
+      const grantedUserIds = undefined;
       const grantedGroupIds = [{ item: groupParent._id, type: GroupType.userGroup }, { item: externalGroupParent._id, type: GroupType.externalUserGroup }];
       const shouldCheckDescendants = true;
 
@@ -818,4 +917,51 @@ describe('PageGrantService', () => {
       );
     });
   });
+  describe('Test for getPageGroupGrantData', () => {
+    test('return expected group grant data', async() => {
+      const groupGrantDataTestChildPage = await Page.findOne({ path: groupGrantDataTestChildPagePath });
+      const result = await pageGrantService.getPageGroupGrantData(groupGrantDataTestChildPage, user3);
+      expect(result).toStrictEqual({
+        userRelatedGroups: [
+          {
+            id: groupGrantDataTestExternalUserGroupId.toString(),
+            name: 'groupGrantDataTestExternalGroup',
+            type: GroupType.externalUserGroup,
+            provider: ExternalGroupProviderType.ldap,
+            status: UserGroupPageGrantStatus.notGranted,
+          },
+          {
+            id: groupGrantDataTestChildUserGroupId.toString(),
+            name: 'groupGrantDataTestChildGroup',
+            type: GroupType.userGroup,
+            provider: undefined,
+            status: UserGroupPageGrantStatus.isGranted,
+          },
+          {
+            id: groupGrantDataTestParentUserGroupId.toString(),
+            name: 'groupGrantDataTestParentGroup',
+            type: GroupType.userGroup,
+            provider: undefined,
+            status: UserGroupPageGrantStatus.cannotGrant,
+          },
+        ],
+        nonUserRelatedGrantedGroups: [
+          {
+            id: groupGrantDataTestExternalUserGroupId2.toString(),
+            name: 'groupGrantDataTestExternalGroup2',
+            type: GroupType.externalUserGroup,
+            provider: ExternalGroupProviderType.ldap,
+          },
+        ],
+      });
+    });
+
+    test('return empty arrays when page is root', async() => {
+      const result = await pageGrantService.getPageGroupGrantData(rootPage, user1);
+      expect(result).toStrictEqual({
+        userRelatedGroups: [],
+        nonUserRelatedGrantedGroups: [],
+      });
+    });
+  });
 });

+ 1 - 1
packages/editor/src/components/CodeMirrorEditor/Toolbar/TemplateButton.tsx

@@ -22,7 +22,7 @@ export const TemplateButton = (props: Props): JSX.Element => {
   }, [codeMirrorEditor?.view, openTemplateModal]);
 
   return (
-    <button type="button" className="btn btn-toolbar-button" onClick={onClickTempleteButton}>
+    <button type="button" className="btn btn-toolbar-button" onClick={onClickTempleteButton} data-testid="open-template-button">
       <span className="material-symbols-outlined fs-5">file_copy</span>
     </button>
   );

+ 3 - 3
yarn.lock

@@ -16992,9 +16992,9 @@ tar-stream@^3.0.0:
     streamx "^2.15.0"
 
 tar@^6.1.11, tar@^6.1.2:
-  version "6.2.0"
-  resolved "https://registry.yarnpkg.com/tar/-/tar-6.2.0.tgz#b14ce49a79cb1cd23bc9b016302dea5474493f73"
-  integrity sha512-/Wo7DcT0u5HUV486xg675HtjNd3BXZ6xDbzsCUZPt5iw8bTQ63bP0Raut3mvro9u+CUyq7YQd8Cx55fsZXxqLQ==
+  version "6.2.1"
+  resolved "https://registry.yarnpkg.com/tar/-/tar-6.2.1.tgz#717549c541bc3c2af15751bea94b1dd068d4b03a"
+  integrity sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==
   dependencies:
     chownr "^2.0.0"
     fs-minipass "^2.0.0"