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

Merge branch 'master' into fix/cypress-error-10-30-50

Shun Miyazawa 3 лет назад
Родитель
Сommit
8d00b6bd0f
63 измененных файлов с 813 добавлено и 888 удалено
  1. 9 8
      .github/workflows/release.yml
  2. 2 16
      packages/app/package.json
  3. 1 1
      packages/app/public/static/locales/en_US/translation.json
  4. 1 1
      packages/app/public/static/locales/ja_JP/translation.json
  5. 1 1
      packages/app/public/static/locales/zh_CN/translation.json
  6. 13 7
      packages/app/src/client/services/AdminMarkDownContainer.js
  7. 32 0
      packages/app/src/client/services/layout.ts
  8. 4 1
      packages/app/src/client/services/page-operation.ts
  9. 5 6
      packages/app/src/components/Layout/AdminLayout.tsx
  10. 14 11
      packages/app/src/components/Layout/BasicLayout.tsx
  11. 2 3
      packages/app/src/components/Layout/NoLoginLayout.tsx
  12. 1 3
      packages/app/src/components/Layout/RawLayout.tsx
  13. 2 11
      packages/app/src/components/Layout/SearchResultLayout.tsx
  14. 4 9
      packages/app/src/components/Layout/ShareLinkLayout.tsx
  15. 8 2
      packages/app/src/components/Navbar/AppearanceModeDropdown.tsx
  16. 1 1
      packages/app/src/components/PageEditor/Editor.tsx
  17. 7 2
      packages/app/src/components/PageEditorByHackmd.tsx
  18. 1 1
      packages/app/src/components/Sidebar/PageTree/PrivateLegacyPagesLink.tsx
  19. 2 3
      packages/app/src/components/StickyStretchableScroller.tsx
  20. 2 2
      packages/app/src/components/TableOfContents.tsx
  21. 76 82
      packages/app/src/pages/[[...path]].page.tsx
  22. 13 2
      packages/app/src/pages/_app.page.tsx
  23. 8 6
      packages/app/src/pages/_private-legacy-pages.page.tsx
  24. 17 17
      packages/app/src/pages/_search.page.tsx
  25. 7 1
      packages/app/src/pages/admin/[...path].page.tsx
  26. 8 3
      packages/app/src/pages/admin/app.page.tsx
  27. 7 2
      packages/app/src/pages/admin/audit-log.page.tsx
  28. 8 3
      packages/app/src/pages/admin/customize.page.tsx
  29. 8 3
      packages/app/src/pages/admin/export.page.tsx
  30. 7 3
      packages/app/src/pages/admin/global-notification/[globalNotificationId].page.tsx
  31. 6 2
      packages/app/src/pages/admin/global-notification/new.page.tsx
  32. 8 3
      packages/app/src/pages/admin/importer.page.tsx
  33. 6 2
      packages/app/src/pages/admin/index.page.tsx
  34. 9 3
      packages/app/src/pages/admin/markdown.page.tsx
  35. 8 3
      packages/app/src/pages/admin/notification.page.tsx
  36. 6 2
      packages/app/src/pages/admin/plugins.page.tsx
  37. 7 2
      packages/app/src/pages/admin/search.page.tsx
  38. 8 4
      packages/app/src/pages/admin/security.page.tsx
  39. 7 2
      packages/app/src/pages/admin/slack-integration-legacy.page.tsx
  40. 8 3
      packages/app/src/pages/admin/slack-integration.page.tsx
  41. 7 3
      packages/app/src/pages/admin/user-group-detail/[userGroupId].page.tsx
  42. 7 2
      packages/app/src/pages/admin/user-groups.page.tsx
  43. 6 2
      packages/app/src/pages/admin/users/external-accounts.page.tsx
  44. 7 2
      packages/app/src/pages/admin/users/index.page.tsx
  45. 7 2
      packages/app/src/pages/installer.page.tsx
  46. 7 2
      packages/app/src/pages/invited.page.tsx
  47. 7 2
      packages/app/src/pages/login.page.tsx
  48. 17 6
      packages/app/src/pages/me/[[...path]].page.tsx
  49. 94 79
      packages/app/src/pages/share/[[...path]].page.tsx
  50. 15 5
      packages/app/src/pages/tags.page.tsx
  51. 25 6
      packages/app/src/pages/trash.page.tsx
  52. 9 2
      packages/app/src/pages/user-activation.page.tsx
  53. 2 2
      packages/app/src/pages/utils/commons.ts
  54. 3 1
      packages/app/src/server/models/config.ts
  55. 16 7
      packages/app/src/server/routes/apiv3/markdown-setting.js
  56. 10 1
      packages/app/src/stores/middlewares/sync-to-storage.ts
  57. 14 8
      packages/app/src/stores/ui.tsx
  58. 24 18
      packages/app/src/stores/websocket.tsx
  59. 2 2
      packages/app/src/styles/_layout.scss
  60. 8 11
      packages/app/test/cypress/integration/20-basic-features/20-basic-features--access-to-page.spec.ts
  61. 11 8
      packages/app/test/cypress/integration/20-basic-features/20-basic-features--access-to-pagelist.spec.ts
  62. 67 51
      packages/app/test/cypress/integration/20-basic-features/20-basic-features--use-tools.spec.ts
  63. 104 429
      yarn.lock

+ 9 - 8
.github/workflows/release.yml

@@ -144,7 +144,7 @@ jobs:
 
     - name: Docker meta
       id: meta
-      uses: docker/metadata-action@v3
+      uses: docker/metadata-action@v4
       with:
         images: weseek/growi,ghcr.io/weseek/growi
         flavor: |
@@ -155,22 +155,24 @@ jobs:
           type=semver,value=${{ needs.create-github-release.outputs.RELEASED_VERSION }},pattern={{major}}.{{minor}}
           type=semver,value=${{ needs.create-github-release.outputs.RELEASED_VERSION }},pattern={{major}}.{{minor}}.{{patch}}
 
-    - name: Login to docker.io registry
-      run: |
-        echo ${{ secrets. DOCKER_REGISTRY_PASSWORD }} | docker login --username wsmoogle --password-stdin
+    - name: Login to Docker Hub
+      uses: docker/login-action@v2
+      with:
+        username: wsmoogle
+        password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
 
     - name: Login to GitHub Container Registry
-      uses: docker/login-action@v1
+      uses: docker/login-action@v2
       with:
         registry: ghcr.io
         username: wsmoogle
         password: ${{ secrets.DOCKER_REGISTRY_ON_GITHUB_PASSWORD }}
 
     - name: Set up Docker Buildx
-      uses: docker/setup-buildx-action@v1
+      uses: docker/setup-buildx-action@v2
 
     - name: Build and push
-      uses: docker/build-push-action@v2
+      uses: docker/build-push-action@v3
       with:
         context: .
         file: ./packages/app/docker/Dockerfile
@@ -202,4 +204,3 @@ jobs:
       run: |
         STATUS=`git status --porcelain`
         if [ -z "$STATUS" ]; then exit 0; else exit 1; fi
-

+ 2 - 16
packages/app/package.json

@@ -74,14 +74,12 @@
     "@growi/slack": "^6.0.0-RC.9",
     "@promster/express": "^7.0.2",
     "@promster/server": "^7.0.4",
-    "@slack/events-api": "^3.0.0",
     "@slack/web-api": "^6.2.4",
     "@slack/webhook": "^6.0.0",
     "JSONStream": "^1.3.5",
     "archiver": "^5.3.0",
     "array.prototype.flatmap": "^1.2.2",
     "async-canvas-to-blob": "^1.0.3",
-    "aws-sdk": "^2.1044.0",
     "axios": "^0.24.0",
     "axios-retry": "^3.2.4",
     "body-parser": "^1.18.2",
@@ -150,8 +148,6 @@
     "passport-local": "^1.0.0",
     "passport-saml": "^3.2.0",
     "passport-twitter": "^1.0.4",
-    "prism-themes": "^1.9.0",
-    "prom-client": "^13.0.0",
     "rate-limiter-flexible": "^2.3.7",
     "react": "^18.2.0",
     "react-bootstrap-typeahead": "^5.2.2",
@@ -181,10 +177,7 @@
     "remark-gfm": "^3.0.1",
     "remark-math": "^5.1.1",
     "remark-wiki-link": "^1.0.4",
-    "rimraf": "^3.0.0",
-    "simplebar-react": "^2.3.6",
     "socket.io": "^4.2.0",
-    "sticky-events": "^3.4.11",
     "stream-to-promise": "^3.0.0",
     "string-width": "=4.2.2",
     "superjson": "^1.9.1",
@@ -212,19 +205,12 @@
     "@handsontable/react": "=2.1.0",
     "@icon/themify-icons": "1.0.1-alpha.3",
     "@next/bundle-analyzer": "^12.2.3",
-    "@types/compression": "^1.7.0",
     "@types/express": "^4.17.11",
-    "@types/jquery": "^3.5.8",
-    "@types/multer": "^1.4.5",
     "@types/react-scroll": "^1.8.4",
     "autoprefixer": "^9.0.0",
     "babel-loader": "^8.2.5",
     "bootstrap": "^4.6.1",
-    "browser-sync": "^2.27.7",
-    "bunyan-debug": "^2.0.0",
-    "cli": "~1.0.1",
     "codemirror": "^5.64.0",
-    "colors": "=1.4.0",
     "connect-browser-sync": "^2.1.0",
     "core-js": "=2.6.9",
     "diff2html": "^3.1.2",
@@ -242,23 +228,23 @@
     "material-icons": "^1.11.3",
     "morgan": "^1.10.0",
     "next-transpile-modules": "^9.0.0",
-    "normalize-path": "^3.0.0",
     "penpal": "^4.0.0",
     "plantuml-encoder": "^1.2.5",
     "prettier": "^1.19.1",
     "react-codemirror2": "^6.0.0",
     "react-copy-to-clipboard": "^5.0.1",
     "react-dropzone": "^11.2.4",
-    "react-frame-component": "^4.0.0",
     "react-hotkeys": "^2.0.0",
     "react-waypoint": "^10.1.0",
     "rehype-rewrite": "^3.0.6",
     "replacestream": "^4.0.3",
     "reveal.js": "^4.3.1",
     "sass": "^1.53.0",
+    "simplebar-react": "^2.3.6",
     "simple-line-icons": "^2.5.5",
     "simple-load-script": "^1.0.2",
     "socket.io-client": "^4.2.0",
+    "sticky-events": "^3.4.11",
     "swagger2openapi": "^5.3.1",
     "ts-node-dev": "^2.0.0",
     "tsc-alias": "^1.2.9"

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

@@ -570,6 +570,7 @@
     }
   },
   "private_legacy_pages": {
+    "title": "Private Legacy Pages",
     "bulk_operation": "Bulk operation",
     "convert_all_selected_pages": "Convert all to new v5 compatible format",
     "input_path_to_convert": "Input a path to convert pages",
@@ -731,7 +732,6 @@
     "logout": "Logout"
   },
   "pagetree": {
-    "private_legacy_pages": "Private Legacy Pages",
     "cannot_rename_a_title_that_contains_slash": "Cannot rename a title that contains '/'",
     "you_cannot_move_this_page_now": "You cannot move this page now",
     "something_went_wrong_with_moving_page": "Something went wrong with moving page"

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

@@ -569,6 +569,7 @@
     }
   },
   "private_legacy_pages": {
+    "title": "旧形式のプライベートページ",
     "bulk_operation": "一括操作",
     "convert_all_selected_pages": "新しい v5 互換形式に一括変換",
     "input_path_to_convert": "パスを入力して変換",
@@ -730,7 +731,6 @@
     "logout": "ログアウト"
   },
   "pagetree": {
-    "private_legacy_pages": "旧形式のプライベートページ",
     "cannot_rename_a_title_that_contains_slash": "`/` が含まれているタイトルにリネームできません",
     "you_cannot_move_this_page_now": "現在、このページを移動することはできません",
     "something_went_wrong_with_moving_page": "ページの移動に問題が発生しました"

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

@@ -574,6 +574,7 @@
     }
 	},
   "private_legacy_pages": {
+    "title": "私人遗留页面",
     "bulk_operation": "批量操作",
     "convert_all_selected_pages": "全部转换为新的v5兼容格式",
 		"input_path_to_convert": "输入一个转换页面的路径",
@@ -735,7 +736,6 @@
     "logout": "登出"
   },
   "pagetree": {
-    "private_legacy_pages": "私人遗留页面",
     "cannot_rename_a_title_that_contains_slash": "不能重命名包含 ’/' 的标题",
     "you_cannot_move_this_page_now": "你现在不能移动这个页面",
     "something_went_wrong_with_moving_page": "移动页面时出了问题"

+ 13 - 7
packages/app/src/client/services/AdminMarkDownContainer.js

@@ -29,7 +29,7 @@ export default class AdminMarkDownContainer extends Container {
       isEnabledXss: false,
       xssOption: '',
       tagWhiteList: '',
-      attrWhiteList: '',
+      attrWhiteList: '{}',
     };
 
     this.switchEnableXss = this.switchEnableXss.bind(this);
@@ -119,19 +119,25 @@ export default class AdminMarkDownContainer extends Container {
    * Update Xss Setting
    */
   async updateXssSetting() {
-    let { tagWhiteList, attrWhiteList } = this.state;
+    let { tagWhiteList } = this.state;
+    const { attrWhiteList } = this.state;
 
     tagWhiteList = Array.isArray(tagWhiteList) ? tagWhiteList : tagWhiteList.split(',');
-    attrWhiteList = Array.isArray(attrWhiteList) ? attrWhiteList : attrWhiteList.split(',');
 
-    const response = await apiv3Put('/markdown-setting/xss', {
+    try {
+      // Check if parsing is possible
+      JSON.parse(attrWhiteList);
+    }
+    catch (err) {
+      throw Error(err);
+    }
+
+    await apiv3Put('/markdown-setting/xss', {
       isEnabledXss: this.state.isEnabledXss,
       xssOption: this.state.xssOption,
       tagWhiteList,
-      attrWhiteList,
+      attrWhiteList: attrWhiteList ?? '{}',
     });
-
-    return response;
   }
 
   /**

+ 32 - 0
packages/app/src/client/services/layout.ts

@@ -0,0 +1,32 @@
+import { useIsContainerFluid, useShareLinkId } from '~/stores/context';
+import { useSWRxCurrentPage } from '~/stores/page';
+import { useEditorMode } from '~/stores/ui';
+
+export const useEditorModeClassName = (): string => {
+  const { getClassNamesByEditorMode } = useEditorMode();
+
+  // TODO: Enable `editing-sidebar` class somehow
+  // https://redmine.weseek.co.jp/issues/111527
+  // const classNames: string[] = [];
+  // if (currentPage != null) {
+  //   const isSidebar = currentPage.path === '/Sidebar';
+  //   classNames.push(...getClassNamesByEditorMode(/* isSidebar */));
+  // }
+
+  return `${getClassNamesByEditorMode().join(' ') ?? ''}`;
+};
+
+export const useCurrentGrowiLayoutFluidClassName = (): string => {
+  const { data: shareLinkId } = useShareLinkId();
+  const { data: currentPage } = useSWRxCurrentPage(shareLinkId ?? undefined);
+
+  const { data: dataIsContainerFluid } = useIsContainerFluid();
+
+  const isContainerFluidEachPage = currentPage == null || !('expandContentWidth' in currentPage)
+    ? null
+    : currentPage.expandContentWidth;
+  const isContainerFluidDefault = dataIsContainerFluid;
+  const isContainerFluid = isContainerFluidEachPage ?? isContainerFluidDefault;
+
+  return isContainerFluid ? 'growi-layout-fluid' : '';
+};

+ 4 - 1
packages/app/src/client/services/page-operation.ts

@@ -3,7 +3,7 @@ import urljoin from 'url-join';
 
 import { OptionsToSave } from '~/interfaces/page-operation';
 import { useCurrentPageId } from '~/stores/context';
-import { useIsEnabledUnsavedWarning, usePageTagsForEditors } from '~/stores/editor';
+import { useEditingMarkdown, useIsEnabledUnsavedWarning, usePageTagsForEditors } from '~/stores/editor';
 import { useSWRxCurrentPage, useSWRxTagsInfo } from '~/stores/page';
 import { useSetRemoteLatestPageData } from '~/stores/remote-latest-page';
 import loggerFactory from '~/utils/logger';
@@ -181,6 +181,7 @@ export const useUpdateStateAfterSave = (pageId: string|undefined|null): (() => P
   const { setRemoteLatestPageData } = useSetRemoteLatestPageData();
   const { mutate: mutateTagsInfo } = useSWRxTagsInfo(pageId);
   const { sync: syncTagsInfoForEditor } = usePageTagsForEditors(pageId);
+  const { mutate: mutateEditingMarkdown } = useEditingMarkdown();
 
   if (pageId == null) { return }
 
@@ -194,6 +195,8 @@ export const useUpdateStateAfterSave = (pageId: string|undefined|null): (() => P
 
     if (updatedPage == null) { return }
 
+    mutateEditingMarkdown(updatedPage.revision.body);
+
     const remoterevisionData = {
       remoteRevisionId: updatedPage.revision._id,
       remoteRevisionBody: updatedPage.revision.body,

+ 5 - 6
packages/app/src/components/Layout/AdminLayout.tsx

@@ -8,25 +8,24 @@ import { RawLayout } from './RawLayout';
 
 import styles from './Admin.module.scss';
 
+
+const AdminNavigation = dynamic(() => import('~/components/Admin/Common/AdminNavigation'), { ssr: false });
+const SystemVersion = dynamic(() => import('../SystemVersion'), { ssr: false });
 const HotkeysManager = dynamic(() => import('../Hotkeys/HotkeysManager'), { ssr: false });
 
 
 type Props = {
-  title?: string
   componentTitle?: string
   children?: ReactNode
 }
 
 
 const AdminLayout = ({
-  children, title, componentTitle,
+  children, componentTitle,
 }: Props): JSX.Element => {
 
-  const AdminNavigation = dynamic(() => import('~/components/Admin/Common/AdminNavigation'), { ssr: false });
-  const SystemVersion = dynamic(() => import('../SystemVersion'), { ssr: false });
-
   return (
-    <RawLayout title={title}>
+    <RawLayout>
       <div className={`admin-page ${styles['admin-page']}`}>
         <GrowiNavbar isGlobalSearchHidden={true} />
 

+ 14 - 11
packages/app/src/components/Layout/BasicLayout.tsx

@@ -4,6 +4,7 @@ import dynamic from 'next/dynamic';
 import { DndProvider } from 'react-dnd';
 import { HTML5Backend } from 'react-dnd-html5-backend';
 
+import { useEditorModeClassName } from '../../client/services/layout';
 import { GrowiNavbar } from '../Navbar/GrowiNavbar';
 import Sidebar from '../Sidebar';
 
@@ -27,21 +28,13 @@ const Fab = dynamic(() => import('../Fab').then(mod => mod.Fab), { ssr: false })
 
 
 type Props = {
-  title: string
-  className?: string,
-  expandContainer?: boolean,
   children?: ReactNode
+  className?: string
 }
 
-export const BasicLayout = ({
-  children, title, className, expandContainer,
-}: Props): JSX.Element => {
-
-  const myClassName = `${className ?? ''} ${expandContainer ? 'growi-layout-fluid' : ''}`;
-
+export const BasicLayout = ({ children, className }: Props): JSX.Element => {
   return (
-    <RawLayout title={title} className={myClassName}>
-
+    <RawLayout className={className ?? ''}>
       <DndProvider backend={HTML5Backend}>
         <GrowiNavbar />
 
@@ -75,3 +68,13 @@ export const BasicLayout = ({
     </RawLayout>
   );
 };
+
+export const BasicLayoutWithEditorMode = ({ children }: Props): JSX.Element => {
+  const className = useEditorModeClassName();
+
+  return (
+    <BasicLayout className={className}>
+      {children}
+    </BasicLayout>
+  );
+};

+ 2 - 3
packages/app/src/components/Layout/NoLoginLayout.tsx

@@ -7,20 +7,19 @@ import { RawLayout } from './RawLayout';
 import commonStyles from './NoLoginLayout.module.scss';
 
 type Props = {
-  title: string,
   className?: string,
   children?: ReactNode,
 }
 
 export const NoLoginLayout = ({
-  children, title, className,
+  children, className,
 }: Props): JSX.Element => {
   const classNames: string[] = ['wrapper'];
   if (className != null) {
     classNames.push(className);
   }
   return (
-    <RawLayout title={title} className={`${commonStyles.nologin}`}>
+    <RawLayout className={`${commonStyles.nologin}`}>
       <div className="nologin">
         <div id="wrapper">
           <div id="page-wrapper">

+ 1 - 3
packages/app/src/components/Layout/RawLayout.tsx

@@ -12,12 +12,11 @@ const logger = loggerFactory('growi:cli:RawLayout');
 
 
 type Props = {
-  title?: string,
   className?: string,
   children?: ReactNode,
 }
 
-export const RawLayout = ({ children, title, className }: Props): JSX.Element => {
+export const RawLayout = ({ children, className }: Props): JSX.Element => {
   const classNames: string[] = ['layout-root', 'growi'];
   if (className != null) {
     classNames.push(className);
@@ -35,7 +34,6 @@ export const RawLayout = ({ children, title, className }: Props): JSX.Element =>
   return (
     <>
       <Head>
-        <title>{title}</title>
         <meta charSet="utf-8" />
         <meta name="viewport" content="initial-scale=1.0, width=device-width" />
       </Head>

+ 2 - 11
packages/app/src/components/Layout/SearchResultLayout.tsx

@@ -5,23 +5,14 @@ import { BasicLayout } from '~/components/Layout/BasicLayout';
 import commonStyles from './SearchResultLayout.module.scss';
 
 type Props = {
-  title: string,
-  className?: string,
   children?: ReactNode,
 }
 
-const SearchResultLayout = ({
-  children, title, className,
-}: Props): JSX.Element => {
-
-  const classNames: string[] = [];
-  if (className != null) {
-    classNames.push(className);
-  }
+const SearchResultLayout = ({ children }: Props): JSX.Element => {
 
   return (
     <div className={`on-search ${commonStyles['on-search']}`}>
-      <BasicLayout title={title} className={classNames.join(' ')}>
+      <BasicLayout>
         <div id="grw-fav-sticky-trigger" className="sticky-top"></div>
         <div id="main" className="main search-page mt-0">
           { children }

+ 4 - 9
packages/app/src/components/Layout/ShareLinkLayout.tsx

@@ -2,6 +2,7 @@ import React, { ReactNode } from 'react';
 
 import dynamic from 'next/dynamic';
 
+import { useEditorModeClassName } from '../../client/services/layout';
 import { GrowiNavbar } from '../Navbar/GrowiNavbar';
 
 import { RawLayout } from './RawLayout';
@@ -16,20 +17,14 @@ const Fab = dynamic(() => import('../Fab').then(mod => mod.Fab), { ssr: false })
 
 
 type Props = {
-  title: string
-  className?: string,
-  expandContainer?: boolean,
   children?: ReactNode
 }
 
-export const ShareLinkLayout = ({
-  children, title, className, expandContainer,
-}: Props): JSX.Element => {
-
-  const myClassName = `${className ?? ''} ${expandContainer ? 'growi-layout-fluid' : ''}`;
+export const ShareLinkLayout = ({ children }: Props): JSX.Element => {
+  const className = useEditorModeClassName();
 
   return (
-    <RawLayout title={title} className={myClassName}>
+    <RawLayout className={className}>
       <GrowiNavbar isGlobalSearchHidden={true} />
 
       <div className="page-wrapper d-flex d-print-block">

+ 8 - 2
packages/app/src/components/Navbar/AppearanceModeDropdown.tsx

@@ -113,10 +113,16 @@ export const AppearanceModeDropdown:FC<AppearanceModeDropdownProps> = (props: Ap
       <div className="dropdown-menu dropdown-menu-right">
 
         {/* sidebar mode */}
-        {[renderSidebarModeSwitch(false), dropdownDivider]}
+        {renderSidebarModeSwitch(false)}
+        {dropdownDivider}
 
         {/* side bar mode on editor */}
-        {isAuthenticated && [renderSidebarModeSwitch(true), dropdownDivider]}
+        {isAuthenticated && (
+          <>
+            {renderSidebarModeSwitch(true)}
+            {dropdownDivider}
+          </>
+        )}
 
         {/* color mode */}
         <h6 className="dropdown-header">{t('personal_dropdown.color_mode')}</h6>

+ 1 - 1
packages/app/src/components/PageEditor/Editor.tsx

@@ -246,7 +246,7 @@ const Editor: ForwardRefRenderFunction<IEditorMethods, EditorPropsType> = (props
     };
 
     return (
-      <Modal isOpen={isCheatsheetModalShown} toggle={hideCheatsheetModal} className={`modal-gfm-cheatsheet ${styles['modal-gfm-cheatsheet']}`} >
+      <Modal isOpen={isCheatsheetModalShown} toggle={hideCheatsheetModal} className={`modal-gfm-cheatsheet ${styles['modal-gfm-cheatsheet']}`} size={'lg'} >
         <ModalHeader tag="h4" toggle={hideCheatsheetModal} className="bg-primary text-light">
           <i className="icon-fw icon-question" />Markdown help
         </ModalHeader>

+ 7 - 2
packages/app/src/components/PageEditorByHackmd.tsx

@@ -259,6 +259,8 @@ export const PageEditorByHackmd = (): JSX.Element => {
       updateStateAfterSave?.();
       mutateTagsInfo();
 
+      mutateIsEnabledUnsavedWarning(false);
+
       logger.debug('success to save');
 
       toastSuccess(t('successfully_saved_the_page'));
@@ -267,7 +269,8 @@ export const PageEditorByHackmd = (): JSX.Element => {
       logger.error('failed to save', error);
       toastError(error.message);
     }
-  }, [currentPagePath, currentPathname, pageId, revisionIdHackmdSynced, optionsToSave, saveOrUpdate, mutatePageData, updateStateAfterSave, mutateTagsInfo, t]);
+  }, [currentPagePath, currentPathname, pageId, revisionIdHackmdSynced, optionsToSave,
+      saveOrUpdate, mutatePageData, updateStateAfterSave, mutateTagsInfo, mutateIsEnabledUnsavedWarning, t]);
 
   /**
    * onChange event of HackmdEditor handler
@@ -283,13 +286,15 @@ export const PageEditorByHackmd = (): JSX.Element => {
       return;
     }
 
+    mutateIsEnabledUnsavedWarning(true);
+
     try {
       await apiPost('/hackmd.saveOnHackmd', { pageId });
     }
     catch (err) {
       logger.error(err);
     }
-  }, [pageId, revision?.body, hackmdUri]);
+  }, [hackmdUri, pageId, revision?.body, mutateIsEnabledUnsavedWarning]);
 
   const penpalErrorOccuredHandler = useCallback((error) => {
     toastError(error.message);

+ 1 - 1
packages/app/src/components/Sidebar/PageTree/PrivateLegacyPagesLink.tsx

@@ -10,7 +10,7 @@ export const PrivateLegacyPagesLink: FC = memo(() => {
   return (
     <Link href="/_private-legacy-pages" prefetch={false}>
       <a className="h5 grw-private-legacy-pages-anchor text-decoration-none">
-        <i className="icon-drawer mr-2"></i> {t('pagetree.private_legacy_pages')}
+        <i className="icon-drawer mr-2"></i> {t('private_legacy_pages.title')}
       </a>
     </Link>
   );

+ 2 - 3
packages/app/src/components/StickyStretchableScroller.tsx

@@ -1,5 +1,5 @@
 import React, {
-  useEffect, useCallback, ReactNode, useRef, useState, useMemo, RefObject,
+  useEffect, useCallback, FC, useRef, useState, useMemo, RefObject,
 } from 'react';
 
 import SimpleBar from 'simplebar-react';
@@ -15,7 +15,6 @@ export type StickyStretchableScrollerProps = {
   stickyElemSelector: string,
   simplebarRef?: (ref: RefObject<SimpleBar>) => void,
   calcViewHeight?: (scrollElement: HTMLElement) => number,
-  children?: ReactNode,
 }
 
 /**
@@ -40,7 +39,7 @@ export type StickyStretchableScrollerProps = {
     </StickyStretchableScroller>
   );
  */
-export const StickyStretchableScroller = (props: StickyStretchableScrollerProps): JSX.Element => {
+export const StickyStretchableScroller: FC<StickyStretchableScrollerProps> = (props) => {
 
   const {
     children, stickyElemSelector, calcViewHeight, simplebarRef: setSimplebarRef,

+ 2 - 2
packages/app/src/components/TableOfContents.tsx

@@ -1,14 +1,13 @@
 import React, { useCallback } from 'react';
 
 import { pagePathUtils } from '@growi/core';
+import dynamic from 'next/dynamic';
 import ReactMarkdown from 'react-markdown';
 
 import { useCurrentPagePath } from '~/stores/page';
 import { useTocOptions } from '~/stores/renderer';
 import loggerFactory from '~/utils/logger';
 
-import { StickyStretchableScroller } from './StickyStretchableScroller';
-
 import styles from './TableOfContents.module.scss';
 
 const { isUserPage: _isUserPage } = pagePathUtils;
@@ -17,6 +16,7 @@ const { isUserPage: _isUserPage } = pagePathUtils;
 const logger = loggerFactory('growi:TableOfContents');
 
 const TableOfContents = (): JSX.Element => {
+  const StickyStretchableScroller = dynamic(() => import('./StickyStretchableScroller').then(mod => mod.StickyStretchableScroller), { ssr: false });
 
   const { data: currentPagePath } = useCurrentPagePath();
 

+ 76 - 82
packages/app/src/pages/[[...path]].page.tsx

@@ -11,7 +11,7 @@ import type {
 } from '@growi/core';
 import ExtensibleCustomError from 'extensible-custom-error';
 import {
-  NextPage, GetServerSideProps, GetServerSidePropsContext,
+  GetServerSideProps, GetServerSidePropsContext,
 } from 'next';
 import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
 import dynamic from 'next/dynamic';
@@ -19,6 +19,7 @@ import Head from 'next/head';
 import { useRouter } from 'next/router';
 import superjson from 'superjson';
 
+import { useCurrentGrowiLayoutFluidClassName } from '~/client/services/layout';
 import { Comments } from '~/components/Comments';
 import { PageAlerts } from '~/components/PageAlert/PageAlerts';
 // import { useTranslation } from '~/i18n';
@@ -42,7 +43,7 @@ import { useSWRxCurrentPage, useSWRxIsGrantNormalized } from '~/stores/page';
 import { useRedirectFrom } from '~/stores/page-redirect';
 import { useRemoteRevisionId } from '~/stores/remote-latest-page';
 import {
-  useEditorMode, useSelectedGrant,
+  useSelectedGrant,
   usePreferDrawerModeByUser, usePreferDrawerModeOnEditByUser, useSidebarCollapsed, useCurrentSidebarContents, useCurrentProductNavWidth,
 } from '~/stores/ui';
 import { useSetupGlobalSocket, useSetupGlobalSocketForPage } from '~/stores/websocket';
@@ -53,7 +54,7 @@ import loggerFactory from '~/utils/logger';
 // import GrowiSubNavigation from '../client/js/components/Navbar/GrowiSubNavigation';
 // import GrowiSubNavigationSwitcher from '../client/js/components/Navbar/GrowiSubNavigationSwitcher';
 import { DescendantsPageListModal } from '../components/DescendantsPageListModal';
-import { BasicLayout } from '../components/Layout/BasicLayout';
+import { BasicLayoutWithEditorMode } from '../components/Layout/BasicLayout';
 import GrowiContextualSubNavigation from '../components/Navbar/GrowiContextualSubNavigation';
 import DisplaySwitcher from '../components/Page/DisplaySwitcher';
 // import { serializeUserSecurely } from '../server/models/serializers/user-serializer';
@@ -71,10 +72,10 @@ import {
   useEditorConfig, useIsAllReplyShown, useIsUploadableFile, useIsUploadableImage, useCustomizedLogoSrc, useIsContainerFluid,
 } from '../stores/context';
 
+import { NextPageWithLayout } from './_app.page';
 import {
-  CommonProps, getNextI18NextConfig, getServerSideCommonProps, useCustomTitle,
+  CommonProps, getNextI18NextConfig, getServerSideCommonProps, generateCustomTitle,
 } from './utils/commons';
-// import { useCurrentPageSWR } from '../stores/page';
 
 
 declare global {
@@ -188,7 +189,7 @@ type Props = CommonProps & {
   sidebarConfig: ISidebarConfig,
 };
 
-const GrowiPage: NextPage<Props> = (props: Props) => {
+const Page: NextPageWithLayout<Props> = (props: Props) => {
   // const { t } = useTranslation();
   const router = useRouter();
 
@@ -260,24 +261,24 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
 
   useCurrentPageId(pageId ?? null);
   useRevisionIdHackmdSynced(pageWithMeta?.data.revisionHackmdSynced);
-  useRemoteRevisionId(pageWithMeta?.data.revision._id);
+  useRemoteRevisionId(pageWithMeta?.data.revision?._id);
   usePageIdOnHackmd(pageWithMeta?.data.pageIdOnHackmd);
   useHasDraftOnHackmd(pageWithMeta?.data.hasDraftOnHackmd ?? false);
   // useIsNotCreatable(props.isForbidden || !isCreatablePage(pagePath)); // TODO: need to include props.isIdentical
   useCurrentPathname(props.currentPathname);
 
-  const { data: currentPage } = useSWRxCurrentPage(undefined, pageWithMeta?.data ?? null); // store initial data
+  useSWRxCurrentPage(undefined, pageWithMeta?.data ?? null); // store initial data
 
   useEditingMarkdown(pageWithMeta?.data.revision?.body);
 
   const { data: grantData } = useSWRxIsGrantNormalized(pageId);
   const { mutate: mutateSelectedGrant } = useSelectedGrant();
 
-  const { getClassNamesByEditorMode } = useEditorMode();
-
   useSetupGlobalSocket();
   useSetupGlobalSocketForPage(pageId);
 
+  const growiLayoutFluidClass = useCurrentGrowiLayoutFluidClassName();
+
   const shouldRenderPutbackPageModal = pageWithMeta != null
     ? _isTrashPage(pageWithMeta.data.path)
     : false;
@@ -295,89 +296,82 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
     }
   }, [props.currentPathname, router]);
 
-  const classNames: string[] = [];
-
-  const isSidebar = pagePath === '/Sidebar';
-  classNames.push(...getClassNamesByEditorMode(isSidebar));
-
   const isTopPagePath = isTopPage(pageWithMeta?.data.path ?? '');
 
-  const isContainerFluidEachPage = currentPage == null || !('expandContentWidth' in currentPage)
-    ? null
-    : currentPage.expandContentWidth;
-  const isContainerFluidDefault = props.isContainerFluid;
-  const isContainerFluid = isContainerFluidEachPage ?? isContainerFluidDefault;
+  const title = generateCustomTitle(props, 'GROWI');
 
   return (
     <>
       <Head>
-        {/*
-        {renderScriptTagByName('drawio-viewer')}
-        {renderScriptTagByName('highlight-addons')}
-        {renderHighlightJsStyleTag(props.highlightJsStyle)}
-        */}
+        <title>{title}</title>
       </Head>
-
-      <DrawioViewerScript />
-
-      <BasicLayout title={useCustomTitle(props, 'GROWI')} className={classNames.join(' ')} expandContainer={isContainerFluid}>
-
-        <div className="h-100 d-flex flex-column justify-content-between">
-          <header className="py-0 position-relative">
-            <div id="grw-subnav-container">
-              <GrowiContextualSubNavigation isLinkSharingDisabled={props.disableLinkSharing} />
-            </div>
-          </header>
-          <div className="d-edit-none">
-            <GrowiSubNavigationSwitcher />
+      <div className={`dynamic-layout-root ${growiLayoutFluidClass} h-100 d-flex flex-column justify-content-between`}>
+        <header className="py-0 position-relative">
+          <div id="grw-subnav-container">
+            <GrowiContextualSubNavigation isLinkSharingDisabled={props.disableLinkSharing} />
           </div>
+        </header>
+        <div className="d-edit-none">
+          <GrowiSubNavigationSwitcher />
+        </div>
+
+        <div id="grw-subnav-sticky-trigger" className="sticky-top"></div>
+        <div id="grw-fav-sticky-trigger" className="sticky-top"></div>
+
+        <div className="flex-grow-1">
+          <div id="main" className={`main ${isUsersHomePage(props.currentPathname) && 'user-page'}`}>
+            <div id="content-main" className="content-main grw-container-convertible">
+              { props.isIdenticalPathPage && <IdenticalPathPage /> }
+
+              { !props.isIdenticalPathPage && (
+                <>
+                  <PageAlerts />
+                  { props.isForbidden && <ForbiddenPage /> }
+                  { props.isNotCreatablePage && <NotCreatablePage />}
+                  { !props.isForbidden && !props.isNotCreatablePage && <DisplaySwitcher />}
+                  {/* <DisplaySwitcher /> */}
+                  <PageStatusAlert />
+                </>
+              ) }
 
-          <div id="grw-subnav-sticky-trigger" className="sticky-top"></div>
-          <div id="grw-fav-sticky-trigger" className="sticky-top"></div>
-
-          <div className="flex-grow-1">
-            <div id="main" className={`main ${isUsersHomePage(props.currentPathname) && 'user-page'}`}>
-              <div id="content-main" className="content-main grw-container-convertible">
-                { props.isIdenticalPathPage && <IdenticalPathPage /> }
-
-                { !props.isIdenticalPathPage && (
-                  <>
-                    <PageAlerts />
-                    { props.isForbidden && <ForbiddenPage /> }
-                    { props.isNotCreatablePage && <NotCreatablePage />}
-                    { !props.isForbidden && !props.isNotCreatablePage && <DisplaySwitcher />}
-                    {/* <DisplaySwitcher /> */}
-                    <PageStatusAlert />
-                  </>
-                ) }
-
-                {/* <div className="col-xl-2 col-lg-3 d-none d-lg-block revision-toc-container">
-                  <div id="revision-toc" className="revision-toc mt-3 sps sps--abv" data-sps-offset="123">
-                    <div id="revision-toc-content" className="revision-toc-content"></div>
-                  </div>
-                </div> */}
-              </div>
+              {/* <div className="col-xl-2 col-lg-3 d-none d-lg-block revision-toc-container">
+                <div id="revision-toc" className="revision-toc mt-3 sps sps--abv" data-sps-offset="123">
+                  <div id="revision-toc-content" className="revision-toc-content"></div>
+                </div>
+              </div> */}
             </div>
           </div>
-          { !props.isIdenticalPathPage && !props.isNotFound && (
-            <footer className="footer d-edit-none">
-              { pageWithMeta != null && pagePath != null && !isTopPagePath && (
-                <Comments pageId={pageId} pagePath={pagePath} revision={pageWithMeta.data.revision} />
-              ) }
-              { pageWithMeta != null && isUsersHomePage(pageWithMeta.data.path) && (
-                <UsersHomePageFooter creatorId={pageWithMeta.data.creator._id}/>
-              ) }
-              <CurrentPageContentFooter />
-            </footer>
-          )}
-
-          <UnsavedAlertDialog />
-          <DescendantsPageListModal />
-          <DrawioModal />
-          <HandsontableModal />
-          {shouldRenderPutbackPageModal && <PutbackPageModal />}
         </div>
-      </BasicLayout>
+        { !props.isIdenticalPathPage && !props.isNotFound && (
+          <footer className="footer d-edit-none">
+            { pageWithMeta != null && pagePath != null && !isTopPagePath && (
+              <Comments pageId={pageId} pagePath={pagePath} revision={pageWithMeta.data.revision} />
+            ) }
+            { pageWithMeta != null && isUsersHomePage(pageWithMeta.data.path) && (
+              <UsersHomePageFooter creatorId={pageWithMeta.data.creator._id}/>
+            ) }
+            <CurrentPageContentFooter />
+          </footer>
+        )}
+
+        {shouldRenderPutbackPageModal && <PutbackPageModal />}
+      </div>
+    </>
+  );
+};
+
+Page.getLayout = function getLayout(page) {
+  return (
+    <>
+      <DrawioViewerScript />
+
+      <BasicLayoutWithEditorMode>
+        {page}
+      </BasicLayoutWithEditorMode>
+      <UnsavedAlertDialog />
+      <DescendantsPageListModal />
+      <DrawioModal />
+      <HandsontableModal />
     </>
   );
 };
@@ -645,4 +639,4 @@ export const getServerSideProps: GetServerSideProps = async(context: GetServerSi
   };
 };
 
-export default GrowiPage;
+export default Page;

+ 13 - 2
packages/app/src/pages/_app.page.tsx

@@ -1,6 +1,7 @@
-import React, { useEffect } from 'react';
+import React, { ReactElement, ReactNode, useEffect } from 'react';
 
 import { isServer } from '@growi/core';
+import { NextPage } from 'next';
 import { appWithTranslation } from 'next-i18next';
 import { AppProps } from 'next/app';
 import { SWRConfig } from 'swr';
@@ -32,9 +33,16 @@ const swrConfig: SWRConfigValue = {
 };
 
 
+// eslint-disable-next-line @typescript-eslint/ban-types
+export type NextPageWithLayout<P = {}, IP = P> = NextPage<P, IP> & {
+  getLayout?: (page: ReactElement) => ReactNode,
+}
+
 type GrowiAppProps = AppProps & {
   pageProps: CommonProps;
+  Component: NextPageWithLayout,
 };
+
 // register custom serializer
 registerTransformerForObjectId();
 
@@ -58,9 +66,12 @@ function GrowiApp({ Component, pageProps }: GrowiAppProps): JSX.Element {
   useGrowiVersion(commonPageProps.growiVersion);
   useCustomizedLogoSrc(commonPageProps.customizedLogoSrc);
 
+  // Use the layout defined at the page level, if available
+  const getLayout = Component.getLayout ?? (page => page);
+
   return (
     <SWRConfig value={swrConfig}>
-      <Component {...pageProps} />
+      {getLayout(<Component {...pageProps} />)}
     </SWRConfig>
   );
 }

+ 8 - 6
packages/app/src/pages/_private-legacy-pages.page.tsx

@@ -1,6 +1,7 @@
 import {
   NextPage, GetServerSideProps, GetServerSidePropsContext,
 } from 'next';
+import { useTranslation } from 'next-i18next';
 import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
 import dynamic from 'next/dynamic';
 import Head from 'next/head';
@@ -22,7 +23,7 @@ import {
 } from '~/stores/ui';
 
 import {
-  CommonProps, getNextI18NextConfig, getServerSideCommonProps, useCustomTitle,
+  CommonProps, getNextI18NextConfig, getServerSideCommonProps, generateCustomTitle,
 } from './utils/commons';
 
 const SearchResultLayout = dynamic(() => import('~/components/Layout/SearchResultLayout'), { ssr: false });
@@ -47,6 +48,8 @@ type Props = CommonProps & {
 };
 
 const PrivateLegacyPage: NextPage<Props> = (props: Props) => {
+  const { t } = useTranslation();
+
   const { userUISettings } = props;
 
   const PrivateLegacyPages = dynamic(() => import('~/components/PrivateLegacyPages'), { ssr: false });
@@ -74,18 +77,17 @@ const PrivateLegacyPage: NextPage<Props> = (props: Props) => {
   // render config
   useRendererConfig(props.rendererConfig);
 
+  const title = generateCustomTitle(props, t('private_legacy_pages.title'));
+
   return (
     <>
       <Head>
-        {/*
-        {renderScriptTagByName('drawio-viewer')}
-        {renderScriptTagByName('highlight-addons')}
-        */}
+        <title>{title}</title>
       </Head>
 
       <DrawioViewerScript />
 
-      <SearchResultLayout title={useCustomTitle(props, 'GROWI')}>
+      <SearchResultLayout>
         <div id="private-regacy-pages">
           <PrivateLegacyPages />
         </div>

+ 17 - 17
packages/app/src/pages/_search.page.tsx

@@ -24,8 +24,9 @@ import {
 import { SearchPage } from '../components/SearchPage';
 
 import {
-  CommonProps, getNextI18NextConfig, getServerSideCommonProps, useCustomTitle,
+  CommonProps, getNextI18NextConfig, getServerSideCommonProps, generateCustomTitle,
 } from './utils/commons';
+import { NextPageWithLayout } from './_app.page';
 
 const SearchResultLayout = dynamic(() => import('~/components/Layout/SearchResultLayout'), { ssr: false });
 
@@ -53,7 +54,7 @@ type Props = CommonProps & {
 
 };
 
-const SearchResultPage: NextPage<Props> = (props: Props) => {
+const SearchResultPage: NextPageWithLayout<Props> = (props: Props) => {
   const { userUISettings } = props;
 
   // commons
@@ -87,33 +88,32 @@ const SearchResultPage: NextPage<Props> = (props: Props) => {
     return <PutbackPageModal />;
   };
 
-  const classNames: string[] = [];
-  // if (props.isContainerFluid) {
-  //   classNames.push('growi-layout-fluid');
-  // }
+  const title = generateCustomTitle(props, 'GROWI');
 
   return (
     <>
       <Head>
-        {/*
-        {renderScriptTagByName('drawio-viewer')}
-        {renderScriptTagByName('highlight-addons')}
-        */}
+        <title>{title}</title>
       </Head>
 
-      <DrawioViewerScript />
-
-      <SearchResultLayout title={useCustomTitle(props, 'GROWI')} className={classNames.join(' ')}>
-        <div id="search-page">
-          <SearchPage />
-        </div>
-      </SearchResultLayout>
+      <div id="search-page" className="dynamic-layout-root">
+        <SearchPage />
+      </div>
 
       <PutbackPageModal />
     </>
   );
 };
 
+SearchResultPage.getLayout = function getLayout(page) {
+  return (
+    <>
+      <DrawioViewerScript />
+      <SearchResultLayout>{page}</SearchResultLayout>
+    </>
+  );
+};
+
 async function injectUserUISettings(context: GetServerSidePropsContext, props: Props): Promise<void> {
   const { model: mongooseModel } = await import('mongoose');
 

+ 7 - 1
packages/app/src/pages/admin/[...path].page.tsx

@@ -2,8 +2,9 @@ import {
   NextPage, GetServerSideProps, GetServerSidePropsContext,
 } from 'next';
 import dynamic from 'next/dynamic';
+import Head from 'next/head';
 
-import { CommonProps } from '~/pages/utils/commons';
+import { CommonProps, generateCustomTitle } from '~/pages/utils/commons';
 import { useCurrentUser } from '~/stores/context';
 import { useIsMaintenanceMode } from '~/stores/maintenanceMode';
 
@@ -17,8 +18,13 @@ const AdminAppPage: NextPage<CommonProps> = (props) => {
   useIsMaintenanceMode(props.isMaintenanceMode);
   useCurrentUser(props.currentUser ?? null);
 
+  const title = generateCustomTitle(props, 'GROWI');
+
   return (
     <AdminLayout>
+      <Head>
+        <title>{title}</title>
+      </Head>
       <AdminNotFoundPage />
     </AdminLayout>
   );

+ 8 - 3
packages/app/src/pages/admin/app.page.tsx

@@ -4,11 +4,12 @@ import {
 } from 'next';
 import { useTranslation } from 'next-i18next';
 import dynamic from 'next/dynamic';
+import Head from 'next/head';
 import { Container, Provider } from 'unstated';
 
 
 import AdminAppContainer from '~/client/services/AdminAppContainer';
-import { CommonProps, useCustomTitle } from '~/pages/utils/commons';
+import { CommonProps, generateCustomTitle } from '~/pages/utils/commons';
 import { useCurrentUser } from '~/stores/context';
 import { useIsMaintenanceMode } from '~/stores/maintenanceMode';
 
@@ -24,7 +25,6 @@ const AdminAppPage: NextPage<CommonProps> = (props) => {
   useIsMaintenanceMode(props.isMaintenanceMode);
   useCurrentUser(props.currentUser ?? null);
 
-  const title = t('headers.app_settings');
   const injectableContainers: Container<any>[] = [];
 
   if (isClient()) {
@@ -32,9 +32,14 @@ const AdminAppPage: NextPage<CommonProps> = (props) => {
     injectableContainers.push(adminAppContainer);
   }
 
+  const title = generateCustomTitle(props, t('headers.app_settings'));
+
   return (
     <Provider inject={[...injectableContainers]}>
-      <AdminLayout title={useCustomTitle(props, title)} componentTitle={title} >
+      <AdminLayout componentTitle={title}>
+        <Head>
+          <title>{title}</title>
+        </Head>
         <AppSettingsPageContents />
       </AdminLayout>
     </Provider>

+ 7 - 2
packages/app/src/pages/admin/audit-log.page.tsx

@@ -3,10 +3,11 @@ import {
 } from 'next';
 import { useTranslation } from 'next-i18next';
 import dynamic from 'next/dynamic';
+import Head from 'next/head';
 
 import { SupportedActionType } from '~/interfaces/activity';
 import { CrowiRequest } from '~/interfaces/crowi-request';
-import { CommonProps, useCustomTitle } from '~/pages/utils/commons';
+import { CommonProps, generateCustomTitle } from '~/pages/utils/commons';
 import { useCurrentUser, useAuditLogEnabled, useAuditLogAvailableActions } from '~/stores/context';
 
 import { retrieveServerSideProps } from '../../utils/admin-page-util';
@@ -29,9 +30,13 @@ const AdminAuditLogPage: NextPage<Props> = (props) => {
   useCurrentUser(props.currentUser ?? null);
 
   const title = t('audit_log_management.audit_log');
+  const headTitle = generateCustomTitle(props, title);
 
   return (
-    <AdminLayout title={useCustomTitle(props, title)} componentTitle={title} >
+    <AdminLayout componentTitle={title}>
+      <Head>
+        <title>{headTitle}</title>
+      </Head>
       <AuditLogManagement />
     </AdminLayout>
   );

+ 8 - 3
packages/app/src/pages/admin/customize.page.tsx

@@ -4,11 +4,12 @@ import {
 } from 'next';
 import { useTranslation } from 'next-i18next';
 import dynamic from 'next/dynamic';
+import Head from 'next/head';
 import { Container, Provider } from 'unstated';
 
 import AdminCustomizeContainer from '~/client/services/AdminCustomizeContainer';
 import { CrowiRequest } from '~/interfaces/crowi-request';
-import { CommonProps, useCustomTitle } from '~/pages/utils/commons';
+import { CommonProps, generateCustomTitle } from '~/pages/utils/commons';
 import { useCustomizeTitle, useCurrentUser } from '~/stores/context';
 
 import { retrieveServerSideProps } from '../../utils/admin-page-util';
@@ -27,7 +28,8 @@ const AdminCustomizeSettingsPage: NextPage<Props> = (props) => {
   useCustomizeTitle(props.customizeTitle);
   useCurrentUser(props.currentUser ?? null);
 
-  const title = t('customize_settings.customize_settings');
+  const componentTitle = t('customize_settings.customize_settings');
+  const pageTitle = generateCustomTitle(props, componentTitle);
   const injectableContainers: Container<any>[] = [];
 
   if (isClient()) {
@@ -39,7 +41,10 @@ const AdminCustomizeSettingsPage: NextPage<Props> = (props) => {
 
   return (
     <Provider inject={[...injectableContainers]}>
-      <AdminLayout title={useCustomTitle(props, title)} componentTitle={title} >
+      <AdminLayout componentTitle={componentTitle}>
+        <Head>
+          <title>{pageTitle}</title>
+        </Head>
         <CustomizeSettingContents />
       </AdminLayout>
     </Provider>

+ 8 - 3
packages/app/src/pages/admin/export.page.tsx

@@ -4,10 +4,11 @@ import {
 } from 'next';
 import { useTranslation } from 'next-i18next';
 import dynamic from 'next/dynamic';
+import Head from 'next/head';
 import { Container, Provider } from 'unstated';
 
 import AdminAppContainer from '~/client/services/AdminAppContainer';
-import { CommonProps, useCustomTitle } from '~/pages/utils/commons';
+import { CommonProps, generateCustomTitle } from '~/pages/utils/commons';
 import { useCurrentUser } from '~/stores/context';
 
 import { retrieveServerSideProps } from '../../utils/admin-page-util';
@@ -20,7 +21,8 @@ const AdminExportDataArchivePage: NextPage<CommonProps> = (props) => {
   const { t } = useTranslation('admin');
   useCurrentUser(props.currentUser ?? null);
 
-  const title = t('export_management.export_archive_data');
+  const componentTitle = t('export_management.export_archive_data');
+  const pageTitle = generateCustomTitle(props, componentTitle);
   const injectableContainers: Container<any>[] = [];
 
   if (isClient()) {
@@ -30,7 +32,10 @@ const AdminExportDataArchivePage: NextPage<CommonProps> = (props) => {
 
   return (
     <Provider inject={[...injectableContainers]}>
-      <AdminLayout title={useCustomTitle(props, title)} componentTitle={title} >
+      <AdminLayout componentTitle={componentTitle}>
+        <Head>
+          <title>{pageTitle}</title>
+        </Head>
         <ExportArchiveDataPage />
       </AdminLayout>
     </Provider>

+ 7 - 3
packages/app/src/pages/admin/global-notification/[globalNotificationId].page.tsx

@@ -6,13 +6,14 @@ import {
 } from 'next';
 import { useTranslation } from 'next-i18next';
 import dynamic from 'next/dynamic';
+import Head from 'next/head';
 import { useRouter } from 'next/router';
 import { Container, Provider } from 'unstated';
 
 
 import AdminNotificationContainer from '~/client/services/AdminNotificationContainer';
 import { toastError } from '~/client/util/apiNotification';
-import { CommonProps, useCustomTitle } from '~/pages/utils/commons';
+import { CommonProps, generateCustomTitle } from '~/pages/utils/commons';
 import { useCurrentUser } from '~/stores/context';
 
 import { retrieveServerSideProps } from '../../../utils/admin-page-util';
@@ -43,7 +44,7 @@ const AdminGlobalNotificationNewPage: NextPage<CommonProps> = (props) => {
 
 
   const title = t('external_notification.external_notification');
-  const customTitle = useCustomTitle(props, title);
+  const customTitle = generateCustomTitle(props, title);
 
 
   const injectableContainers: Container<any>[] = [];
@@ -56,7 +57,10 @@ const AdminGlobalNotificationNewPage: NextPage<CommonProps> = (props) => {
 
   return (
     <Provider inject={[...injectableContainers]}>
-      <AdminLayout title={customTitle} componentTitle={title} >
+      <AdminLayout componentTitle={title}>
+        <Head>
+          <title>{customTitle}</title>
+        </Head>
         {
           currentGlobalNotificationId != null && router.isReady
       && <ManageGlobalNotification globalNotificationId={currentGlobalNotificationId} />

+ 6 - 2
packages/app/src/pages/admin/global-notification/new.page.tsx

@@ -4,10 +4,11 @@ import {
 } from 'next';
 import { useTranslation } from 'next-i18next';
 import dynamic from 'next/dynamic';
+import Head from 'next/head';
 import { Container, Provider } from 'unstated';
 
 import AdminNotificationContainer from '~/client/services/AdminNotificationContainer';
-import { CommonProps, useCustomTitle } from '~/pages/utils/commons';
+import { CommonProps, generateCustomTitle } from '~/pages/utils/commons';
 import { useCurrentUser } from '~/stores/context';
 
 import { retrieveServerSideProps } from '../../../utils/admin-page-util';
@@ -31,7 +32,10 @@ const AdminGlobalNotificationNewPage: NextPage<CommonProps> = (props) => {
 
   return (
     <Provider inject={[...injectableContainers]}>
-      <AdminLayout title={useCustomTitle(props, title)} componentTitle={title} >
+      <AdminLayout componentTitle={title}>
+        <Head>
+          <title>{title}</title>
+        </Head>
         <ManageGlobalNotification />
       </AdminLayout>
     </Provider>

+ 8 - 3
packages/app/src/pages/admin/importer.page.tsx

@@ -4,10 +4,11 @@ import {
 } from 'next';
 import { useTranslation } from 'next-i18next';
 import dynamic from 'next/dynamic';
+import Head from 'next/head';
 import { Container, Provider } from 'unstated';
 
 import AdminImportContainer from '~/client/services/AdminImportContainer';
-import { CommonProps, useCustomTitle } from '~/pages/utils/commons';
+import { CommonProps, generateCustomTitle } from '~/pages/utils/commons';
 import { useCurrentUser } from '~/stores/context';
 
 import { retrieveServerSideProps } from '../../utils/admin-page-util';
@@ -20,7 +21,8 @@ const AdminDataImportPage: NextPage<CommonProps> = (props) => {
   const { t } = useTranslation('admin');
   useCurrentUser(props.currentUser ?? null);
 
-  const title = t('importer_management.import_data');
+  const componentTitle = t('importer_management.import_data');
+  const pageTitle = generateCustomTitle(props, componentTitle);
   const injectableContainers: Container<any>[] = [];
 
   if (isClient()) {
@@ -31,7 +33,10 @@ const AdminDataImportPage: NextPage<CommonProps> = (props) => {
 
   return (
     <Provider inject={[...injectableContainers]}>
-      <AdminLayout title={useCustomTitle(props, title)} componentTitle={title} >
+      <AdminLayout componentTitle={componentTitle}>
+        <Head>
+          <title>{pageTitle}</title>
+        </Head>
         <DataImportPageContents />
       </AdminLayout>
     </Provider>

+ 6 - 2
packages/app/src/pages/admin/index.page.tsx

@@ -4,11 +4,12 @@ import {
 } from 'next';
 import { useTranslation } from 'next-i18next';
 import dynamic from 'next/dynamic';
+import Head from 'next/head';
 import { Container, Provider } from 'unstated';
 
 import AdminHomeContainer from '~/client/services/AdminHomeContainer';
 import { CrowiRequest } from '~/interfaces/crowi-request';
-import { CommonProps, useCustomTitle } from '~/pages/utils/commons';
+import { CommonProps, generateCustomTitle } from '~/pages/utils/commons';
 import { useCurrentUser, useGrowiCloudUri, useGrowiAppIdForGrowiCloud } from '~/stores/context';
 
 import { retrieveServerSideProps } from '../../utils/admin-page-util';
@@ -46,7 +47,10 @@ const AdminHomePage: NextPage<Props> = (props) => {
 
   return (
     <Provider inject={[...injectableContainers]}>
-      <AdminLayout title={useCustomTitle(props, title)} componentTitle={title} >
+      <AdminLayout componentTitle={title}>
+        <Head>
+          <title>{title}</title>
+        </Head>
         <AdminHome
           nodeVersion={props.nodeVersion}
           npmVersion={props.npmVersion}

+ 9 - 3
packages/app/src/pages/admin/markdown.page.tsx

@@ -4,11 +4,12 @@ import {
 } from 'next';
 import { useTranslation } from 'next-i18next';
 import dynamic from 'next/dynamic';
+import Head from 'next/head';
 import { Container, Provider } from 'unstated';
 
 
 import AdminMarkDownContainer from '~/client/services/AdminMarkDownContainer';
-import { CommonProps, useCustomTitle } from '~/pages/utils/commons';
+import { CommonProps, generateCustomTitle } from '~/pages/utils/commons';
 import { useCurrentUser } from '~/stores/context';
 
 import { retrieveServerSideProps } from '../../utils/admin-page-util';
@@ -21,7 +22,9 @@ const AdminMarkdownPage: NextPage<CommonProps> = (props) => {
   const { t } = useTranslation('admin');
   useCurrentUser(props.currentUser ?? null);
 
-  const title = t('markdown_settings.markdown_settings');
+  const componentTitle = t('markdown_settings.markdown_settings');
+  const pageTitle = generateCustomTitle(props, componentTitle);
+
   const injectableContainers: Container<any>[] = [];
 
   if (isClient()) {
@@ -32,7 +35,10 @@ const AdminMarkdownPage: NextPage<CommonProps> = (props) => {
 
   return (
     <Provider inject={[...injectableContainers]}>
-      <AdminLayout title={useCustomTitle(props, title)} componentTitle={title} >
+      <AdminLayout componentTitle={componentTitle}>
+        <Head>
+          <title>{pageTitle}</title>
+        </Head>
         <MarkDownSettingContents />
       </AdminLayout>
     </Provider>

+ 8 - 3
packages/app/src/pages/admin/notification.page.tsx

@@ -4,10 +4,11 @@ import {
 } from 'next';
 import { useTranslation } from 'next-i18next';
 import dynamic from 'next/dynamic';
+import Head from 'next/head';
 import { Container, Provider } from 'unstated';
 
 import AdminNotificationContainer from '~/client/services/AdminNotificationContainer';
-import { CommonProps, useCustomTitle } from '~/pages/utils/commons';
+import { CommonProps, generateCustomTitle } from '~/pages/utils/commons';
 import { useCurrentUser } from '~/stores/context';
 
 import { retrieveServerSideProps } from '../../utils/admin-page-util';
@@ -20,7 +21,8 @@ const AdminExternalNotificationPage: NextPage<CommonProps> = (props) => {
   const { t } = useTranslation('admin');
   useCurrentUser(props.currentUser ?? null);
 
-  const title = t('external_notification.external_notification');
+  const componentTitle = t('external_notification.external_notification');
+  const pageTitle = generateCustomTitle(props, componentTitle);
   const injectableContainers: Container<any>[] = [];
 
   if (isClient()) {
@@ -32,7 +34,10 @@ const AdminExternalNotificationPage: NextPage<CommonProps> = (props) => {
 
   return (
     <Provider inject={[...injectableContainers]}>
-      <AdminLayout title={useCustomTitle(props, title)} componentTitle={title} >
+      <AdminLayout componentTitle={componentTitle}>
+        <Head>
+          <title>{pageTitle}</title>
+        </Head>
         <NotificationSetting />
       </AdminLayout>
     </Provider>

+ 6 - 2
packages/app/src/pages/admin/plugins.page.tsx

@@ -4,11 +4,12 @@ import {
 } from 'next';
 import { useTranslation } from 'next-i18next';
 import dynamic from 'next/dynamic';
+import Head from 'next/head';
 import { Container, Provider } from 'unstated';
 
 
 import AdminAppContainer from '~/client/services/AdminAppContainer';
-import { CommonProps, useCustomTitle } from '~/pages/utils/commons';
+import { CommonProps, generateCustomTitle } from '~/pages/utils/commons';
 import { useCurrentUser } from '~/stores/context';
 import { useIsMaintenanceMode } from '~/stores/maintenanceMode';
 
@@ -37,7 +38,10 @@ const AdminAppPage: NextPage<CommonProps> = (props) => {
 
   return (
     <Provider inject={[...injectableContainers]}>
-      <AdminLayout title={useCustomTitle(props, title)} componentTitle={title} >
+      <AdminLayout componentTitle={title} >
+        <Head>
+          <title>{generateCustomTitle(props, title)}</title>
+        </Head>
         <PluginsExtensionPageContents />
       </AdminLayout>
     </Provider>

+ 7 - 2
packages/app/src/pages/admin/search.page.tsx

@@ -3,9 +3,10 @@ import {
 } from 'next';
 import { useTranslation } from 'next-i18next';
 import dynamic from 'next/dynamic';
+import Head from 'next/head';
 
 import { CrowiRequest } from '~/interfaces/crowi-request';
-import { CommonProps, useCustomTitle } from '~/pages/utils/commons';
+import { CommonProps, generateCustomTitle } from '~/pages/utils/commons';
 import { useIsSearchServiceReachable, useCurrentUser } from '~/stores/context';
 
 import { retrieveServerSideProps } from '../../utils/admin-page-util';
@@ -27,9 +28,13 @@ const AdminFullTextSearchManagementPage: NextPage<Props> = (props) => {
   useIsSearchServiceReachable(props.isSearchServiceReachable);
 
   const title = t('full_text_search_management.full_text_search_management');
+  const headTitle = generateCustomTitle(props, title);
 
   return (
-    <AdminLayout title={useCustomTitle(props, title)} componentTitle={title} >
+    <AdminLayout componentTitle={title}>
+      <Head>
+        <title>{headTitle}</title>
+      </Head>
       <FullTextSearchManagement />
     </AdminLayout>
   );

+ 8 - 4
packages/app/src/pages/admin/security.page.tsx

@@ -4,6 +4,7 @@ import {
 } from 'next';
 import { useTranslation } from 'next-i18next';
 import dynamic from 'next/dynamic';
+import Head from 'next/head';
 import { Container, Provider } from 'unstated';
 
 import AdminGeneralSecurityContainer from '~/client/services/AdminGeneralSecurityContainer';
@@ -15,7 +16,7 @@ import AdminOidcSecurityContainer from '~/client/services/AdminOidcSecurityConta
 import AdminSamlSecurityContainer from '~/client/services/AdminSamlSecurityContainer';
 import AdminTwitterSecurityContainer from '~/client/services/AdminTwitterSecurityContainer';
 import { CrowiRequest } from '~/interfaces/crowi-request';
-import { CommonProps, useCustomTitle } from '~/pages/utils/commons';
+import { CommonProps, generateCustomTitle } from '~/pages/utils/commons';
 import { useCurrentUser, useIsMailerSetup, useSiteUrl } from '~/stores/context';
 
 import { retrieveServerSideProps } from '../../utils/admin-page-util';
@@ -36,7 +37,8 @@ const AdminSecuritySettingsPage: NextPage<Props> = (props) => {
   useSiteUrl(props.siteUrl);
   useIsMailerSetup(props.isMailerSetup);
 
-  const title = t('security_settings.security_settings');
+  const componentTitle = t('security_settings.security_settings');
+  const pageTitle = generateCustomTitle(props, componentTitle);
   const adminSecurityContainers: Container<any>[] = [];
 
   if (isClient()) {
@@ -65,10 +67,12 @@ const AdminSecuritySettingsPage: NextPage<Props> = (props) => {
     }
   }
 
-
   return (
     <Provider inject={[...adminSecurityContainers]}>
-      <AdminLayout title={useCustomTitle(props, title)} componentTitle={title} >
+      <AdminLayout componentTitle={componentTitle}>
+        <Head>
+          <title>{pageTitle}</title>
+        </Head>
         <SecurityManagement />
       </AdminLayout>
     </Provider>

+ 7 - 2
packages/app/src/pages/admin/slack-integration-legacy.page.tsx

@@ -4,10 +4,11 @@ import {
 } from 'next';
 import { useTranslation } from 'next-i18next';
 import dynamic from 'next/dynamic';
+import Head from 'next/head';
 import { Container, Provider } from 'unstated';
 
 import AdminSlackIntegrationLegacyContainer from '~/client/services/AdminSlackIntegrationLegacyContainer';
-import { CommonProps, useCustomTitle } from '~/pages/utils/commons';
+import { CommonProps, generateCustomTitle } from '~/pages/utils/commons';
 import { useCurrentUser } from '~/stores/context';
 
 import { retrieveServerSideProps } from '../../utils/admin-page-util';
@@ -21,6 +22,7 @@ const AdminLegacySlackIntegrationPage: NextPage<CommonProps> = (props) => {
   useCurrentUser(props.currentUser ?? null);
 
   const title = t('slack_integration_legacy.slack_integration_legacy');
+  const headTitle = generateCustomTitle(props, title);
   const injectableContainers: Container<any>[] = [];
 
   if (isClient()) {
@@ -31,7 +33,10 @@ const AdminLegacySlackIntegrationPage: NextPage<CommonProps> = (props) => {
 
   return (
     <Provider inject={[...injectableContainers]}>
-      <AdminLayout title={useCustomTitle(props, title)} componentTitle={title} >
+      <AdminLayout componentTitle={title}>
+        <Head>
+          <title>{headTitle}</title>
+        </Head>
         <LegacySlackIntegration />
       </AdminLayout>
     </Provider>

+ 8 - 3
packages/app/src/pages/admin/slack-integration.page.tsx

@@ -3,9 +3,10 @@ import {
 } from 'next';
 import { useTranslation } from 'next-i18next';
 import dynamic from 'next/dynamic';
+import Head from 'next/head';
 
 import { CrowiRequest } from '~/interfaces/crowi-request';
-import { CommonProps, useCustomTitle } from '~/pages/utils/commons';
+import { CommonProps, generateCustomTitle } from '~/pages/utils/commons';
 import { useCurrentUser, useSiteUrl } from '~/stores/context';
 
 import { retrieveServerSideProps } from '../../utils/admin-page-util';
@@ -25,10 +26,14 @@ const AdminSlackIntegrationPage: NextPage<Props> = (props) => {
   useCurrentUser(props.currentUser ?? null);
   useSiteUrl(props.siteUrl);
 
-  const title = t('slack_integration.slack_integration');
+  const componentTitle = t('slack_integration.slack_integration');
+  const pageTitle = generateCustomTitle(props, componentTitle);
 
   return (
-    <AdminLayout title={useCustomTitle(props, title)} componentTitle={title} >
+    <AdminLayout componentTitle={componentTitle}>
+      <Head>
+        <title>{pageTitle}</title>
+      </Head>
       <SlackIntegration />
     </AdminLayout>
   );

+ 7 - 3
packages/app/src/pages/admin/user-group-detail/[userGroupId].page.tsx

@@ -3,10 +3,11 @@ import {
 } from 'next';
 import { useTranslation } from 'next-i18next';
 import dynamic from 'next/dynamic';
+import Head from 'next/head';
 import { useRouter } from 'next/router';
 
 import { CrowiRequest } from '~/interfaces/crowi-request';
-import { CommonProps, useCustomTitle } from '~/pages/utils/commons';
+import { CommonProps, generateCustomTitle } from '~/pages/utils/commons';
 import { useIsAclEnabled, useCurrentUser } from '~/stores/context';
 import { useIsMaintenanceMode } from '~/stores/maintenanceMode';
 
@@ -27,14 +28,17 @@ const AdminUserGroupDetailPage: NextPage<Props> = (props: Props) => {
   const { userGroupId } = router.query;
 
   const title = t('user_group_management.user_group_management');
-  const customTitle = useCustomTitle(props, title);
+  const customTitle = generateCustomTitle(props, title);
 
   const currentUserGroupId = Array.isArray(userGroupId) ? userGroupId[0] : userGroupId;
 
   useIsAclEnabled(props.isAclEnabled);
 
   return (
-    <AdminLayout title={customTitle} componentTitle={title} >
+    <AdminLayout componentTitle={title}>
+      <Head>
+        <title>{customTitle}</title>
+      </Head>
       {
         currentUserGroupId != null && router.isReady
       && <UserGroupDetailPage userGroupId={currentUserGroupId} />

+ 7 - 2
packages/app/src/pages/admin/user-groups.page.tsx

@@ -3,9 +3,10 @@ import {
 } from 'next';
 import { useTranslation } from 'next-i18next';
 import dynamic from 'next/dynamic';
+import Head from 'next/head';
 
 import { CrowiRequest } from '~/interfaces/crowi-request';
-import { CommonProps, useCustomTitle } from '~/pages/utils/commons';
+import { CommonProps, generateCustomTitle } from '~/pages/utils/commons';
 import { useIsAclEnabled, useCurrentUser } from '~/stores/context';
 
 import { retrieveServerSideProps } from '../../utils/admin-page-util';
@@ -25,9 +26,13 @@ const AdminUserGroupPage: NextPage<Props> = (props) => {
   useIsAclEnabled(props.isAclEnabled);
 
   const title = t('user_group_management.user_group_management');
+  const headTitle = generateCustomTitle(props, title);
 
   return (
-    <AdminLayout title={useCustomTitle(props, title)} componentTitle={title} >
+    <AdminLayout componentTitle={title}>
+      <Head>
+        <title>{headTitle}</title>
+      </Head>
       <UserGroupPage />
     </AdminLayout>
   );

+ 6 - 2
packages/app/src/pages/admin/users/external-accounts.page.tsx

@@ -4,10 +4,11 @@ import {
 } from 'next';
 import { useTranslation } from 'next-i18next';
 import dynamic from 'next/dynamic';
+import Head from 'next/head';
 import { Container, Provider } from 'unstated';
 
 import AdminExternalAccountsContainer from '~/client/services/AdminExternalAccountsContainer';
-import { CommonProps, useCustomTitle } from '~/pages/utils/commons';
+import { CommonProps, generateCustomTitle } from '~/pages/utils/commons';
 import { useCurrentUser } from '~/stores/context';
 
 import { retrieveServerSideProps } from '../../../utils/admin-page-util';
@@ -34,7 +35,10 @@ const AdminUserManagementPage: NextPage<CommonProps> = (props) => {
 
   return (
     <Provider inject={[...injectableContainers]}>
-      <AdminLayout title={useCustomTitle(props, title)} componentTitle={title} >
+      <AdminLayout componentTitle={title}>
+        <Head>
+          <title>{title}</title>
+        </Head>
         <ManageExternalAccount />
       </AdminLayout>
     </Provider>

+ 7 - 2
packages/app/src/pages/admin/users/index.page.tsx

@@ -4,11 +4,12 @@ import {
 } from 'next';
 import { useTranslation } from 'next-i18next';
 import dynamic from 'next/dynamic';
+import Head from 'next/head';
 import { Container, Provider } from 'unstated';
 
 import AdminUsersContainer from '~/client/services/AdminUsersContainer';
 import { CrowiRequest } from '~/interfaces/crowi-request';
-import { CommonProps, useCustomTitle } from '~/pages/utils/commons';
+import { CommonProps, generateCustomTitle } from '~/pages/utils/commons';
 import { useCurrentUser, useIsMailerSetup } from '~/stores/context';
 
 import { retrieveServerSideProps } from '../../../utils/admin-page-util';
@@ -29,6 +30,7 @@ const AdminUserManagementPage: NextPage<Props> = (props) => {
   useIsMailerSetup(props.isMailerSetup);
 
   const title = t('user_management.user_management');
+  const headTitle = generateCustomTitle(props, title);
   const injectableContainers: Container<any>[] = [];
 
   if (isClient()) {
@@ -40,7 +42,10 @@ const AdminUserManagementPage: NextPage<Props> = (props) => {
 
   return (
     <Provider inject={[...injectableContainers]}>
-      <AdminLayout title={useCustomTitle(props, title)} componentTitle={title} >
+      <AdminLayout componentTitle={title}>
+        <Head>
+          <title>{headTitle}</title>
+        </Head>
         <UserManagement />
       </AdminLayout>
     </Provider>

+ 7 - 2
packages/app/src/pages/installer.page.tsx

@@ -5,6 +5,7 @@ import {
   NextPage, GetServerSideProps, GetServerSidePropsContext,
 } from 'next';
 import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
+import Head from 'next/head';
 
 import { NoLoginLayout } from '~/components/Layout/NoLoginLayout';
 
@@ -15,7 +16,7 @@ import {
 
 
 import {
-  CommonProps, getNextI18NextConfig, getServerSideCommonProps, useCustomTitle,
+  CommonProps, getNextI18NextConfig, getServerSideCommonProps, generateCustomTitle,
 } from './utils/commons';
 
 
@@ -39,10 +40,14 @@ const InstallerPage: NextPage<Props> = (props: Props) => {
   useConfidential(props.confidential);
   useCsrfToken(props.csrfToken);
 
+  const title = generateCustomTitle(props, 'GROWI');
   const classNames: string[] = [];
 
   return (
-    <NoLoginLayout title={useCustomTitle(props, 'GROWI')} className={classNames.join(' ')}>
+    <NoLoginLayout className={classNames.join(' ')}>
+      <Head>
+        <title>{title}</title>
+      </Head>
       <div id="installer-form-container">
         <InstallerForm />
       </div>

+ 7 - 2
packages/app/src/pages/invited.page.tsx

@@ -5,6 +5,7 @@ import { USER_STATUS } from '@growi/core';
 import { NextPage, GetServerSideProps, GetServerSidePropsContext } from 'next';
 import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
 import dynamic from 'next/dynamic';
+import Head from 'next/head';
 
 import { InvitedFormProps } from '~/components/InvitedForm';
 import { NoLoginLayout } from '~/components/Layout/NoLoginLayout';
@@ -13,7 +14,7 @@ import type { CrowiRequest } from '~/interfaces/crowi-request';
 import { useCsrfToken, useCurrentPathname, useCurrentUser } from '../stores/context';
 
 import {
-  CommonProps, getServerSideCommonProps, useCustomTitle, getNextI18NextConfig,
+  CommonProps, getServerSideCommonProps, generateCustomTitle, getNextI18NextConfig,
 } from './utils/commons';
 
 const InvitedForm = dynamic<InvitedFormProps>(() => import('~/components/InvitedForm').then(mod => mod.InvitedForm), { ssr: false });
@@ -30,10 +31,14 @@ const InvitedPage: NextPage<Props> = (props: Props) => {
   useCurrentPathname(props.currentPathname);
   useCurrentUser(props.currentUser);
 
+  const title = generateCustomTitle(props, 'GROWI');
   const classNames: string[] = ['invited-page'];
 
   return (
-    <NoLoginLayout title={useCustomTitle(props, 'GROWI')} className={classNames.join(' ')}>
+    <NoLoginLayout className={classNames.join(' ')}>
+      <Head>
+        <title>{title}</title>
+      </Head>
       <InvitedForm invitedFormUsername={props.invitedFormUsername} invitedFormName={props.invitedFormName} />
     </NoLoginLayout>
   );

+ 7 - 2
packages/app/src/pages/login.page.tsx

@@ -4,6 +4,7 @@ import {
   NextPage, GetServerSideProps, GetServerSidePropsContext,
 } from 'next';
 import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
+import Head from 'next/head';
 
 import { NoLoginLayout } from '~/components/Layout/NoLoginLayout';
 import { LoginForm } from '~/components/LoginForm';
@@ -17,7 +18,7 @@ import {
 } from '../stores/context';
 
 import {
-  CommonProps, getServerSideCommonProps, useCustomTitle, getNextI18NextConfig,
+  CommonProps, getServerSideCommonProps, generateCustomTitle, getNextI18NextConfig,
 } from './utils/commons';
 
 type Props = CommonProps & {
@@ -42,10 +43,14 @@ const LoginPage: NextPage<Props> = (props: Props) => {
   // page
   useCurrentPathname(props.currentPathname);
 
+  const title = generateCustomTitle(props, 'GROWI');
   const classNames: string[] = ['login-page'];
 
   return (
-    <NoLoginLayout title={useCustomTitle(props, 'GROWI')} className={classNames.join(' ')}>
+    <NoLoginLayout className={classNames.join(' ')}>
+      <Head>
+        <title>{title}</title>
+      </Head>
       <LoginForm
         objOfIsExternalAuthEnableds={props.enabledStrategies}
         isLocalStrategySetup={props.isLocalStrategySetup}

+ 17 - 6
packages/app/src/pages/me/[[...path]].page.tsx

@@ -8,6 +8,7 @@ import {
 import { useTranslation } from 'next-i18next';
 import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
 import dynamic from 'next/dynamic';
+import Head from 'next/head';
 import { useRouter } from 'next/router';
 
 import { BasicLayout } from '~/components/Layout/BasicLayout';
@@ -28,8 +29,9 @@ import {
 import loggerFactory from '~/utils/logger';
 
 import {
-  CommonProps, getNextI18NextConfig, getServerSideCommonProps, useCustomTitle,
+  CommonProps, getNextI18NextConfig, getServerSideCommonProps, generateCustomTitle,
 } from '../utils/commons';
+import { NextPageWithLayout } from '../_app.page';
 
 
 const logger = loggerFactory('growi:pages:me');
@@ -53,7 +55,7 @@ const InAppNotificationPage = dynamic(
   () => import('~/components/InAppNotification/InAppNotificationPage').then(mod => mod.InAppNotificationPage), { ssr: false },
 );
 
-const MePage: NextPage<Props> = (props: Props) => {
+const MePage: NextPageWithLayout<Props> = (props: Props) => {
   const router = useRouter();
   const { t } = useTranslation(['translation', 'commons']);
   const { path } = router.query;
@@ -109,10 +111,14 @@ const MePage: NextPage<Props> = (props: Props) => {
 
   useRendererConfig(props.rendererConfig);
 
+  const title = generateCustomTitle(props, 'GROWI');
+
   return (
     <>
-      <BasicLayout title={useCustomTitle(props, 'GROWI')}>
-
+      <Head>
+        <title>{title}</title>
+      </Head>
+      <div className="dynamic-layout-root">
         <header className="py-3">
           <div className="container-fluid">
             <h1 className="title">{ targetPage.title }</h1>
@@ -126,12 +132,17 @@ const MePage: NextPage<Props> = (props: Props) => {
             {targetPage.component}
           </div>
         </div>
-
-      </BasicLayout>
+      </div>
     </>
   );
 };
 
+MePage.getLayout = function getLayout(page) {
+  return (
+    <BasicLayout>{page}</BasicLayout>
+  );
+};
+
 async function injectUserUISettings(context: GetServerSidePropsContext, props: Props): Promise<void> {
   const req = context.req as CrowiRequest<IUserHasId & any>;
   const { user } = req;

+ 94 - 79
packages/app/src/pages/share/[[...path]].page.tsx

@@ -7,7 +7,9 @@ import {
 import { useTranslation } from 'next-i18next';
 import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
 import dynamic from 'next/dynamic';
+import Head from 'next/head';
 
+import { useCurrentGrowiLayoutFluidClassName } from '~/client/services/layout';
 import CountBadge from '~/components/Common/CountBadge';
 import PageListIcon from '~/components/Icons/PageListIcon';
 import { ShareLinkLayout } from '~/components/Layout/ShareLinkLayout';
@@ -22,13 +24,14 @@ import { RendererConfig } from '~/interfaces/services/renderer';
 import { IShareLinkHasId } from '~/interfaces/share-link';
 import {
   useCurrentUser, useCurrentPathname, useCurrentPageId, useRendererConfig, useIsSearchPage,
-  useShareLinkId, useIsSearchServiceConfigured, useIsSearchServiceReachable, useIsSearchScopeChildrenAsDefault, useDrawioUri,
+  useShareLinkId, useIsSearchServiceConfigured, useIsSearchServiceReachable, useIsSearchScopeChildrenAsDefault, useDrawioUri, useIsContainerFluid,
 } from '~/stores/context';
 import { useDescendantsPageListModal } from '~/stores/modal';
 import loggerFactory from '~/utils/logger';
 
+import { NextPageWithLayout } from '../_app.page';
 import {
-  CommonProps, getServerSideCommonProps, useCustomTitle, getNextI18NextConfig,
+  CommonProps, getServerSideCommonProps, generateCustomTitle, getNextI18NextConfig,
 } from '../utils/commons';
 
 const logger = loggerFactory('growi:next-page:share');
@@ -47,7 +50,7 @@ type Props = CommonProps & {
   rendererConfig: RendererConfig,
 };
 
-const SharedPage: NextPage<Props> = (props: Props) => {
+const SharedPage: NextPageWithLayout<Props> = (props: Props) => {
   useIsSearchPage(false);
   useShareLinkId(props.shareLink?._id);
   useCurrentPageId(props.shareLink?.relatedPage._id);
@@ -58,98 +61,110 @@ const SharedPage: NextPage<Props> = (props: Props) => {
   useIsSearchServiceReachable(props.isSearchServiceReachable);
   useIsSearchScopeChildrenAsDefault(props.isSearchScopeChildrenAsDefault);
   useDrawioUri(props.drawioUri);
+  useIsContainerFluid(props.isContainerFluid);
 
   const { open: openDescendantPageListModal } = useDescendantsPageListModal();
   const { t } = useTranslation();
 
+  const growiLayoutFluidClass = useCurrentGrowiLayoutFluidClassName();
+
   const isNotFound = props.shareLink == null || props.shareLink.relatedPage == null || props.shareLink.relatedPage.isEmpty;
   const isShowSharedPage = !props.disableLinkSharing && !isNotFound && !props.isExpired;
   const shareLink = props.shareLink;
 
+  const title = generateCustomTitle(props, 'GROWI');
+
   return (
     <>
-      <DrawioViewerScript />
-
-      <ShareLinkLayout title={useCustomTitle(props, 'GROWI')} expandContainer={props.isContainerFluid}>
-        <div className="h-100 d-flex flex-column justify-content-between">
-          <header className="py-0 position-relative">
-            {isShowSharedPage && <GrowiContextualSubNavigation isLinkSharingDisabled={props.disableLinkSharing} />}
-          </header>
-
-          <div id="grw-fav-sticky-trigger" className="sticky-top"></div>
-
-          <div className="flex-grow-1">
-            <div id="content-main" className="content-main">
-              <div className="grw-container-convertible">
-                { props.disableLinkSharing && (
-                  <div className="mt-4">
-                    <ForbiddenPage isLinkSharingDisabled={props.disableLinkSharing} />
-                  </div>
-                )}
-
-                { (isNotFound && !props.disableLinkSharing) && (
-                  <div className="container-lg">
-                    <h2 className="text-muted mt-4">
-                      <i className="icon-ban" aria-hidden="true" />
-                      <span> Page is not found</span>
-                    </h2>
-                  </div>
-                )}
-
-                { (props.isExpired && !props.disableLinkSharing && shareLink != null) && (
-                  <div className="container-lg">
-                    <ShareLinkAlert expiredAt={shareLink.expiredAt} createdAt={shareLink.createdAt} />
-                    <h2 className="text-muted mt-4">
-                      <i className="icon-ban" aria-hidden="true" />
-                      <span> Page is expired</span>
-                    </h2>
-                  </div>
-                )}
-
-                {(isShowSharedPage && shareLink != null) && (
-                  <>
-                    <ShareLinkAlert expiredAt={shareLink.expiredAt} createdAt={shareLink.createdAt} />
-                    <div className="d-flex flex-column flex-lg-row-reverse">
-
-                      <div className="grw-side-contents-container">
-                        <div className="grw-side-contents-sticky-container">
-
-                          {/* Page list */}
-                          <div className={`grw-page-accessories-control ${styles['grw-page-accessories-control']}`}>
-                            { shareLink.relatedPage.path != null && (
-                              <button
-                                type="button"
-                                className="btn btn-block btn-outline-secondary grw-btn-page-accessories
-                                rounded-pill d-flex justify-content-between align-items-center"
-                                onClick={() => openDescendantPageListModal(shareLink.relatedPage.path)}
-                                data-testid="pageListButton"
-                              >
-                                <div className="grw-page-accessories-control-icon">
-                                  <PageListIcon />
-                                </div>
-                                {t('page_list')}
-                                <CountBadge count={shareLink.relatedPage.descendantCount} offset={1} />
-                              </button>
-                            ) }
-                          </div>
-
-                          <div className="d-none d-lg-block">
-                            <TableOfContents />
-                          </div>
-                        </div>
+      <Head>
+        <title>{title}</title>
+      </Head>
+
+      <div className={`dynamic-layout-root ${growiLayoutFluidClass} h-100 d-flex flex-column justify-content-between`}>
+        <header className="py-0 position-relative">
+          {isShowSharedPage && <GrowiContextualSubNavigation isLinkSharingDisabled={props.disableLinkSharing} />}
+        </header>
+
+        <div id="grw-fav-sticky-trigger" className="sticky-top"></div>
+
+        <div className="flex-grow-1">
+          <div id="content-main" className="content-main grw-container-convertible">
+            { props.disableLinkSharing && (
+              <div className="mt-4">
+                <ForbiddenPage isLinkSharingDisabled={props.disableLinkSharing} />
+              </div>
+            )}
+
+            { (isNotFound && !props.disableLinkSharing) && (
+              <div className="container-lg">
+                <h2 className="text-muted mt-4">
+                  <i className="icon-ban" aria-hidden="true" />
+                  <span> Page is not found</span>
+                </h2>
+              </div>
+            )}
+
+            { (props.isExpired && !props.disableLinkSharing && shareLink != null) && (
+              <div className="container-lg">
+                <ShareLinkAlert expiredAt={shareLink.expiredAt} createdAt={shareLink.createdAt} />
+                <h2 className="text-muted mt-4">
+                  <i className="icon-ban" aria-hidden="true" />
+                  <span> Page is expired</span>
+                </h2>
+              </div>
+            )}
+
+            {(isShowSharedPage && shareLink != null) && (
+              <>
+                <ShareLinkAlert expiredAt={shareLink.expiredAt} createdAt={shareLink.createdAt} />
+                <div className="d-flex flex-column flex-lg-row-reverse">
+
+                  <div className="grw-side-contents-container">
+                    <div className="grw-side-contents-sticky-container">
+
+                      {/* Page list */}
+                      <div className={`grw-page-accessories-control ${styles['grw-page-accessories-control']}`}>
+                        { shareLink.relatedPage.path != null && (
+                          <button
+                            type="button"
+                            className="btn btn-block btn-outline-secondary grw-btn-page-accessories
+                            rounded-pill d-flex justify-content-between align-items-center"
+                            onClick={() => openDescendantPageListModal(shareLink.relatedPage.path)}
+                            data-testid="pageListButton"
+                          >
+                            <div className="grw-page-accessories-control-icon">
+                              <PageListIcon />
+                            </div>
+                            {t('page_list')}
+                            <CountBadge count={shareLink.relatedPage.descendantCount} offset={1} />
+                          </button>
+                        ) }
                       </div>
 
-                      <div className="flex-grow-1 flex-basis-0 mw-0">
-                        <Page />
+                      <div className="d-none d-lg-block">
+                        <TableOfContents />
                       </div>
                     </div>
-                  </>
-                )}
-              </div>
-            </div>
+                  </div>
+
+                  <div className="flex-grow-1 flex-basis-0 mw-0">
+                    <Page />
+                  </div>
+                </div>
+              </>
+            )}
           </div>
         </div>
-      </ShareLinkLayout>
+      </div>
+    </>
+  );
+};
+
+SharedPage.getLayout = function getLayout(page) {
+  return (
+    <>
+      <DrawioViewerScript />
+      <ShareLinkLayout>{page}</ShareLinkLayout>
     </>
   );
 };

+ 15 - 5
packages/app/src/pages/tags.page.tsx

@@ -26,8 +26,9 @@ import {
 } from '../stores/context';
 
 import {
-  CommonProps, getServerSideCommonProps, getNextI18NextConfig, useCustomTitle,
+  CommonProps, getServerSideCommonProps, getNextI18NextConfig, generateCustomTitle,
 } from './utils/commons';
+import { NextPageWithLayout } from './_app.page';
 
 const PAGING_LIMIT = 10;
 
@@ -49,7 +50,7 @@ type Props = CommonProps & {
 const TagList = dynamic(() => import('~/components/TagList'), { ssr: false });
 const TagCloudBox = dynamic(() => import('~/components/TagCloudBox'), { ssr: false });
 
-const TagPage: NextPage<CommonProps> = (props: Props) => {
+const TagPage: NextPageWithLayout<CommonProps> = (props: Props) => {
   const [activePage, setActivePage] = useState<number>(1);
   const [offset, setOffset] = useState<number>(0);
 
@@ -64,7 +65,7 @@ const TagPage: NextPage<CommonProps> = (props: Props) => {
   const tagData: IDataTagCount[] = tagDataList?.data || [];
   const totalCount: number = tagDataList?.totalCount || 0;
   const isLoading = tagDataList === undefined && error == null;
-  const classNames: string[] = [];
+
 
   useIsSearchPage(false);
   useIsSearchServiceConfigured(props.isSearchServiceConfigured);
@@ -79,11 +80,14 @@ const TagPage: NextPage<CommonProps> = (props: Props) => {
 
   useRendererConfig(props.rendererConfig);
 
+  const title = generateCustomTitle(props, 'GROWI');
+
   return (
     <>
       <Head>
+        <title>{title}</title>
       </Head>
-      <BasicLayout title={useCustomTitle(props, 'GROWI')} className={classNames.join(' ')}>
+      <div className="dynamic-layout-root">
         <div className="grw-container-convertible mb-5 pb-5" data-testid="tags-page">
           <h2 className="my-3">{`${t('Tags')}(${totalCount})`}</h2>
           <div className="px-3 mb-5 text-center">
@@ -109,11 +113,17 @@ const TagPage: NextPage<CommonProps> = (props: Props) => {
           }
           <div id="grw-fav-sticky-trigger" className="sticky-top"></div>
         </div>
-      </BasicLayout>
+      </div>
     </>
   );
 };
 
+TagPage.getLayout = function getLayout(page) {
+  return (
+    <BasicLayout>{page}</BasicLayout>
+  );
+};
+
 async function injectUserUISettings(context: GetServerSidePropsContext, props: Props): Promise<void> {
   const { model: mongooseModel } = await import('mongoose');
 

+ 25 - 6
packages/app/src/pages/trash.page.tsx

@@ -4,7 +4,9 @@ import type { IUser, IUserHasId } from '@growi/core';
 import { NextPage, GetServerSideProps, GetServerSidePropsContext } from 'next';
 import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
 import dynamic from 'next/dynamic';
+import Head from 'next/head';
 
+import { useCurrentGrowiLayoutFluidClassName } from '~/client/services/layout';
 import { GrowiSubNavigation } from '~/components/Navbar/GrowiSubNavigation';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
 import type { RendererConfig } from '~/interfaces/services/renderer';
@@ -15,15 +17,16 @@ import {
   useCurrentProductNavWidth, useCurrentSidebarContents, useDrawerMode, usePreferDrawerModeByUser, usePreferDrawerModeOnEditByUser, useSidebarCollapsed,
 } from '~/stores/ui';
 
-import { BasicLayout } from '../components/Layout/BasicLayout';
+import { BasicLayoutWithEditorMode } from '../components/Layout/BasicLayout';
 import {
   useCurrentUser, useCurrentPageId, useCurrentPathname,
   useIsSearchServiceConfigured, useIsSearchServiceReachable,
   useIsSearchScopeChildrenAsDefault, useIsSearchPage, useShowPageLimitationXL, useIsGuestUser, useRendererConfig,
 } from '../stores/context';
 
+import { NextPageWithLayout } from './_app.page';
 import {
-  CommonProps, getServerSideCommonProps, getNextI18NextConfig, useCustomTitle,
+  CommonProps, getServerSideCommonProps, getNextI18NextConfig, generateCustomTitle,
 } from './utils/commons';
 
 const TrashPageList = dynamic(() => import('~/components/TrashPageList').then(mod => mod.TrashPageList), { ssr: false });
@@ -45,7 +48,7 @@ type Props = CommonProps & {
   rendererConfig: RendererConfig,
 };
 
-const TrashPage: NextPage<CommonProps> = (props: Props) => {
+const TrashPage: NextPageWithLayout<CommonProps> = (props: Props) => {
   useCurrentUser(props.currentUser ?? null);
 
   useIsSearchServiceConfigured(props.isSearchServiceConfigured);
@@ -70,9 +73,16 @@ const TrashPage: NextPage<CommonProps> = (props: Props) => {
   const { data: isDrawerMode } = useDrawerMode();
   const { data: isGuestUser } = useIsGuestUser();
 
+  const growiLayoutFluidClass = useCurrentGrowiLayoutFluidClassName();
+
+  const title = generateCustomTitle(props, 'GROWI');
+
   return (
     <>
-      <BasicLayout title={useCustomTitle(props, 'GROWI')} >
+      <Head>
+        <title>{title}</title>
+      </Head>
+      <div className={`dynamic-layout-root ${growiLayoutFluidClass}`}>
         <header className="py-0 position-relative">
           <GrowiSubNavigation
             pagePath="/trash"
@@ -83,13 +93,22 @@ const TrashPage: NextPage<CommonProps> = (props: Props) => {
           />
         </header>
 
-        <div className="grw-container-convertible mb-5 pb-5">
+        <div className="content-main grw-container-convertible mb-5 pb-5">
           <TrashPageList />
         </div>
 
         <div id="grw-fav-sticky-trigger" className="sticky-top"></div>
-      </BasicLayout>
+      </div>
+    </>
+  );
+};
 
+TrashPage.getLayout = function getLayout(page) {
+  return (
+    <>
+      <BasicLayoutWithEditorMode>
+        {page}
+      </BasicLayoutWithEditorMode>
       <EmptyTrashModal />
       <PutbackPageModal />
     </>

+ 9 - 2
packages/app/src/pages/user-activation.page.tsx

@@ -1,5 +1,6 @@
 import { NextPage, GetServerSideProps, GetServerSidePropsContext } from 'next';
 import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
+import Head from 'next/head';
 
 import CompleteUserRegistrationForm from '~/components/CompleteUserRegistrationForm';
 import { NoLoginLayout } from '~/components/Layout/NoLoginLayout';
@@ -9,7 +10,7 @@ import type { RegistrationMode } from '~/interfaces/registration-mode';
 import { IUserRegistrationOrder } from '~/server/models/user-registration-order';
 
 import {
-  getServerSideCommonProps, getNextI18NextConfig, useCustomTitle, CommonProps,
+  getServerSideCommonProps, getNextI18NextConfig, generateCustomTitle, CommonProps,
 } from './utils/commons';
 
 type Props = CommonProps & {
@@ -21,8 +22,14 @@ type Props = CommonProps & {
 }
 
 const UserActivationPage: NextPage<Props> = (props: Props) => {
+
+  const title = generateCustomTitle(props, 'GROWI');
+
   return (
-    <NoLoginLayout title={useCustomTitle(props, 'GROWI')}>
+    <NoLoginLayout>
+      <Head>
+        <title>{title}</title>
+      </Head>
       <CompleteUserRegistrationForm
         token={props.token}
         email={props.email}

+ 2 - 2
packages/app/src/pages/utils/commons.ts

@@ -101,7 +101,7 @@ export const getNextI18NextConfig = async(
  * @param props
  * @param title
  */
-export const useCustomTitle = (props: CommonProps, title: string): string => {
+export const generateCustomTitle = (props: CommonProps, title: string): string => {
   return props.customTitleTemplate
     .replace('{{sitename}}', props.appTitle)
     .replace('{{page}}', title)
@@ -114,7 +114,7 @@ export const useCustomTitle = (props: CommonProps, title: string): string => {
  * @param props
  * @param pagePath
  */
-export const useCustomTitleForPage = (props: CommonProps, pagePath: string): string => {
+export const generateCustomTitleForPage = (props: CommonProps, pagePath: string): string => {
   const dPagePath = new DevidedPagePath(pagePath, true, true);
 
   return props.customTitleTemplate

+ 3 - 1
packages/app/src/server/models/config.ts

@@ -147,12 +147,14 @@ export const defaultCrowiConfigs: { [key: string]: any } = {
 };
 
 export const defaultMarkdownConfigs: { [key: string]: any } = {
+  // don't use it, but won't turn it off
   'markdown:xss:tagWhiteList': [],
   'markdown:xss:attrWhiteList': [],
+
   'markdown:rehypeSanitize:isEnabledPrevention': true,
   'markdown:rehypeSanitize:option': RehypeSanitizeOption.RECOMMENDED,
   'markdown:rehypeSanitize:tagNames': [],
-  'markdown:rehypeSanitize:attributes': {},
+  'markdown:rehypeSanitize:attributes': '{}',
   'markdown:isEnabledLinebreaks': false,
   'markdown:isEnabledLinebreaksInComments': true,
   'markdown:adminPreferredIndentSize': 4,

+ 16 - 7
packages/app/src/server/routes/apiv3/markdown-setting.js

@@ -30,7 +30,7 @@ const validator = {
   xssSetting: [
     body('isEnabledXss').isBoolean(),
     body('tagWhiteList').isArray(),
-    body('attrWhiteList').isArray(),
+    body('attrWhiteList').isString(),
   ],
 };
 
@@ -127,8 +127,8 @@ module.exports = (crowi) => {
       pageBreakCustomSeparator: await crowi.configManager.getConfig('markdown', 'markdown:presentation:pageBreakCustomSeparator'),
       isEnabledXss: await crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:isEnabledPrevention'),
       xssOption: await crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:option'),
-      tagWhiteList: await crowi.configManager.getConfig('markdown', 'markdown:xss:tagWhiteList'),
-      attrWhiteList: await crowi.configManager.getConfig('markdown', 'markdown:xss:attrWhiteList'),
+      tagWhiteList: await crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:tagNames'),
+      attrWhiteList: await crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:attributes'),
     };
 
     return res.apiv3({ markdownParams });
@@ -292,11 +292,20 @@ module.exports = (crowi) => {
       return res.apiv3Err(new ErrorV3('xss option is required'));
     }
 
+    try {
+      JSON.parse(req.body.attrWhiteList);
+    }
+    catch (err) {
+      const msg = 'Error occurred in updating xss';
+      logger.error('Error', err);
+      return res.apiv3Err(new ErrorV3(msg, 'update-xss-failed'));
+    }
+
     const reqestXssParams = {
       'markdown:rehypeSanitize:isEnabledPrevention': req.body.isEnabledXss,
       'markdown:rehypeSanitize:option': req.body.xssOption,
-      'markdown:xss:tagWhiteList': req.body.tagWhiteList, // Todo: need to be changed at https://redmine.weseek.co.jp/issues/109763
-      'markdown:xss:attrWhiteList': req.body.attrWhiteList, // Todo: need to be changed at https://redmine.weseek.co.jp/issues/109763
+      'markdown:rehypeSanitize:tagNames': req.body.tagWhiteList,
+      'markdown:rehypeSanitize:attributes': req.body.attrWhiteList,
     };
 
     try {
@@ -304,8 +313,8 @@ module.exports = (crowi) => {
       const xssParams = {
         isEnabledXss: await crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:isEnabledPrevention'),
         xssOption: await crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:option'),
-        tagWhiteList: await crowi.configManager.getConfig('markdown', 'markdown:xss:tagWhiteList'),
-        attrWhiteList: await crowi.configManager.getConfig('markdown', 'markdown:xss:attrWhiteList'),
+        tagWhiteList: await crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:tagNames'),
+        attrWhiteList: await crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:attributes'),
       };
 
       const parameters = { action: SupportedAction.ACTION_ADMIN_MARKDOWN_XSS_UPDATE };

+ 10 - 1
packages/app/src/stores/middlewares/sync-to-storage.ts

@@ -1,6 +1,10 @@
 import { isClient } from '@growi/core';
 import { Middleware } from 'swr';
 
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:stores:sync-to-storage');
+
 const generateKeyInStorage = (key: string): string => {
   return `swr-cache-${key}`;
 };
@@ -30,7 +34,12 @@ export const createSyncToStorageMiddlware = (
       // retrieve initial data from storage
       const itemInStorage = storage.getItem(keyInStorage);
       if (itemInStorage != null) {
-        initData = storageSerializer.deserialize(itemInStorage);
+        try {
+          initData = storageSerializer.deserialize(itemInStorage);
+        }
+        catch (e) {
+          logger.warn(`Could not deserialize the item for the key '${keyInStorage}'`);
+        }
       }
 
       config.fallbackData = initData;

+ 14 - 8
packages/app/src/stores/ui.tsx

@@ -6,7 +6,7 @@ import {
 import { withUtils, SWRResponseWithUtils } from '@growi/core/src/utils/with-utils';
 import { Breakpoint, addBreakpointListener, cleanupBreakpointListener } from '@growi/ui';
 import { HtmlElementNode } from 'rehype-toc';
-import SimpleBar from 'simplebar-react';
+import type SimpleBar from 'simplebar-react';
 import {
   useSWRConfig, SWRResponse, Key, Fetcher,
 } from 'swr';
@@ -79,14 +79,16 @@ export const useIsMobile = (): SWRResponse<boolean, Error> => {
   return useStaticSWR<boolean, Error>(key, undefined, configuration);
 };
 
-const getClassNamesByEditorMode = (editorMode: EditorMode | undefined, isSidebar = false): string[] => {
+// TODO: Enable `editing-sidebar` class
+// https://redmine.weseek.co.jp/issues/111527
+const getClassNamesByEditorMode = (editorMode: EditorMode | undefined /* , isSidebar = false */): string[] => {
   const classNames: string[] = [];
   switch (editorMode) {
     case EditorMode.Editor:
       classNames.push('editing', 'builtin-editor');
-      if (isSidebar) {
-        classNames.push('editing-sidebar');
-      }
+      // if (isSidebar) {
+      //   classNames.push('editing-sidebar');
+      // }
       break;
     case EditorMode.HackMD:
       classNames.push('editing', 'hackmd');
@@ -138,8 +140,10 @@ export const determineEditorModeByHash = (): EditorMode => {
   }
 };
 
+// TODO: Enable `editing-sidebar` class somehow
+// https://redmine.weseek.co.jp/issues/111527
 type EditorModeUtils = {
-  getClassNamesByEditorMode: (isEditingSidebar: boolean) => string[],
+  getClassNamesByEditorMode: (/* isEditingSidebar: boolean */) => string[],
 }
 
 export const useEditorMode = (): SWRResponseWithUtils<EditorModeUtils, EditorMode> => {
@@ -167,9 +171,11 @@ export const useEditorMode = (): SWRResponseWithUtils<EditorModeUtils, EditorMod
     return mutateOriginal(editorMode, shouldRevalidate);
   }, [isEditable, mutateOriginal]);
 
+  // TODO: Enable `editing-sidebar` class
+  // https://redmine.weseek.co.jp/issues/111527
   // construct getClassNamesByEditorMode method
-  const getClassNames = useCallback((isEditingSidebar: boolean) => {
-    return getClassNamesByEditorMode(swrResponse.data, isEditingSidebar);
+  const getClassNames = useCallback((/* isEditingSidebar: boolean */) => {
+    return getClassNamesByEditorMode(swrResponse.data /* , isEditingSidebar */);
   }, [swrResponse.data]);
 
   return Object.assign(swrResponse, {

+ 24 - 18
packages/app/src/stores/websocket.tsx

@@ -1,6 +1,6 @@
 import { useEffect } from 'react';
 
-import io, { Socket } from 'socket.io-client';
+import type { Socket } from 'socket.io-client';
 import { SWRResponse } from 'swr';
 
 import loggerFactory from '~/utils/logger';
@@ -23,14 +23,19 @@ export const useSetupGlobalSocket = (): void => {
   const { mutate } = useStaticSWR(GLOBAL_SOCKET_KEY);
 
   useEffect(() => {
-    const socket = io(GLOBAL_SOCKET_NS, {
-      transports: ['websocket'],
-    });
+    const setUpSocket = async() => {
+      const { io } = await import('socket.io-client');
+      const socket = io(GLOBAL_SOCKET_NS, {
+        transports: ['websocket'],
+      });
 
-    socket.on('error', (err) => { logger.error(err) });
-    socket.on('connect_error', (err) => { logger.error('Failed to connect with websocket.', err) });
+      socket.on('error', (err) => { logger.error(err) });
+      socket.on('connect_error', (err) => { logger.error('Failed to connect with websocket.', err) });
 
-    mutate(socket);
+      mutate(socket);
+    };
+
+    setUpSocket();
 
   }, [mutate]);
 };
@@ -39,23 +44,24 @@ export const useGlobalSocket = (): SWRResponse<Socket, Error> => {
   return useStaticSWR(GLOBAL_SOCKET_KEY);
 };
 
+// comment out for porduction build error: https://github.com/weseek/growi/pull/7131
 /*
  * Global Admin Socket
  */
-export const useSetupGlobalAdminSocket = (shouldInit: boolean): SWRResponse<Socket, Error> => {
-  let socket: Socket | undefined;
+// export const useSetupGlobalAdminSocket = (shouldInit: boolean): SWRResponse<Socket, Error> => {
+//   let socket: Socket | undefined;
 
-  if (shouldInit) {
-    socket = io(GLOBAL_ADMIN_SOCKET_NS, {
-      transports: ['websocket'],
-    });
+//   if (shouldInit) {
+//     socket = io(GLOBAL_ADMIN_SOCKET_NS, {
+//       transports: ['websocket'],
+//     });
 
-    socket.on('error', (err) => { logger.error(err) });
-    socket.on('connect_error', (err) => { logger.error('Failed to connect with websocket.', err) });
-  }
+//     socket.on('error', (err) => { logger.error(err) });
+//     socket.on('connect_error', (err) => { logger.error('Failed to connect with websocket.', err) });
+//   }
 
-  return useStaticSWR(shouldInit ? GLOBAL_ADMIN_SOCKET_KEY : null, socket);
-};
+//   return useStaticSWR(shouldInit ? GLOBAL_ADMIN_SOCKET_KEY : null, socket);
+// };
 
 export const useGlobalAdminSocket = (): SWRResponse<Socket, Error> => {
   return useStaticSWR(GLOBAL_ADMIN_SOCKET_KEY);

+ 2 - 2
packages/app/src/styles/_layout.scss

@@ -6,11 +6,11 @@ body {
   overscroll-behavior-y: none;
 }
 
-.layout-root:not(.growi-layout-fluid) .grw-container-convertible {
+.dynamic-layout-root:not(.growi-layout-fluid) .grw-container-convertible {
   @extend .container-lg;
 }
 
-.layout-root.growi-layout-fluid .grw-container-convertible {
+.dynamic-layout-root.growi-layout-fluid .grw-container-convertible {
   @extend .container-fluid;
 }
 

+ 8 - 11
packages/app/test/cypress/integration/20-basic-features/20-basic-features--access-to-page.spec.ts

@@ -6,8 +6,6 @@ context('Access to page', () => {
     cy.fixture("user-admin.json").then(user => {
       cy.login(user.username, user.password);
     });
-    // collapse sidebar
-    cy.collapseSidebar(true);
   });
 
   it('/Sandbox is successfully loaded', () => {
@@ -17,6 +15,7 @@ context('Access to page', () => {
     // for check download toc data
     cy.get('.toc-link').eq(0).contains('Table of Contents');
 
+    cy.collapseSidebar(true);
     cy.screenshot(`${ssPrefix}-sandbox`);
   });
 
@@ -46,6 +45,7 @@ context('Access to page', () => {
     // for check download toc data
     cy.get('.toc-link').should('be.visible');
 
+    cy.collapseSidebar(true);
     cy.screenshot(`${ssPrefix}-sandbox-math`);
   });
 
@@ -77,7 +77,7 @@ context('Access to page', () => {
 
     // eslint-disable-next-line cypress/no-unnecessary-waiting
     cy.wait(2000); // wait for calcViewHeight and rendering
-
+    cy.collapseSidebar(true);
     cy.screenshot(`${ssPrefix}-user-admin`);
   });
 
@@ -92,8 +92,6 @@ context('Access to /me page', () => {
     cy.fixture("user-admin.json").then(user => {
       cy.login(user.username, user.password);
     });
-    // collapse sidebar
-    cy.collapseSidebar(true);
   });
 
   it('/me is successfully loaded', () => {
@@ -101,6 +99,7 @@ context('Access to /me page', () => {
 
     cy.getByTestid('grw-user-settings').should('be.visible');
 
+    cy.collapseSidebar(true);
     cy.screenshot(`${ssPrefix}-me`);
   });
 
@@ -119,8 +118,6 @@ context('Access to special pages', () => {
     cy.fixture("user-admin.json").then(user => {
       cy.login(user.username, user.password);
     });
-    // collapse sidebar
-    cy.collapseSidebar(true);
   });
 
   it('/trash is successfully loaded', () => {
@@ -128,6 +125,7 @@ context('Access to special pages', () => {
 
     cy.getByTestid('trash-page-list').contains('There are no pages under this page.');
 
+    cy.collapseSidebar(true);
     cy.screenshot(`${ssPrefix}-trash`);
   });
 
@@ -147,6 +145,7 @@ context('Access to special pages', () => {
       cy.getByTestid('grw-tags-list').contains('You have no tag, You can set tags on pages');
     });
 
+    cy.collapseSidebar(true);
     cy.screenshot(`${ssPrefix}-tags`);
   });
 
@@ -160,8 +159,6 @@ context('Access to Template Editing Mode', () => {
     cy.fixture("user-admin.json").then(user => {
       cy.login(user.username, user.password);
     });
-    // collapse sidebar
-    cy.collapseSidebar(true);
   });
 
   // TODO: 109057
@@ -226,8 +223,6 @@ context('Access to /me/all-in-app-notifications', () => {
     cy.fixture("user-admin.json").then(user => {
       cy.login(user.username, user.password);
     });
-    // collapse sidebar
-    cy.collapseSidebar(true);
   });
 
   it('All In-App Notification list is successfully loaded', { scrollBehavior: false },() => {
@@ -238,11 +233,13 @@ context('Access to /me/all-in-app-notifications', () => {
     cy.getByTestid('grw-in-app-notification-page').should('be.visible');
     cy.getByTestid('grw-in-app-notification-page-spinner').should('not.exist');
 
+    cy.collapseSidebar(true);
     cy.screenshot(`${ssPrefix}-see-all`);
 
     cy.get('.grw-custom-nav-tab > div > ul > li:nth-child(2) > a').click();
     cy.getByTestid('grw-in-app-notification-page-spinner').should('not.exist');
 
+    cy.collapseSidebar(true);
     cy.screenshot(`${ssPrefix}-see-unread`);
    });
 

+ 11 - 8
packages/app/test/cypress/integration/20-basic-features/20-basic-features--access-to-pagelist.spec.ts

@@ -5,13 +5,12 @@ context('Access to pagelist', () => {
     cy.fixture("user-admin.json").then(user => {
       cy.login(user.username, user.password);
     });
-    // collapse sidebar
-    cy.collapseSidebar(true);
   });
 
   it('Page list modal is successfully opened ', () => {
     cy.visit('/');
     cy.waitUntilSkeletonDisappear();
+    cy.collapseSidebar(true);
 
     cy.getByTestid('pageListButton').click({force: true});
     cy.getByTestid('page-accessories-modal').parent().should('have.class','show');
@@ -55,8 +54,9 @@ context('Access to pagelist', () => {
 
   it('Successfully expand and close modal', () => {
     cy.visit('/');
-
     cy.waitUntilSkeletonDisappear();
+    cy.collapseSidebar(true);
+
     cy.getByTestid('pageListButton').click({force: true});
     cy.getByTestid('page-accessories-modal').parent().should('have.class','show');
     cy.getByTestid('page-list-item-L').should('be.visible');
@@ -77,6 +77,7 @@ context('Access to pagelist', () => {
       cy.get('button.close').eq(1).click();
     });
 
+    cy.collapseSidebar(true);
     cy.screenshot(`${ssPrefix}8-close-page-list-modal`);
   });
 });
@@ -88,22 +89,24 @@ context('Access to timeline', () => {
     cy.fixture("user-admin.json").then(user => {
       cy.login(user.username, user.password);
     });
-    // collapse sidebar
-    cy.collapseSidebar(true);
   });
   it('Timeline list successfully openend', () => {
     cy.visit('/');
+    cy.collapseSidebar(true);
+
     cy.getByTestid('pageListButton').click({force: true});
     cy.getByTestid('page-accessories-modal').parent().should('have.class','show').within(() => {
       cy.get('.nav-title > li').eq(1).find('a').click();
     });
     // eslint-disable-next-line cypress/no-unnecessary-waiting
     cy.wait(500); // wait for loading wiki
-    cy.screenshot(`${ssPrefix}1-timeline-list`, {capture: 'viewport'});
+    cy.screenshot(`${ssPrefix}1-timeline-list`);
   });
 
   it('Successfully expand and close modal', () => {
     cy.visit('/');
+    cy.collapseSidebar(true);
+
     cy.getByTestid('pageListButton').click({force: true});
     cy.getByTestid('page-accessories-modal').parent().should('have.class','show').within(() => {
       cy.get('.nav-title > li').eq(1).find('a').click();
@@ -112,10 +115,10 @@ context('Access to timeline', () => {
     cy.get('.modal').should('be.visible');
     // eslint-disable-next-line cypress/no-unnecessary-waiting
     cy.wait(500); // wait for loading wiki
-    cy.screenshot(`${ssPrefix}2-timeline-list-fullscreen`, {capture: 'viewport'});
+    cy.screenshot(`${ssPrefix}2-timeline-list-fullscreen`);
     cy.getByTestid('page-accessories-modal').parent().should('have.class','show').within(() => {
       cy.get('button.close').eq(1).click();
     });
-    cy.screenshot(`${ssPrefix}3-close-modal`, {capture: 'viewport'});
+    cy.screenshot(`${ssPrefix}3-close-modal`);
   });
 });

+ 67 - 51
packages/app/test/cypress/integration/20-basic-features/20-basic-features--use-tools.spec.ts

@@ -1,4 +1,4 @@
-context('Switch Sidebar content', () => {
+context('Switch Sidebar content', { scrollBehavior: false }, () => {
   const ssPrefix = 'switch-sidebar-content';
 
   beforeEach(() => {
@@ -9,8 +9,8 @@ context('Switch Sidebar content', () => {
   });
 
   it('PageTree is successfully shown', () => {
-    cy.collapseSidebar(false);
     cy.visit('/page');
+    cy.collapseSidebar(false);
     cy.waitUntilSkeletonDisappear();
 
     cy.getByTestid('grw-sidebar-nav-primary-page-tree').click();
@@ -29,7 +29,6 @@ context('Modal for page operation', () => {
     cy.fixture("user-admin.json").then(user => {
       cy.login(user.username, user.password);
     });
-    cy.collapseSidebar(true);
   });
 
   it("PageCreateModal is shown and closed successfully", () => {
@@ -39,12 +38,14 @@ context('Modal for page operation', () => {
     cy.getByTestid('newPageBtn').click();
 
     // eslint-disable-next-line cypress/no-unnecessary-waiting
-    cy.wait(500) // Wait for animation to finish when the Create Page button is pressed
+    cy.wait(1000) // Wait for animation to finish when the Create Page button is pressed
 
     cy.getByTestid('page-create-modal').should('be.visible').within(() => {
       cy.screenshot(`${ssPrefix}new-page-modal-opened`);
       cy.get('button.close').click();
     });
+
+    cy.collapseSidebar(true, true);
     cy.screenshot(`${ssPrefix}page-create-modal-closed`);
   });
 
@@ -69,9 +70,8 @@ context('Modal for page operation', () => {
     // eslint-disable-next-line cypress/no-unnecessary-waiting
     cy.wait(300);
 
-    // Do not use "cy.waitUntilSkeletonDisappear()"
-    cy.get('.grw-skeleton').should('not.exist');
-
+    cy.collapseSidebar(true, true);
+    cy.waitUntilSkeletonDisappear();
     cy.screenshot(`${ssPrefix}create-today-page`);
   });
 
@@ -81,6 +81,9 @@ context('Modal for page operation', () => {
     cy.visit('/Sandbox');
     cy.waitUntilSkeletonDisappear();
 
+    // eslint-disable-next-line cypress/no-unnecessary-waiting
+    cy.wait(1000);
+
     cy.getByTestid('newPageBtn').click();
 
     cy.getByTestid('page-create-modal').should('be.visible').within(() => {
@@ -98,9 +101,8 @@ context('Modal for page operation', () => {
     // eslint-disable-next-line cypress/no-unnecessary-waiting
     cy.wait(300);
 
-    // Do not use "cy.waitUntilSkeletonDisappear()"
-    cy.get('.grw-skeleton').should('not.exist');
-
+    cy.waitUntilSkeletonDisappear();
+    cy.collapseSidebar(true);
     cy.screenshot(`${ssPrefix}create-page-under-specific-page`);
   });
 
@@ -119,6 +121,7 @@ context('Modal for page operation', () => {
       cy.getByTestid('grw-btn-edit-page').should('be.visible').click();
     });
     cy.get('.toast-error').should('be.visible').invoke('attr', 'style', 'opacity: 1');
+    cy.collapseSidebar(true);
     cy.screenshot(`${ssPrefix}create-template-for-children-error`);
     cy.get('.toast-error').should('be.visible').click();
     cy.get('.toast-error').should('not.exist');
@@ -129,10 +132,11 @@ context('Modal for page operation', () => {
       cy.getByTestid('grw-btn-edit-page').should('be.visible').click();
     });
     cy.get('.toast-error').should('be.visible').invoke('attr', 'style', 'opacity: 1');
+    cy.collapseSidebar(true);
     cy.screenshot(`${ssPrefix}create-template-for-descendants-error`);
   });
 
-  it('Page Deletion and PutBack is executed successfully', () => {
+  it('Page Deletion and PutBack is executed successfully', { scrollBehavior: false }, () => {
     cy.visit('/Sandbox/Bootstrap4');
     cy.waitUntilSkeletonDisappear();
 
@@ -146,6 +150,7 @@ context('Modal for page operation', () => {
       cy.getByTestid('delete-page-button').click();
     });
     cy.getByTestid('trash-page-alert').should('be.visible');
+    cy.collapseSidebar(true);
     cy.screenshot(`${ssPrefix}-bootstrap4-is-in-garbage-box`);
 
     cy.getByTestid('put-back-button').click();
@@ -153,6 +158,8 @@ context('Modal for page operation', () => {
       cy.screenshot(`${ssPrefix}-put-back-modal`);
       cy.getByTestid('put-back-execution-button').should('be.visible').click();
     });
+
+    cy.collapseSidebar(true);
     cy.screenshot(`${ssPrefix}-put-backed-bootstrap4-page`);
   });
 
@@ -222,7 +229,6 @@ context('Page Accessories Modal', () => {
     cy.fixture("user-admin.json").then(user => {
       cy.login(user.username, user.password);
     });
-    cy.collapseSidebar(true);
   });
 
   it('Page History is shown successfully', () => {
@@ -236,7 +242,8 @@ context('Page Accessories Modal', () => {
       cy.getByTestid('open-page-accessories-modal-btn-with-history-tab').click({force: true});
     });
 
-     cy.getByTestid('page-history').should('be.visible')
+     cy.getByTestid('page-history').should('be.visible');
+     cy.collapseSidebar(true);
      cy.screenshot(`${ssPrefix}-open-page-history-bootstrap4`);
   });
 
@@ -252,6 +259,8 @@ context('Page Accessories Modal', () => {
     });
 
      cy.getByTestid('page-attachment').should('be.visible').contains('No attachments yet.');
+
+     cy.collapseSidebar(true);
      cy.screenshot(`${ssPrefix}-open-page-attachment-data-bootstrap4`);
   });
 
@@ -269,6 +278,8 @@ context('Page Accessories Modal', () => {
 
    cy.getByTestid('page-accessories-modal').should('be.visible');
    cy.getByTestid('share-link-management').should('be.visible');
+
+   cy.collapseSidebar(true);
    cy.screenshot(`${ssPrefix}-open-share-link-management-bootstrap4`);
   });
 });
@@ -280,7 +291,6 @@ context('Tag Oprations', { scrollBehavior: false }, () =>{
     cy.fixture("user-admin.json").then(user => {
       cy.login(user.username, user.password);
     });
-    cy.collapseSidebar(true);
   });
 
   it('Successfully add new tag', () => {
@@ -318,6 +328,7 @@ context('Tag Oprations', { scrollBehavior: false }, () =>{
     cy.get('.grw-taglabels-container > .grw-tag-labels > a').contains(tag).should('exist');
     /* eslint-disable cypress/no-unnecessary-waiting */
     cy.wait(150); // wait for toastr to change its color occured by mouseover
+    cy.collapseSidebar(true);
     cy.screenshot(`${ssPrefix}4-click-done`);
   });
 
@@ -339,6 +350,7 @@ context('Tag Oprations', { scrollBehavior: false }, () =>{
 
     // force to add 'active' to pass VRT: https://github.com/weseek/growi/pull/6603
     cy.getByTestid('page-list-item-L').first().invoke('addClass', 'active');
+    cy.collapseSidebar(true);
     cy.screenshot(`${ssPrefix}1-click-tag-name`);
     cy.getByTestid('search-result-list').should('be.visible').then(($el)=>{
       cy.wrap($el).within(()=>{
@@ -347,6 +359,7 @@ context('Tag Oprations', { scrollBehavior: false }, () =>{
 
       // eslint-disable-next-line cypress/no-unnecessary-waiting
       cy.wait(1500); // for wait rendering pagelist info
+      cy.collapseSidebar(true);
       cy.screenshot(`${ssPrefix}2-click-three-dots-menu`);
 
       cy.wrap($el).within(()=>{
@@ -369,6 +382,7 @@ context('Tag Oprations', { scrollBehavior: false }, () =>{
 
     cy.visit(`Sandbox-${newPageName}`);
     cy.waitUntilSkeletonDisappear();
+    cy.collapseSidebar(true);
     cy.screenshot(`${ssPrefix}4-duplicated-page`);
   });
 
@@ -391,6 +405,7 @@ context('Tag Oprations', { scrollBehavior: false }, () =>{
 
     // eslint-disable-next-line cypress/no-unnecessary-waiting
     cy.wait(300);
+    cy.collapseSidebar(true);
     cy.screenshot(`${ssPrefix}1-click-tag-name`);
 
     cy.getByTestid('search-result-list').within(() => {
@@ -437,53 +452,54 @@ context('Tag Oprations', { scrollBehavior: false }, () =>{
     cy.waitUntilSkeletonDisappear();
 
     cy.getByTestid('grw-tag-labels').should('be.visible')
+    cy.collapseSidebar(true);
     cy.screenshot(`${ssPrefix}4-new-page-name-applied`);
   });
 });
 
-context('Shortcuts', () => {
-  const ssPrefix = 'shortcuts';
+// context('Shortcuts', () => {
+//   const ssPrefix = 'shortcuts';
 
-  beforeEach(() => {
-    // login
-    cy.fixture("user-admin.json").then(user => {
-      cy.login(user.username, user.password);
-    });
-  });
+//   beforeEach(() => {
+//     // login
+//     cy.fixture("user-admin.json").then(user => {
+//       cy.login(user.username, user.password);
+//     });
+//   });
 
-  it('Successfully updating a page using a shortcut on a previously created page', () => {
-    const body1 = 'hello';
-    const body2 = 'world';
-    const savePageShortcutKey = '{ctrl+s}'
+//   it('Successfully updating a page using a shortcut on a previously created page', { scrollBehavior: false }, () => {
+//     const body1 = 'hello';
+//     const body2 = 'world';
+//     const savePageShortcutKey = '{ctrl+s}';
 
-    cy.visit('/Sandbox/child');
-    cy.waitUntilSkeletonDisappear();
+//     cy.visit('/Sandbox/child');
+//     cy.waitUntilSkeletonDisappear();
 
-    cy.get('#grw-subnav-container').within(() => {
-      cy.getByTestid('editor-button').should('be.visible').click();
-    })
+//     cy.get('#grw-subnav-container').within(() => {
+//       cy.getByTestid('editor-button').click();
+//     });
 
-    cy.get('.layout-root').should('have.class', 'editing');
-    cy.get('.grw-editor-navbar-bottom').should('be.visible');
+//     cy.get('.layout-root').should('have.class', 'editing');
+//     cy.get('.grw-editor-navbar-bottom').should('be.visible');
 
-    // 1st
-    cy.get('.CodeMirror').type(body1);
-    cy.get('.CodeMirror').contains(body1);
-    cy.get('.page-editor-preview-body').contains(body1);
-    cy.get('.CodeMirror').type(savePageShortcutKey);
+//     // 1st
+//     cy.get('.CodeMirror').type(body1);
+//     cy.get('.CodeMirror').contains(body1);
+//     cy.get('.page-editor-preview-body').contains(body1);
+//     cy.get('.CodeMirror').type(savePageShortcutKey);
 
-    cy.get('.toast').should('be.visible').trigger('mouseover');
-    cy.screenshot(`${ssPrefix}-update-page-1`);
-    cy.get('.toast-close-button').click();
-    cy.get('.toast').should('not.exist');
+//     cy.get('.Toastify').should('visible').trigger('mouseover');
+//     cy.screenshot(`${ssPrefix}-update-page-1`);
+//     cy.get('.Toastify__close-button').should('be.visible').click();
+//     cy.get('.Toastify').should('not.be.visible');
 
-    // 2nd
-    cy.get('.CodeMirror').type(body2);
-    cy.get('.CodeMirror').contains(body2);
-    cy.get('.page-editor-preview-body').contains(body2);
-    cy.get('.CodeMirror').type(savePageShortcutKey);
+//     // 2nd
+//     cy.get('.CodeMirror').type(body2);
+//     cy.get('.CodeMirror').contains(body2);
+//     cy.get('.page-editor-preview-body').contains(body2);
+//     cy.get('.CodeMirror').type(savePageShortcutKey);
 
-    cy.get('.toast').should('be.visible').trigger('mouseover');
-    cy.screenshot(`${ssPrefix}-update-page-2`);
-  });
-});
+//     cy.get('.Toastify').should('visible').trigger('mouseover');
+//     cy.screenshot(`${ssPrefix}-update-page-2`);
+//   });
+// });

Разница между файлами не показана из-за своего большого размера
+ 104 - 429
yarn.lock


Некоторые файлы не были показаны из-за большого количества измененных файлов