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

Merge branch 'dev/7.0.x' into feat/new-incremental-search

Shun Miyazawa 2 лет назад
Родитель
Сommit
eb797d6a84
75 измененных файлов с 1416 добавлено и 974 удалено
  1. 15 13
      apps/app/_obsolete/src/components/PageEditor/MarkdownTableInterceptor.js
  2. 8 0
      apps/app/public/static/locales/en_US/translation.json
  3. 8 0
      apps/app/public/static/locales/ja_JP/translation.json
  4. 8 0
      apps/app/public/static/locales/zh_CN/translation.json
  5. 20 11
      apps/app/src/client/services/layout.ts
  6. 4 4
      apps/app/src/client/services/side-effects/handsontable-modal-launcher-for-view.ts
  7. 49 0
      apps/app/src/client/services/use-on-template-button-clicked.ts
  8. 6 0
      apps/app/src/client/services/user-ui-settings.ts
  9. 18 0
      apps/app/src/components/Common/PageViewLayout.module.scss
  10. 7 2
      apps/app/src/components/Common/PageViewLayout.tsx
  11. 13 3
      apps/app/src/components/CreateTemplateModal.tsx
  12. 15 34
      apps/app/src/components/InAppNotification/InAppNotificationElm.tsx
  13. 2 11
      apps/app/src/components/InAppNotification/PageNotification/ModelNotification.tsx
  14. 34 25
      apps/app/src/components/InAppNotification/PageNotification/PageModelNotification.tsx
  15. 30 26
      apps/app/src/components/InAppNotification/PageNotification/UserModelNotification.tsx
  16. 19 0
      apps/app/src/components/InAppNotification/PageNotification/index.tsx
  17. 2 19
      apps/app/src/components/InAppNotification/PageNotification/useActionAndMsg.ts
  18. 6 95
      apps/app/src/components/Me/OtherSettings.tsx
  19. 106 0
      apps/app/src/components/Me/QuestionnaireSettings.tsx
  20. 2 2
      apps/app/src/components/Me/SidebarCollapsedIcon.jsx
  21. 0 0
      apps/app/src/components/Me/SidebarDockIcon.jsx
  22. 7 0
      apps/app/src/components/Me/UISettings.module.scss
  23. 114 0
      apps/app/src/components/Me/UISettings.tsx
  24. 6 6
      apps/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  25. 2 47
      apps/app/src/components/Navbar/hooks.tsx
  26. 4 0
      apps/app/src/components/Page/PageView.tsx
  27. 9 0
      apps/app/src/components/PageControls/PageControls.tsx
  28. 13 6
      apps/app/src/components/PageEditor/HandsontableModal.tsx
  29. 1 1
      apps/app/src/components/PageEditor/MarkdownTableDataImportForm.tsx
  30. 0 190
      apps/app/src/components/PageEditor/MarkdownTableUtil.js
  31. 11 22
      apps/app/src/components/PageEditor/PageEditor.tsx
  32. 9 20
      apps/app/src/components/PageEditor/Preview.module.scss
  33. 6 2
      apps/app/src/components/PageEditor/Preview.tsx
  34. 147 0
      apps/app/src/components/PageEditor/markdown-table-util-for-editor.ts
  35. 26 0
      apps/app/src/components/PageEditor/markdown-table-util-for-view.ts
  36. 8 0
      apps/app/src/components/SearchPage/SearchResultContent.module.scss
  37. 13 13
      apps/app/src/components/SearchPage/SearchResultContent.tsx
  38. 4 0
      apps/app/src/components/ShareLinkPageView.tsx
  39. 2 1
      apps/app/src/components/Sidebar/AppTitle/AppTitle.module.scss
  40. 6 6
      apps/app/src/components/Sidebar/PageCreateButton/DropendMenu.tsx
  41. 21 145
      apps/app/src/components/Sidebar/PageCreateButton/PageCreateButton.tsx
  42. 95 0
      apps/app/src/components/Sidebar/PageCreateButton/hooks.tsx
  43. 1 2
      apps/app/src/components/Sidebar/PageTreeItem/PageTreeItem.tsx
  44. 1 1
      apps/app/src/components/Sidebar/Tag.tsx
  45. 6 4
      apps/app/src/components/TreeItem/SimpleItem.tsx
  46. 0 2
      apps/app/src/interfaces/in-app-notification.ts
  47. 3 5
      apps/app/src/pages/[[...path]].page.tsx
  48. 1 1
      apps/app/src/pages/me/[[...path]].page.tsx
  49. 2 5
      apps/app/src/pages/share/[[...path]].page.tsx
  50. 1 1
      apps/app/src/pages/tags.page.tsx
  51. 1 1
      apps/app/src/pages/trash.page.tsx
  52. 2 2
      apps/app/src/server/models/activity.ts
  53. 246 1
      apps/app/src/server/routes/apiv3/attachment.js
  54. 3 1
      apps/app/src/server/routes/apiv3/bookmarks.js
  55. 4 1
      apps/app/src/server/routes/apiv3/page.js
  56. 3 1
      apps/app/src/server/routes/apiv3/pages.js
  57. 0 162
      apps/app/src/server/routes/attachment/api.js
  58. 10 1
      apps/app/src/server/routes/comment.js
  59. 0 2
      apps/app/src/server/routes/index.js
  60. 9 2
      apps/app/src/server/routes/login.js
  61. 5 1
      apps/app/src/server/routes/page.js
  62. 18 5
      apps/app/src/server/service/activity.ts
  63. 20 36
      apps/app/src/server/service/in-app-notification.ts
  64. 19 0
      apps/app/src/server/service/in-app-notification/in-app-notification-utils.ts
  65. 41 15
      apps/app/src/server/service/page.ts
  66. 66 0
      apps/app/src/server/service/pre-notify.ts
  67. 3 7
      apps/app/src/stores/modal.tsx
  68. 0 5
      apps/app/src/styles/_layout.scss
  69. 1 0
      apps/app/src/styles/_mixins.scss
  70. 4 0
      apps/app/src/styles/mixins/_fluid-layout.scss
  71. 3 1
      apps/app/test/cypress/e2e/50-sidebar/50-sidebar--access-to-side-bar.cy.ts
  72. 2 2
      packages/editor/src/components/CodeMirrorEditor/CodeMirrorEditor.tsx
  73. 19 2
      packages/editor/src/components/CodeMirrorEditor/Toolbar/TableButton.tsx
  74. 1 1
      packages/editor/src/components/CodeMirrorEditor/Toolbar/Toolbar.tsx
  75. 45 0
      packages/editor/src/stores/use-handsontable.ts

+ 15 - 13
apps/app/src/components/PageEditor/MarkdownTableInterceptor.js → apps/app/_obsolete/src/components/PageEditor/MarkdownTableInterceptor.js

@@ -2,7 +2,10 @@ import { BasicInterceptor } from '@growi/core/dist/utils';
 
 
 import MarkdownTable from '~/client/models/MarkdownTable';
 import MarkdownTable from '~/client/models/MarkdownTable';
 
 
-import mtu from './MarkdownTableUtil';
+import {
+  getStrFromBot, addRowToMarkdownTable, getStrToEot, isEndOfLine, mergeMarkdownTable, replaceFocusedMarkdownTableWithEditor,
+  isInTable, emptyLineOfTableRE,
+} from '../../../../src/components/PageEditor/markdown-table-util-for-editor';
 
 
 /**
 /**
  * Interceptor for markdown table
  * Interceptor for markdown table
@@ -27,24 +30,24 @@ export default class MarkdownTableInterceptor extends BasicInterceptor {
 
 
   addRow(cm) {
   addRow(cm) {
     // get lines all of table from current position to beginning of table
     // get lines all of table from current position to beginning of table
-    const strFromBot = mtu.getStrFromBot(cm);
+    const strFromBot = getStrFromBot(cm);
     let table = MarkdownTable.fromMarkdownString(strFromBot);
     let table = MarkdownTable.fromMarkdownString(strFromBot);
 
 
-    mtu.addRowToMarkdownTable(table);
+    addRowToMarkdownTable(table);
 
 
-    const strToEot = mtu.getStrToEot(cm);
+    const strToEot = getStrToEot(cm);
     const tableBottom = MarkdownTable.fromMarkdownString(strToEot);
     const tableBottom = MarkdownTable.fromMarkdownString(strToEot);
     if (tableBottom.table.length > 0) {
     if (tableBottom.table.length > 0) {
-      table = mtu.mergeMarkdownTable([table, tableBottom]);
+      table = mergeMarkdownTable([table, tableBottom]);
     }
     }
 
 
-    mtu.replaceMarkdownTableWithReformed(cm, table);
+    replaceFocusedMarkdownTableWithEditor(cm, table);
   }
   }
 
 
   reformTable(cm) {
   reformTable(cm) {
-    const tableStr = mtu.getStrFromBot(cm) + mtu.getStrToEot(cm);
+    const tableStr = getStrFromBot(cm) + getStrToEot(cm);
     const table = MarkdownTable.fromMarkdownString(tableStr);
     const table = MarkdownTable.fromMarkdownString(tableStr);
-    mtu.replaceMarkdownTableWithReformed(cm, table);
+    replaceFocusedMarkdownTableWithEditor(cm, table);
   }
   }
 
 
   removeRow(editor) {
   removeRow(editor) {
@@ -67,16 +70,15 @@ export default class MarkdownTableInterceptor extends BasicInterceptor {
 
 
     const cm = editor.getCodeMirror();
     const cm = editor.getCodeMirror();
 
 
-    const isInTable = mtu.isInTable(cm);
-    const isLastRow = mtu.getStrToEot(cm) === editor.getStrToEol();
+    const isLastRow = getStrToEot(cm) === editor.getStrToEol();
 
 
-    if (isInTable) {
+    if (isInTable(cm)) {
       // at EOL in the table
       // at EOL in the table
-      if (mtu.isEndOfLine(cm)) {
+      if (isEndOfLine(cm)) {
         this.addRow(cm);
         this.addRow(cm);
       }
       }
       // last empty row
       // last empty row
-      else if (isLastRow && mtu.emptyLineOfTableRE.test(editor.getStrFromBol() + editor.getStrToEol())) {
+      else if (isLastRow && emptyLineOfTableRE.test(editor.getStrFromBol() + editor.getStrToEol())) {
         this.removeRow(editor);
         this.removeRow(editor);
       }
       }
       else {
       else {

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

@@ -249,6 +249,14 @@
       "page_create": "Subscribe to the page when you create it."
       "page_create": "Subscribe to the page when you create it."
     }
     }
   },
   },
+  "ui_settings": {
+    "ui_settings": "UI Settings",
+    "side_bar_mode": {
+      "settings": "Sidebar mode settings",
+      "side_bar_mode_setting": "Set the sidebar mode",
+      "description": "You can set whether or not the sidebar will always be open when the screen width is large. If the screen width is small, the sidebar will always be closed."
+    }
+  },
   "editor_settings": {
   "editor_settings": {
     "editor_settings": "Editor Settings"
     "editor_settings": "Editor Settings"
   },
   },

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

@@ -250,6 +250,14 @@
       "page_create": "ページを作成した時にそのページをサブスクライブします。"
       "page_create": "ページを作成した時にそのページをサブスクライブします。"
     }
     }
   },
   },
+  "ui_settings": {
+    "ui_settings": "UI設定",
+    "side_bar_mode": {
+      "settings": "サイドバーモードの設定",
+      "side_bar_mode_setting": "サイドバーのモードを設定する",
+      "description": "画面幅が大きい場合に、サイドバーを常時開いた状態にするかどうかを設定できます。画面幅が小さい場合はサイドバーは常に閉じた状態となります。"
+    }
+  },
   "editor_settings": {
   "editor_settings": {
     "editor_settings": "エディター設定",
     "editor_settings": "エディター設定",
     "common_settings": {
     "common_settings": {

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

@@ -240,6 +240,14 @@
       "page_create": "创建页面时订阅页面。"
       "page_create": "创建页面时订阅页面。"
     }
     }
   },
   },
+  "ui_settings": {
+    "ui_settings": "用户界面设置",
+    "side_bar_mode": {
+      "settings": "侧边栏模式设置",
+      "side_bar_mode_setting": "设置侧边栏模式",
+      "description": "您可以设置当屏幕宽度较大时,侧边栏是否始终打开。 如果屏幕宽度较小,侧边栏将始终关闭。"
+    }
+  },
   "editor_settings": {
   "editor_settings": {
     "editor_settings": "编辑器设置"
     "editor_settings": "编辑器设置"
   },
   },

+ 20 - 11
apps/app/src/client/services/layout.ts

@@ -9,20 +9,29 @@ export const useEditorModeClassName = (): string => {
   return `${getClassNamesByEditorMode().join(' ') ?? ''}`;
   return `${getClassNamesByEditorMode().join(' ') ?? ''}`;
 };
 };
 
 
-export const useLayoutFluidClassName = (expandContentWidth?: boolean | null): string => {
+const useDetermineExpandContent = (expandContentWidth?: boolean | null): boolean => {
   const { data: dataIsContainerFluid } = useIsContainerFluid();
   const { data: dataIsContainerFluid } = useIsContainerFluid();
 
 
   const isContainerFluidDefault = dataIsContainerFluid;
   const isContainerFluidDefault = dataIsContainerFluid;
-  const isContainerFluid = expandContentWidth ?? isContainerFluidDefault;
-
-  return isContainerFluid ? 'growi-layout-fluid' : '';
+  return expandContentWidth ?? isContainerFluidDefault ?? false;
 };
 };
 
 
-export const useLayoutFluidClassNameByPage = (initialPage?: IPage): string => {
-  const page = initialPage;
-  const expandContentWidth = page == null || !('expandContentWidth' in page)
-    ? null
-    : page.expandContentWidth;
-
-  return useLayoutFluidClassName(expandContentWidth);
+export const useShouldExpandContent = (data?: IPage | boolean | null): boolean => {
+  const expandContentWidth = (() => {
+    // when data is null
+    if (data == null) {
+      return null;
+    }
+    // when data is boolean
+    if (data === true || data === false) {
+      return data;
+    }
+    // when IPage does not have expandContentWidth
+    if (!('expandContentWidth' in data)) {
+      return null;
+    }
+    return data.expandContentWidth;
+  })();
+
+  return useDetermineExpandContent(expandContentWidth);
 };
 };

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

@@ -4,7 +4,7 @@ import EventEmitter from 'events';
 
 
 import MarkdownTable from '~/client/models/MarkdownTable';
 import MarkdownTable from '~/client/models/MarkdownTable';
 import { useSaveOrUpdate } from '~/client/services/page-operation';
 import { useSaveOrUpdate } from '~/client/services/page-operation';
-import mtu from '~/components/PageEditor/MarkdownTableUtil';
+import { getMarkdownTableFromLine, replaceMarkdownTableInMarkdown } from '~/components/PageEditor/markdown-table-util-for-view';
 import type { OptionsToSave } from '~/interfaces/page-operation';
 import type { OptionsToSave } from '~/interfaces/page-operation';
 import { useShareLinkId } from '~/stores/context';
 import { useShareLinkId } from '~/stores/context';
 import { useHandsontableModal } from '~/stores/modal';
 import { useHandsontableModal } from '~/stores/modal';
@@ -40,7 +40,7 @@ export const useHandsontableModalLauncherForView = (opts?: {
     }
     }
 
 
     const currentMarkdown = currentPage.revision.body;
     const currentMarkdown = currentPage.revision.body;
-    const newMarkdown = mtu.replaceMarkdownTableInMarkdown(table, currentMarkdown, bol, eol);
+    const newMarkdown = replaceMarkdownTableInMarkdown(table, currentMarkdown, bol, eol);
 
 
     const grantUserGroupIds = currentPage.grantedGroups.map((g) => {
     const grantUserGroupIds = currentPage.grantedGroups.map((g) => {
       return {
       return {
@@ -82,8 +82,8 @@ export const useHandsontableModalLauncherForView = (opts?: {
 
 
     const handler = (bol: number, eol: number) => {
     const handler = (bol: number, eol: number) => {
       const markdown = currentPage.revision.body;
       const markdown = currentPage.revision.body;
-      const currentMarkdownTable = mtu.getMarkdownTableFromLine(markdown, bol, eol);
-      openHandsontableModal(currentMarkdownTable, undefined, false, table => saveByHandsontableModal(table, bol, eol));
+      const currentMarkdownTable = getMarkdownTableFromLine(markdown, bol, eol);
+      openHandsontableModal(currentMarkdownTable, false, table => saveByHandsontableModal(table, bol, eol));
     };
     };
     globalEmitter.on('launchHandsonTableModal', handler);
     globalEmitter.on('launchHandsonTableModal', handler);
 
 

+ 49 - 0
apps/app/src/client/services/use-on-template-button-clicked.ts

@@ -0,0 +1,49 @@
+import { useCallback, useState } from 'react';
+
+import { useRouter } from 'next/router';
+
+import { createPage, exist } from '~/client/services/page-operation';
+import { LabelType } from '~/interfaces/template';
+
+export const useOnTemplateButtonClicked = (
+    currentPagePath?: string,
+): {
+  onClickHandler: (label: LabelType) => Promise<void>,
+  isPageCreating: boolean
+} => {
+  const router = useRouter();
+  const [isPageCreating, setIsPageCreating] = useState(false);
+
+  const onClickHandler = useCallback(async(label: LabelType) => {
+    try {
+      setIsPageCreating(true);
+
+      const path = currentPagePath == null || currentPagePath === '/'
+        ? `/${label}`
+        : `${currentPagePath}/${label}`;
+
+      const params = {
+        isSlackEnabled: false,
+        slackChannels: '',
+        grant: 4,
+      // grant: currentPage?.grant || 1,
+      // grantUserGroupId: currentPage?.grantedGroup?._id,
+      };
+
+      const res = await exist(JSON.stringify([path]));
+      if (!res.pages[path]) {
+        await createPage(path, '', params);
+      }
+
+      router.push(`${path}#edit`);
+    }
+    catch (err) {
+      throw err;
+    }
+    finally {
+      setIsPageCreating(false);
+    }
+  }, [currentPagePath, router]);
+
+  return { onClickHandler, isPageCreating };
+};

+ 6 - 0
apps/app/src/client/services/user-ui-settings.ts

@@ -25,3 +25,9 @@ export const scheduleToPut = (settings: Partial<IUserUISettings>): void => {
 
 
   _putUserUISettingsInBulkDebounced();
   _putUserUISettingsInBulkDebounced();
 };
 };
+
+export const updateUserUISettings = async(settings: Partial<IUserUISettings>): Promise<AxiosResponse<IUserUISettings>> => {
+  const result = await apiv3Put<IUserUISettings>('/user-ui-settings', { settings });
+
+  return result;
+};

+ 18 - 0
apps/app/src/components/Common/PageViewLayout.module.scss

@@ -1,5 +1,6 @@
 @use '@growi/core/scss/bootstrap/init' as bs;
 @use '@growi/core/scss/bootstrap/init' as bs;
 
 
+@use '~/styles/mixins';
 @use '~/styles/variables' as var;
 @use '~/styles/variables' as var;
 
 
 
 
@@ -18,6 +19,23 @@ $page-view-layout-margin-top: 32px;
   }
   }
 }
 }
 
 
+// container padding
+.page-view-layout :global,
+.footer-layout :global {
+  @include bs.media-breakpoint-up(lg) {
+    .container-lg {
+      --bs-gutter-x: 3rem;
+    }
+  }
+}
+
+// fluid layout
+.fluid-layout :global {
+  .grw-container-convertible {
+    @include mixins.fluid-layout();
+  }
+}
+
 .page-view-layout :global {
 .page-view-layout :global {
   .grw-side-contents-container {
   .grw-side-contents-container {
     margin-bottom: 1rem;
     margin-bottom: 1rem;

+ 7 - 2
apps/app/src/components/Common/PageViewLayout.tsx

@@ -4,22 +4,27 @@ import styles from './PageViewLayout.module.scss';
 
 
 const pageViewLayoutClass = styles['page-view-layout'] ?? '';
 const pageViewLayoutClass = styles['page-view-layout'] ?? '';
 const footerLayoutClass = styles['footer-layout'] ?? '';
 const footerLayoutClass = styles['footer-layout'] ?? '';
+const _fluidLayoutClass = styles['fluid-layout'] ?? '';
 
 
 type Props = {
 type Props = {
   children?: ReactNode,
   children?: ReactNode,
   headerContents?: ReactNode,
   headerContents?: ReactNode,
   sideContents?: ReactNode,
   sideContents?: ReactNode,
   footerContents?: ReactNode,
   footerContents?: ReactNode,
+  expandContentWidth?: boolean,
 }
 }
 
 
 export const PageViewLayout = (props: Props): JSX.Element => {
 export const PageViewLayout = (props: Props): JSX.Element => {
   const {
   const {
     children, headerContents, sideContents, footerContents,
     children, headerContents, sideContents, footerContents,
+    expandContentWidth,
   } = props;
   } = props;
 
 
+  const fluidLayoutClass = expandContentWidth ? _fluidLayoutClass : '';
+
   return (
   return (
     <>
     <>
-      <div id="main" className={`main ${pageViewLayoutClass} flex-expand-vert`}>
+      <div id="main" className={`main ${pageViewLayoutClass} ${fluidLayoutClass} flex-expand-vert`}>
         <div id="content-main" className="content-main container-lg grw-container-convertible flex-expand-vert">
         <div id="content-main" className="content-main container-lg grw-container-convertible flex-expand-vert">
           { headerContents != null && headerContents }
           { headerContents != null && headerContents }
           { sideContents != null
           { sideContents != null
@@ -43,7 +48,7 @@ export const PageViewLayout = (props: Props): JSX.Element => {
       </div>
       </div>
 
 
       { footerContents != null && (
       { footerContents != null && (
-        <footer className={`footer d-edit-none ${footerLayoutClass}`}>
+        <footer className={`footer d-edit-none ${footerLayoutClass} ${fluidLayoutClass}`}>
           {footerContents}
           {footerContents}
         </footer>
         </footer>
       ) }
       ) }

+ 13 - 3
apps/app/src/components/CreateTemplateModal.tsx

@@ -1,12 +1,13 @@
-import React from 'react';
+import React, { useCallback } from 'react';
 
 
 import { pathUtils } from '@growi/core/dist/utils';
 import { pathUtils } from '@growi/core/dist/utils';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import { Modal, ModalHeader, ModalBody } from 'reactstrap';
 import { Modal, ModalHeader, ModalBody } from 'reactstrap';
 
 
+import { useOnTemplateButtonClicked } from '~/client/services/use-on-template-button-clicked';
+import { toastError } from '~/client/util/toastr';
 import { TargetType, LabelType } from '~/interfaces/template';
 import { TargetType, LabelType } from '~/interfaces/template';
 
 
-import { useOnTemplateButtonClicked } from './Navbar/hooks';
 
 
 type TemplateCardProps = {
 type TemplateCardProps = {
   target: TargetType;
   target: TargetType;
@@ -56,6 +57,15 @@ export const CreateTemplateModal: React.FC<CreateTemplateModalProps> = ({
 
 
   const { onClickHandler: onClickTemplateButton, isPageCreating } = useOnTemplateButtonClicked(path);
   const { onClickHandler: onClickTemplateButton, isPageCreating } = useOnTemplateButtonClicked(path);
 
 
+  const onClickTemplateButtonHandler = useCallback(async(label: LabelType) => {
+    try {
+      await onClickTemplateButton(label);
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [onClickTemplateButton]);
+
   const parentPath = pathUtils.addTrailingSlash(path);
   const parentPath = pathUtils.addTrailingSlash(path);
 
 
   const renderTemplateCard = (target: TargetType, label: LabelType) => (
   const renderTemplateCard = (target: TargetType, label: LabelType) => (
@@ -64,7 +74,7 @@ export const CreateTemplateModal: React.FC<CreateTemplateModalProps> = ({
         target={target}
         target={target}
         label={label}
         label={label}
         isPageCreating={isPageCreating}
         isPageCreating={isPageCreating}
-        onClickHandler={() => onClickTemplateButton(label)}
+        onClickHandler={() => onClickTemplateButtonHandler(label)}
       />
       />
     </div>
     </div>
   );
   );

+ 15 - 34
apps/app/src/components/InAppNotification/InAppNotificationElm.tsx

@@ -1,19 +1,13 @@
-import React, {
-  FC, useRef,
-} from 'react';
+import React, { FC } from 'react';
 
 
-import type { IUser, IPage, HasObjectId } from '@growi/core';
+import type { HasObjectId } from '@growi/core';
 import { UserPicture } from '@growi/ui/dist/components';
 import { UserPicture } from '@growi/ui/dist/components';
 import { DropdownItem } from 'reactstrap';
 import { DropdownItem } from 'reactstrap';
 
 
-import { IInAppNotificationOpenable } from '~/client/interfaces/in-app-notification-openable';
 import { apiv3Post } from '~/client/util/apiv3-client';
 import { apiv3Post } from '~/client/util/apiv3-client';
-import { SupportedTargetModel } from '~/interfaces/activity';
 import { IInAppNotification, InAppNotificationStatuses } from '~/interfaces/in-app-notification';
 import { IInAppNotification, InAppNotificationStatuses } from '~/interfaces/in-app-notification';
 
 
-// Change the display for each targetmodel
-import PageModelNotification from './PageNotification/PageModelNotification';
-import UserModelNotification from './PageNotification/UserModelNotification';
+import { useModelNotification } from './PageNotification';
 
 
 interface Props {
 interface Props {
   notification: IInAppNotification & HasObjectId
   notification: IInAppNotification & HasObjectId
@@ -26,7 +20,14 @@ const InAppNotificationElm: FC<Props> = (props: Props) => {
 
 
   const { notification } = props;
   const { notification } = props;
 
 
-  const notificationRef = useRef<IInAppNotificationOpenable>(null);
+  const modelNotificationUtils = useModelNotification(notification);
+
+  const Notification = modelNotificationUtils?.Notification;
+  const publishOpen = modelNotificationUtils?.publishOpen;
+
+  if (Notification == null || publishOpen == null) {
+    return <></>;
+  }
 
 
   const clickHandler = async(notification: IInAppNotification & HasObjectId): Promise<void> => {
   const clickHandler = async(notification: IInAppNotification & HasObjectId): Promise<void> => {
     if (notification.status === InAppNotificationStatuses.STATUS_UNOPENED) {
     if (notification.status === InAppNotificationStatuses.STATUS_UNOPENED) {
@@ -34,10 +35,7 @@ const InAppNotificationElm: FC<Props> = (props: Props) => {
       await apiv3Post('/in-app-notification/open', { id: notification._id });
       await apiv3Post('/in-app-notification/open', { id: notification._id });
     }
     }
 
 
-    const currentInstance = notificationRef.current;
-    if (currentInstance != null) {
-      currentInstance.open();
-    }
+    publishOpen();
   };
   };
 
 
   const renderActionUserPictures = (): JSX.Element => {
   const renderActionUserPictures = (): JSX.Element => {
@@ -61,14 +59,6 @@ const InAppNotificationElm: FC<Props> = (props: Props) => {
 
 
   const isDropdownItem = props.type === 'dropdown-item';
   const isDropdownItem = props.type === 'dropdown-item';
 
 
-  const isPageNotification = (notification: IInAppNotification): notification is IInAppNotification<IPage> => {
-    return notification.targetModel === SupportedTargetModel.MODEL_PAGE;
-  };
-
-  const isUserNotification = (notification: IInAppNotification): notification is IInAppNotification<IUser> => {
-    return notification.targetModel === SupportedTargetModel.MODEL_USER;
-  };
-
   // determine tag
   // determine tag
   const TagElem = isDropdownItem
   const TagElem = isDropdownItem
     ? DropdownItem
     ? DropdownItem
@@ -86,18 +76,9 @@ const InAppNotificationElm: FC<Props> = (props: Props) => {
         >
         >
         </span>
         </span>
         {renderActionUserPictures()}
         {renderActionUserPictures()}
-        {isPageNotification(notification) && (
-          <PageModelNotification
-            ref={notificationRef}
-            notification={notification}
-          />
-        )}
-        {isUserNotification(notification) && (
-          <UserModelNotification
-            ref={notificationRef}
-            notification={notification}
-          />
-        )}
+
+        <Notification />
+
       </div>
       </div>
     </TagElem>
     </TagElem>
   );
   );

+ 2 - 11
apps/app/src/components/InAppNotification/PageNotification/ModelNotification.tsx

@@ -1,9 +1,8 @@
-import React, { FC, useImperativeHandle } from 'react';
+import React, { FC } from 'react';
 
 
 import type { HasObjectId } from '@growi/core';
 import type { HasObjectId } from '@growi/core';
 import { PagePathLabel } from '@growi/ui/dist/components';
 import { PagePathLabel } from '@growi/ui/dist/components';
 
 
-import type { IInAppNotificationOpenable } from '~/client/interfaces/in-app-notification-openable';
 import type { IInAppNotification } from '~/interfaces/in-app-notification';
 import type { IInAppNotification } from '~/interfaces/in-app-notification';
 
 
 import FormattedDistanceDate from '../../FormattedDistanceDate';
 import FormattedDistanceDate from '../../FormattedDistanceDate';
@@ -13,21 +12,13 @@ type Props = {
   actionMsg: string
   actionMsg: string
   actionIcon: string
   actionIcon: string
   actionUsers: string
   actionUsers: string
-  publishOpen:() => void
-  ref: React.ForwardedRef<IInAppNotificationOpenable>
 };
 };
 
 
 export const ModelNotification: FC<Props> = (props) => {
 export const ModelNotification: FC<Props> = (props) => {
   const {
   const {
-    notification, actionMsg, actionIcon, actionUsers, publishOpen, ref,
+    notification, actionMsg, actionIcon, actionUsers,
   } = props;
   } = props;
 
 
-  useImperativeHandle(ref, () => ({
-    open() {
-      publishOpen();
-    },
-  }));
-
   return (
   return (
     <div className="p-2 overflow-hidden">
     <div className="p-2 overflow-hidden">
       <div className="text-truncate">
       <div className="text-truncate">

+ 34 - 25
apps/app/src/components/InAppNotification/PageNotification/PageModelNotification.tsx

@@ -1,29 +1,27 @@
 import React, {
 import React, {
-  forwardRef, ForwardRefRenderFunction, useCallback,
+  FC, useCallback,
 } from 'react';
 } from 'react';
 
 
 import type { IPage, HasObjectId } from '@growi/core';
 import type { IPage, HasObjectId } from '@growi/core';
 import { useRouter } from 'next/router';
 import { useRouter } from 'next/router';
 
 
-import type { IInAppNotificationOpenable } from '~/client/interfaces/in-app-notification-openable';
+import { SupportedTargetModel } from '~/interfaces/activity';
 import type { IInAppNotification } from '~/interfaces/in-app-notification';
 import type { IInAppNotification } from '~/interfaces/in-app-notification';
 import * as pageSerializers from '~/models/serializers/in-app-notification-snapshot/page';
 import * as pageSerializers from '~/models/serializers/in-app-notification-snapshot/page';
 
 
 import { ModelNotification } from './ModelNotification';
 import { ModelNotification } from './ModelNotification';
-import { useActionMsgAndIconForPageModelNotification } from './useActionAndMsg';
+import { useActionMsgAndIconForModelNotification } from './useActionAndMsg';
 
 
 
 
-interface Props {
-  notification: IInAppNotification<IPage> & HasObjectId
+export interface ModelNotificationUtils {
+  Notification: FC
+  publishOpen: () => void
 }
 }
 
 
-const PageModelNotification: ForwardRefRenderFunction<IInAppNotificationOpenable, Props> = (props: Props, ref) => {
-
-  const { notification } = props;
-
-  const { actionMsg, actionIcon } = useActionMsgAndIconForPageModelNotification(notification);
+export const usePageModelNotification = (notification: IInAppNotification & HasObjectId): ModelNotificationUtils | null => {
 
 
   const router = useRouter();
   const router = useRouter();
+  const { actionMsg, actionIcon } = useActionMsgAndIconForModelNotification(notification);
 
 
   const getActionUsers = useCallback(() => {
   const getActionUsers = useCallback(() => {
     const latestActionUsers = notification.actionUsers.slice(0, 3);
     const latestActionUsers = notification.actionUsers.slice(0, 3);
@@ -46,31 +44,42 @@ const PageModelNotification: ForwardRefRenderFunction<IInAppNotificationOpenable
     return actionedUsers;
     return actionedUsers;
   }, [notification.actionUsers]);
   }, [notification.actionUsers]);
 
 
+  const isPageModelNotification = (notification: IInAppNotification & HasObjectId): notification is IInAppNotification<IPage> & HasObjectId => {
+    return notification.targetModel === SupportedTargetModel.MODEL_PAGE;
+  };
+
+  if (!isPageModelNotification(notification)) {
+    return null;
+  }
+
   const actionUsers = getActionUsers();
   const actionUsers = getActionUsers();
 
 
-  // publish open()
+  notification.parsedSnapshot = pageSerializers.parseSnapshot(notification.snapshot);
+
+  const Notification = () => {
+    return (
+      <ModelNotification
+        notification={notification}
+        actionMsg={actionMsg}
+        actionIcon={actionIcon}
+        actionUsers={actionUsers}
+      />
+    );
+  };
+
   const publishOpen = () => {
   const publishOpen = () => {
     if (notification.target != null) {
     if (notification.target != null) {
       // jump to target page
       // jump to target page
-      const targetPagePath = notification.target.path;
+      const targetPagePath = (notification.target as IPage).path;
       if (targetPagePath != null) {
       if (targetPagePath != null) {
         router.push(targetPagePath);
         router.push(targetPagePath);
       }
       }
     }
     }
   };
   };
 
 
-  notification.parsedSnapshot = pageSerializers.parseSnapshot(notification.snapshot);
+  return {
+    Notification,
+    publishOpen,
+  };
 
 
-  return (
-    <ModelNotification
-      notification={notification}
-      actionMsg={actionMsg}
-      actionIcon={actionIcon}
-      actionUsers={actionUsers}
-      publishOpen={publishOpen}
-      ref={ref}
-    />
-  );
 };
 };
-
-export default forwardRef(PageModelNotification);

+ 30 - 26
apps/app/src/components/InAppNotification/PageNotification/UserModelNotification.tsx

@@ -1,45 +1,49 @@
-import React, {
-  forwardRef, ForwardRefRenderFunction,
-} from 'react';
+import React from 'react';
 
 
 import type { IUser, HasObjectId } from '@growi/core';
 import type { IUser, HasObjectId } from '@growi/core';
 import { useRouter } from 'next/router';
 import { useRouter } from 'next/router';
 
 
-import type { IInAppNotificationOpenable } from '~/client/interfaces/in-app-notification-openable';
+import { SupportedTargetModel } from '~/interfaces/activity';
 import type { IInAppNotification } from '~/interfaces/in-app-notification';
 import type { IInAppNotification } from '~/interfaces/in-app-notification';
 
 
 import { ModelNotification } from './ModelNotification';
 import { ModelNotification } from './ModelNotification';
-import { useActionMsgAndIconForUserModelNotification } from './useActionAndMsg';
+import { ModelNotificationUtils } from './PageModelNotification';
+import { useActionMsgAndIconForModelNotification } from './useActionAndMsg';
 
 
-interface Props {
-  notification: IInAppNotification<IUser> & HasObjectId
-}
 
 
-const UserModelNotification: ForwardRefRenderFunction<IInAppNotificationOpenable, Props> = (props: Props, ref) => {
+export const useUserModelNotification = (notification: IInAppNotification & HasObjectId): ModelNotificationUtils | null => {
 
 
-  const { notification } = props;
+  const { actionMsg, actionIcon } = useActionMsgAndIconForModelNotification(notification);
+  const router = useRouter();
 
 
-  const { actionMsg, actionIcon } = useActionMsgAndIconForUserModelNotification(notification);
+  const isUserModelNotification = (notification: IInAppNotification & HasObjectId): notification is IInAppNotification<IUser> & HasObjectId => {
+    return notification.targetModel === SupportedTargetModel.MODEL_USER;
+  };
 
 
-  const router = useRouter();
+  if (!isUserModelNotification(notification)) {
+    return null;
+  }
+
+  const actionUsers = notification.target.username;
+
+  const Notification = () => {
+    return (
+      <ModelNotification
+        notification={notification}
+        actionMsg={actionMsg}
+        actionIcon={actionIcon}
+        actionUsers={actionUsers}
+      />
+    );
+  };
 
 
-  // publish open()
   const publishOpen = () => {
   const publishOpen = () => {
     router.push('/admin/users');
     router.push('/admin/users');
   };
   };
 
 
-  const actionUsers = notification.target.username;
+  return {
+    Notification,
+    publishOpen,
+  };
 
 
-  return (
-    <ModelNotification
-      notification={notification}
-      actionMsg={actionMsg}
-      actionIcon={actionIcon}
-      actionUsers={actionUsers}
-      publishOpen={publishOpen}
-      ref={ref}
-    />
-  );
 };
 };
-
-export default forwardRef(UserModelNotification);

+ 19 - 0
apps/app/src/components/InAppNotification/PageNotification/index.tsx

@@ -0,0 +1,19 @@
+import type { HasObjectId } from '@growi/core';
+
+import type { IInAppNotification } from '~/interfaces/in-app-notification';
+
+
+import { usePageModelNotification, type ModelNotificationUtils } from './PageModelNotification';
+import { useUserModelNotification } from './UserModelNotification';
+
+
+export const useModelNotification = (notification: IInAppNotification & HasObjectId): ModelNotificationUtils | null => {
+
+  const pageModelNotificationUtils = usePageModelNotification(notification);
+  const userModelNotificationUtils = useUserModelNotification(notification);
+
+  const modelNotificationUtils = pageModelNotificationUtils ?? userModelNotificationUtils;
+
+
+  return modelNotificationUtils;
+};

+ 2 - 19
apps/app/src/components/InAppNotification/PageNotification/useActionAndMsg.ts

@@ -1,4 +1,4 @@
-import type { IUser, IPage, HasObjectId } from '@growi/core';
+import type { HasObjectId } from '@growi/core';
 
 
 import { SupportedAction } from '~/interfaces/activity';
 import { SupportedAction } from '~/interfaces/activity';
 import type { IInAppNotification } from '~/interfaces/in-app-notification';
 import type { IInAppNotification } from '~/interfaces/in-app-notification';
@@ -8,7 +8,7 @@ export type ActionMsgAndIconType = {
   actionIcon: string
   actionIcon: string
 }
 }
 
 
-export const useActionMsgAndIconForPageModelNotification = (notification: IInAppNotification<IPage> & HasObjectId): ActionMsgAndIconType => {
+export const useActionMsgAndIconForModelNotification = (notification: IInAppNotification & HasObjectId): ActionMsgAndIconType => {
   const actionType: string = notification.action;
   const actionType: string = notification.action;
   let actionMsg: string;
   let actionMsg: string;
   let actionIcon: string;
   let actionIcon: string;
@@ -66,23 +66,6 @@ export const useActionMsgAndIconForPageModelNotification = (notification: IInApp
       actionMsg = 'commented on';
       actionMsg = 'commented on';
       actionIcon = 'icon-bubble';
       actionIcon = 'icon-bubble';
       break;
       break;
-    default:
-      actionMsg = '';
-      actionIcon = '';
-  }
-
-  return {
-    actionMsg,
-    actionIcon,
-  };
-};
-
-export const useActionMsgAndIconForUserModelNotification = (notification: IInAppNotification<IUser> & HasObjectId): ActionMsgAndIconType => {
-  const actionType: string = notification.action;
-  let actionMsg: string;
-  let actionIcon: string;
-
-  switch (actionType) {
     case SupportedAction.ACTION_USER_REGISTRATION_APPROVAL_REQUEST:
     case SupportedAction.ACTION_USER_REGISTRATION_APPROVAL_REQUEST:
       actionMsg = 'requested registration approval';
       actionMsg = 'requested registration approval';
       actionIcon = 'icon-bubble';
       actionIcon = 'icon-bubble';

+ 6 - 95
apps/app/src/components/Me/OtherSettings.tsx

@@ -1,105 +1,16 @@
-import {
-  useState, useEffect, useCallback,
-} from 'react';
-
-import { useTranslation } from 'next-i18next';
-import { UncontrolledTooltip } from 'reactstrap';
-
-import { apiv3Put } from '~/client/util/apiv3-client';
-import { toastSuccess, toastError } from '~/client/util/toastr';
-import { useSWRxIsQuestionnaireEnabled } from '~/features/questionnaire/client/stores/questionnaire';
-import { useCurrentUser } from '~/stores/context';
+import { QuestionnaireSettings } from './QuestionnaireSettings';
+import { UISettings } from './UISettings';
 
 
 const OtherSettings = (): JSX.Element => {
 const OtherSettings = (): JSX.Element => {
-  const { t } = useTranslation();
-  const { data: currentUser, error: errorCurrentUser } = useCurrentUser();
-  const { data: growiIsQuestionnaireEnabled } = useSWRxIsQuestionnaireEnabled();
-
-  const [isQuestionnaireEnabled, setIsQuestionnaireEnabled] = useState(currentUser?.isQuestionnaireEnabled);
-
-  const onChangeIsQuestionnaireEnabledHandler = useCallback(async() => {
-    setIsQuestionnaireEnabled(prev => !prev);
-  }, []);
-
-  const onClickUpdateIsQuestionnaireEnabledHandler = useCallback(async() => {
-    try {
-      await apiv3Put('/personal-setting/questionnaire-settings', {
-        isQuestionnaireEnabled,
-      });
-      toastSuccess(t('toaster.update_successed', { target: t('questionnaire.settings'), ns: 'commons' }));
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }, [isQuestionnaireEnabled, t]);
-
-  // Sync currentUser and state
-  useEffect(() => {
-    setIsQuestionnaireEnabled(currentUser?.isQuestionnaireEnabled);
-  }, [currentUser?.isQuestionnaireEnabled]);
-
-  const isLoadingCurrentUser = currentUser === undefined && errorCurrentUser === undefined;
 
 
   return (
   return (
     <>
     <>
-      <h2 className="border-bottom my-4">{t('questionnaire.settings')}</h2>
-
-      {isLoadingCurrentUser && (
-        <div className="text-muted text-center mb-5">
-          <i className="fa fa-2x fa-spinner fa-pulse me-1" />
-        </div>
-      )}
-
-      <div className="row">
-        <div className="offset-md-3 col-md-6 text-start">
-          {!isLoadingCurrentUser && (
-            <div className="form-check form-switch form-check-primary">
-              <span id="grw-questionnaire-settings-toggle-wrapper">
-                <input
-                  type="checkbox"
-                  className="form-check-input"
-                  id="isQuestionnaireEnabled"
-                  checked={growiIsQuestionnaireEnabled && isQuestionnaireEnabled}
-                  onChange={onChangeIsQuestionnaireEnabledHandler}
-                  disabled={!growiIsQuestionnaireEnabled}
-                />
-                <label className="form-label form-check-label" htmlFor="isQuestionnaireEnabled">
-                  {t('questionnaire.enable_questionnaire')}
-                </label>
-              </span>
-              <p className="form-text text-muted small">
-                {t('questionnaire.personal_settings_explanation')}
-              </p>
-              {!growiIsQuestionnaireEnabled && (
-                <UncontrolledTooltip placement="bottom" target="grw-questionnaire-settings-toggle-wrapper">
-                  {t('questionnaire.disabled_by_admin')}
-                </UncontrolledTooltip>
-              ) }
-            </div>
-          )}
-        </div>
+      <div className="mt-4">
+        <QuestionnaireSettings />
       </div>
       </div>
 
 
-      <div className="row my-3">
-        <div className="offset-4 col-5">
-          <span className="d-inline-block" id="grw-questionnaire-settings-update-btn-wrapper">
-            <button
-              data-testid="grw-questionnaire-settings-update-btn"
-              type="button"
-              className="btn btn-primary"
-              onClick={onClickUpdateIsQuestionnaireEnabledHandler}
-              disabled={!growiIsQuestionnaireEnabled}
-              style={growiIsQuestionnaireEnabled ? {} : { pointerEvents: 'none' }}
-            >
-              {t('Update')}
-            </button>
-          </span>
-          {!growiIsQuestionnaireEnabled && (
-            <UncontrolledTooltip placement="bottom" target="grw-questionnaire-settings-update-btn-wrapper">
-              {t('questionnaire.disabled_by_admin')}
-            </UncontrolledTooltip>
-          )}
-        </div>
+      <div className="mt-4">
+        <UISettings />
       </div>
       </div>
     </>
     </>
   );
   );

+ 106 - 0
apps/app/src/components/Me/QuestionnaireSettings.tsx

@@ -0,0 +1,106 @@
+import { useCallback, useEffect, useState } from 'react';
+
+import { useTranslation } from 'react-i18next';
+import { UncontrolledTooltip } from 'reactstrap';
+
+import { apiv3Put } from '~/client/util/apiv3-client';
+import { toastError, toastSuccess } from '~/client/util/toastr';
+import { useSWRxIsQuestionnaireEnabled } from '~/features/questionnaire/client/stores/questionnaire';
+import { useCurrentUser } from '~/stores/context';
+
+
+export const QuestionnaireSettings = (): JSX.Element => {
+  const { t } = useTranslation();
+  const { data: currentUser, error: errorCurrentUser } = useCurrentUser();
+  const { data: growiIsQuestionnaireEnabled } = useSWRxIsQuestionnaireEnabled();
+
+  const [isQuestionnaireEnabled, setIsQuestionnaireEnabled] = useState(currentUser?.isQuestionnaireEnabled);
+
+  const onChangeIsQuestionnaireEnabledHandler = useCallback(async() => {
+    setIsQuestionnaireEnabled(prev => !prev);
+  }, []);
+
+  const onClickUpdateIsQuestionnaireEnabledHandler = useCallback(async() => {
+    try {
+      await apiv3Put('/personal-setting/questionnaire-settings', {
+        isQuestionnaireEnabled,
+      });
+      toastSuccess(t('toaster.update_successed', { target: t('questionnaire.settings'), ns: 'commons' }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [isQuestionnaireEnabled, t]);
+
+  // Sync currentUser and state
+  useEffect(() => {
+    setIsQuestionnaireEnabled(currentUser?.isQuestionnaireEnabled);
+  }, [currentUser?.isQuestionnaireEnabled]);
+
+  const isLoadingCurrentUser = currentUser === undefined && errorCurrentUser === undefined;
+
+  return (
+    <>
+      <h2 className="border-bottom mb-4">{t('questionnaire.settings')}</h2>
+
+      {isLoadingCurrentUser && (
+        <div className="text-muted text-center mb-5">
+          <i className="fa fa-2x fa-spinner fa-pulse me-1" />
+        </div>
+      )}
+
+      <div className="row">
+        <div className="offset-md-3 col-md-6 text-start">
+          {!isLoadingCurrentUser && (
+            <div className="form-check form-switch">
+              <span id="grw-questionnaire-settings-toggle-wrapper">
+                <input
+                  type="checkbox"
+                  className="form-check-input"
+                  id="isQuestionnaireEnabled"
+                  checked={growiIsQuestionnaireEnabled && isQuestionnaireEnabled}
+                  onChange={onChangeIsQuestionnaireEnabledHandler}
+                  disabled={!growiIsQuestionnaireEnabled}
+                />
+                <label className="form-label form-check-label" htmlFor="isQuestionnaireEnabled">
+                  {t('questionnaire.enable_questionnaire')}
+                </label>
+              </span>
+              <p className="form-text text-muted small">
+                {t('questionnaire.personal_settings_explanation')}
+              </p>
+              {!growiIsQuestionnaireEnabled && (
+                <UncontrolledTooltip placement="bottom" target="grw-questionnaire-settings-toggle-wrapper">
+                  {t('questionnaire.disabled_by_admin')}
+                </UncontrolledTooltip>
+              ) }
+            </div>
+          )}
+        </div>
+      </div>
+
+      <div className="row my-3">
+        <div className="offset-4 col-5">
+          <span className="d-inline-block" id="grw-questionnaire-settings-update-btn-wrapper">
+            <button
+              data-testid="grw-questionnaire-settings-update-btn"
+              type="button"
+              className="btn btn-primary"
+              onClick={onClickUpdateIsQuestionnaireEnabledHandler}
+              disabled={!growiIsQuestionnaireEnabled}
+              style={growiIsQuestionnaireEnabled ? {} : { pointerEvents: 'none' }}
+            >
+              {t('Update')}
+            </button>
+          </span>
+          {!growiIsQuestionnaireEnabled && (
+            <UncontrolledTooltip placement="bottom" target="grw-questionnaire-settings-update-btn-wrapper">
+              {t('questionnaire.disabled_by_admin')}
+            </UncontrolledTooltip>
+          )}
+        </div>
+      </div>
+    </>
+
+  );
+};

+ 2 - 2
apps/app/src/components/Icons/SidebarDrawerIcon.jsx → apps/app/src/components/Me/SidebarCollapsedIcon.jsx

@@ -1,6 +1,6 @@
 import React from 'react';
 import React from 'react';
 
 
-const SidebarDrawerIcon = () => (
+const SidebarCollapsedIcon = () => (
   <svg
   <svg
     xmlns="http://www.w3.org/2000/svg"
     xmlns="http://www.w3.org/2000/svg"
     viewBox="0 0 23 23"
     viewBox="0 0 23 23"
@@ -22,4 +22,4 @@ const SidebarDrawerIcon = () => (
   </svg>
   </svg>
 );
 );
 
 
-export default SidebarDrawerIcon;
+export default SidebarCollapsedIcon;

+ 0 - 0
apps/app/src/components/Icons/SidebarDockIcon.jsx → apps/app/src/components/Me/SidebarDockIcon.jsx


+ 7 - 0
apps/app/src/components/Me/UISettings.module.scss

@@ -0,0 +1,7 @@
+@use '@growi/core/scss/bootstrap/init' as bs;
+
+.grw-sidebar-mode-icon {
+  width: 20px;
+  height: 20px;
+  color: bs.$secondary;
+}

+ 114 - 0
apps/app/src/components/Me/UISettings.tsx

@@ -0,0 +1,114 @@
+import { useCallback } from 'react';
+
+import { useTranslation } from 'react-i18next';
+import { UncontrolledTooltip } from 'reactstrap';
+
+import { updateUserUISettings } from '~/client/services/user-ui-settings';
+import { toastError, toastSuccess } from '~/client/util/toastr';
+import { useCollapsedContentsOpened, usePreferCollapsedMode, useSidebarMode } from '~/stores/ui';
+
+import SidebarCollapsedIcon from './SidebarCollapsedIcon';
+import SidebarDockIcon from './SidebarDockIcon';
+
+import styles from './UISettings.module.scss';
+
+const IconWithTooltip = ({
+  id, label, children, additionalClasses,
+}: {
+id: string,
+label: string,
+children: JSX.Element,
+additionalClasses: string
+}) => (
+  <>
+    <div id={id} className={`${additionalClasses != null ? additionalClasses : ''}`}>{children}</div>
+    <UncontrolledTooltip placement="bottom" fade={false} target={id}>{label}</UncontrolledTooltip>
+  </>
+);
+
+export const UISettings = (): JSX.Element => {
+  const { t } = useTranslation();
+  const {
+    isDockMode, isCollapsedMode,
+  } = useSidebarMode();
+  const { mutate: mutatePreferCollapsedMode } = usePreferCollapsedMode();
+  const { mutate: mutateCollapsedContentsOpened } = useCollapsedContentsOpened();
+
+  const toggleCollapsed = useCallback(() => {
+    mutatePreferCollapsedMode(!isCollapsedMode());
+    mutateCollapsedContentsOpened(false);
+  }, [mutatePreferCollapsedMode, isCollapsedMode, mutateCollapsedContentsOpened]);
+
+  const updateButtonHandler = useCallback(async() => {
+    try {
+      await updateUserUISettings({ preferCollapsedModeByUser: isCollapsedMode() });
+      toastSuccess(t('toaster.update_successed', { target: t('ui_settings.side_bar_mode.settings'), ns: 'commons' }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+
+  }, [isCollapsedMode, t]);
+
+  const renderSidebarModeSwitch = () => {
+    return (
+      <>
+        <div className="d-flex align-items-start">
+          <div className="d-flex align-items-center">
+            <IconWithTooltip
+              id="iwt-sidebar-collapsed"
+              label="Collapsed"
+              additionalClasses={styles['grw-sidebar-mode-icon']}
+            >
+              <SidebarCollapsedIcon />
+            </IconWithTooltip>
+            <div className="form-check form-switch ms-2">
+
+              <input
+                id="swSidebarMode"
+                className="form-check-input"
+                type="checkbox"
+                checked={isDockMode()}
+                onChange={toggleCollapsed}
+              />
+              <label className="form-label form-check-label" htmlFor="swSidebarMode"></label>
+            </div>
+            <IconWithTooltip id="iwt-sidebar-dock" label="Dock" additionalClasses={styles['grw-sidebar-mode-icon']}>
+              <SidebarDockIcon />
+            </IconWithTooltip>
+          </div>
+          <div className="ms-2">
+            <label className="form-label form-check-label" htmlFor="swSidebarMode">
+              {t('ui_settings.side_bar_mode.side_bar_mode_setting')}
+            </label>
+            <p className="form-text text-muted small">{t('ui_settings.side_bar_mode.description')}</p>
+          </div>
+        </div>
+      </>
+    );
+  };
+
+  return (
+    <>
+      <h2 className="border-bottom mb-4">{t('ui_settings.ui_settings')}</h2>
+
+      <div className="row justify-content-center">
+        <div className="col-md-6">
+
+          { renderSidebarModeSwitch() }
+
+          <div>
+          </div>
+        </div>
+      </div>
+
+      <div className="row my-3">
+        <div className="offset-4 col-5">
+          <button data-testid="" type="button" className="btn btn-primary" onClick={updateButtonHandler}>
+            {t('Update')}
+          </button>
+        </div>
+      </div>
+    </>
+  );
+};

+ 6 - 6
apps/app/src/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -11,11 +11,12 @@ import dynamic from 'next/dynamic';
 import { useRouter } from 'next/router';
 import { useRouter } from 'next/router';
 import { DropdownItem } from 'reactstrap';
 import { DropdownItem } from 'reactstrap';
 
 
+import { useShouldExpandContent } from '~/client/services/layout';
 import { exportAsMarkdown, updateContentWidth } from '~/client/services/page-operation';
 import { exportAsMarkdown, updateContentWidth } from '~/client/services/page-operation';
 import type { OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction } from '~/interfaces/ui';
 import type { OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction } from '~/interfaces/ui';
 import {
 import {
   useCurrentPathname,
   useCurrentPathname,
-  useCurrentUser, useIsGuestUser, useIsReadOnlyUser, useIsSharedUser, useShareLinkId, useIsContainerFluid,
+  useCurrentUser, useIsGuestUser, useIsReadOnlyUser, useIsSharedUser, useShareLinkId,
 } from '~/stores/context';
 } from '~/stores/context';
 import {
 import {
   usePageAccessoriesModal, PageAccessoriesModalContents, type IPageForPageDuplicateModal,
   usePageAccessoriesModal, PageAccessoriesModalContents, type IPageForPageDuplicateModal,
@@ -28,7 +29,6 @@ import { mutatePageTree } from '~/stores/page-listing';
 import {
 import {
   useEditorMode, useIsAbleToShowPageManagement,
   useEditorMode, useIsAbleToShowPageManagement,
   useIsAbleToChangeEditorMode,
   useIsAbleToChangeEditorMode,
-  useSelectedGrant,
 } from '~/stores/ui';
 } from '~/stores/ui';
 
 
 import { CreateTemplateModal } from '../CreateTemplateModal';
 import { CreateTemplateModal } from '../CreateTemplateModal';
@@ -196,8 +196,8 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isReadOnlyUser } = useIsReadOnlyUser();
   const { data: isReadOnlyUser } = useIsReadOnlyUser();
   const { data: isSharedUser } = useIsSharedUser();
   const { data: isSharedUser } = useIsSharedUser();
-  const { data: isContainerFluid } = useIsContainerFluid();
-  const { data: grantData } = useSelectedGrant();
+
+  const shouldExpandContent = useShouldExpandContent(currentPage);
 
 
   const { data: isAbleToShowPageManagement } = useIsAbleToShowPageManagement();
   const { data: isAbleToShowPageManagement } = useIsAbleToShowPageManagement();
   const { data: isAbleToChangeEditorMode } = useIsAbleToChangeEditorMode();
   const { data: isAbleToChangeEditorMode } = useIsAbleToChangeEditorMode();
@@ -299,7 +299,7 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
     <>
     <>
       <div
       <div
         className={`${styles['grw-contextual-sub-navigation']}
         className={`${styles['grw-contextual-sub-navigation']}
-          d-flex align-items-center justify-content-end px-2 py-1 gap-2 gap-md-4 d-print-none
+          d-flex align-items-center justify-content-end px-2 px-sm-3 px-md-4 py-1 gap-2 gap-md-4 d-print-none
         `}
         `}
         data-testid="grw-contextual-sub-nav"
         data-testid="grw-contextual-sub-nav"
       >
       >
@@ -309,7 +309,7 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
             revisionId={revisionId}
             revisionId={revisionId}
             shareLinkId={shareLinkId}
             shareLinkId={shareLinkId}
             path={path ?? currentPathname} // If the page is empty, "path" is undefined
             path={path ?? currentPathname} // If the page is empty, "path" is undefined
-            expandContentWidth={currentPage?.expandContentWidth ?? isContainerFluid}
+            expandContentWidth={shouldExpandContent}
             disableSeenUserInfoPopover={isSharedUser}
             disableSeenUserInfoPopover={isSharedUser}
             showPageControlDropdown={isAbleToShowPageManagement}
             showPageControlDropdown={isAbleToShowPageManagement}
             additionalMenuItemRenderer={additionalMenuItemsRenderer}
             additionalMenuItemRenderer={additionalMenuItemsRenderer}

+ 2 - 47
apps/app/src/components/Navbar/hooks.tsx

@@ -1,11 +1,10 @@
-import { useCallback, useState } from 'react';
+import { useCallback } from 'react';
 
 
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import { useRouter } from 'next/router';
 import { useRouter } from 'next/router';
 
 
-import { createPage, exist } from '~/client/services/page-operation';
+import { createPage } from '~/client/services/page-operation';
 import { toastError } from '~/client/util/toastr';
 import { toastError } from '~/client/util/toastr';
-import { LabelType } from '~/interfaces/template';
 import { useIsNotFound } from '~/stores/page';
 import { useIsNotFound } from '~/stores/page';
 import { EditorMode, useEditorMode } from '~/stores/ui';
 import { EditorMode, useEditorMode } from '~/stores/ui';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
@@ -57,47 +56,3 @@ export const useOnPageEditorModeButtonClicked = (
     mutateEditorMode(editorMode);
     mutateEditorMode(editorMode);
   }, [isNotFound, mutateEditorMode, path, router, setIsCreating, t]);
   }, [isNotFound, mutateEditorMode, path, router, setIsCreating, t]);
 };
 };
-
-export const useOnTemplateButtonClicked = (
-    currentPagePath: string,
-): {
-  onClickHandler: (label: LabelType) => Promise<void>,
-  isPageCreating: boolean
-} => {
-  const router = useRouter();
-  const [isPageCreating, setIsPageCreating] = useState(false);
-
-  const onClickHandler = useCallback(async(label: LabelType) => {
-    try {
-      setIsPageCreating(true);
-
-      const path = currentPagePath == null || currentPagePath === '/'
-        ? `/${label}`
-        : `${currentPagePath}/${label}`;
-
-      const params = {
-        isSlackEnabled: false,
-        slackChannels: '',
-        grant: 4,
-        // grant: currentPage?.grant || 1,
-        // grantUserGroupId: currentPage?.grantedGroup?._id,
-      };
-
-      const res = await exist(JSON.stringify([path]));
-      if (!res.pages[path]) {
-        await createPage(path, '', params);
-      }
-
-      router.push(`${path}#edit`);
-    }
-    catch (err) {
-      logger.warn(err);
-      toastError(err);
-    }
-    finally {
-      setIsPageCreating(false);
-    }
-  }, [currentPagePath, router]);
-
-  return { onClickHandler, isPageCreating };
-};

+ 4 - 0
apps/app/src/components/Page/PageView.tsx

@@ -6,6 +6,7 @@ import type { IPagePopulatedToShowRevision } from '@growi/core';
 import { isUsersHomepage } from '@growi/core/dist/utils/page-path-utils';
 import { isUsersHomepage } from '@growi/core/dist/utils/page-path-utils';
 import dynamic from 'next/dynamic';
 import dynamic from 'next/dynamic';
 
 
+import { useShouldExpandContent } from '~/client/services/layout';
 import type { RendererConfig } from '~/interfaces/services/renderer';
 import type { RendererConfig } from '~/interfaces/services/renderer';
 import { generateSSRViewOptions } from '~/services/renderer/renderer';
 import { generateSSRViewOptions } from '~/services/renderer/renderer';
 import {
 import {
@@ -70,6 +71,8 @@ export const PageView = (props: Props): JSX.Element => {
   const isNotFound = isNotFoundMeta || page?.revision == null;
   const isNotFound = isNotFoundMeta || page?.revision == null;
   const isUsersHomepagePath = isUsersHomepage(pagePath);
   const isUsersHomepagePath = isUsersHomepage(pagePath);
 
 
+  const shouldExpandContent = useShouldExpandContent(page);
+
 
 
   // ***************************  Auto Scroll  ***************************
   // ***************************  Auto Scroll  ***************************
   useEffect(() => {
   useEffect(() => {
@@ -157,6 +160,7 @@ export const PageView = (props: Props): JSX.Element => {
       headerContents={headerContents}
       headerContents={headerContents}
       sideContents={sideContents}
       sideContents={sideContents}
       footerContents={footerContents}
       footerContents={footerContents}
+      expandContentWidth={shouldExpandContent}
     >
     >
       <PageAlerts />
       <PageAlerts />
 
 

+ 9 - 0
apps/app/src/components/PageControls/PageControls.tsx

@@ -16,6 +16,7 @@ import { toastError } from '~/client/util/toastr';
 import { useIsGuestUser, useIsReadOnlyUser } from '~/stores/context';
 import { useIsGuestUser, useIsReadOnlyUser } from '~/stores/context';
 import { useTagEditModal, type IPageForPageDuplicateModal } from '~/stores/modal';
 import { useTagEditModal, type IPageForPageDuplicateModal } from '~/stores/modal';
 import { EditorMode, useEditorMode } from '~/stores/ui';
 import { EditorMode, useEditorMode } from '~/stores/ui';
+import loggerFactory from '~/utils/logger';
 
 
 import { useSWRxPageInfo, useSWRxTagsInfo } from '../../stores/page';
 import { useSWRxPageInfo, useSWRxTagsInfo } from '../../stores/page';
 import { useSWRxUsersList } from '../../stores/user';
 import { useSWRxUsersList } from '../../stores/user';
@@ -33,6 +34,9 @@ import SubscribeButton from './SubscribeButton';
 
 
 import styles from './PageControls.module.scss';
 import styles from './PageControls.module.scss';
 
 
+const logger = loggerFactory('growi:components/PageControls');
+
+
 type TagsProps = {
 type TagsProps = {
   onClickEditTagsButton: () => void,
   onClickEditTagsButton: () => void,
 }
 }
@@ -200,6 +204,11 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
 
 
   const switchContentWidthClickHandler = useCallback(async(newValue: boolean) => {
   const switchContentWidthClickHandler = useCallback(async(newValue: boolean) => {
     if (onClickSwitchContentWidth == null || (isGuestUser ?? true) || (isReadOnlyUser ?? true)) {
     if (onClickSwitchContentWidth == null || (isGuestUser ?? true) || (isReadOnlyUser ?? true)) {
+      logger.warn('Could not switch content width', {
+        onClickSwitchContentWidth: onClickSwitchContentWidth == null ? 'null' : 'not null',
+        isGuestUser,
+        isReadOnlyUser,
+      });
       return;
       return;
     }
     }
     if (!isIPageInfoForEntity(pageInfo)) {
     if (!isIPageInfoForEntity(pageInfo)) {

+ 13 - 6
apps/app/src/components/PageEditor/HandsontableModal.tsx

@@ -1,5 +1,6 @@
 import React, { useState } from 'react';
 import React, { useState } from 'react';
 
 
+import { useHandsontableModalForEditor } from '@growi/editor/src/stores/use-handsontable';
 import { HotTable } from '@handsontable/react';
 import { HotTable } from '@handsontable/react';
 import Handsontable from 'handsontable';
 import Handsontable from 'handsontable';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
@@ -10,7 +11,7 @@ import {
 import { debounce } from 'throttle-debounce';
 import { debounce } from 'throttle-debounce';
 
 
 import MarkdownTable from '~/client/models/MarkdownTable';
 import MarkdownTable from '~/client/models/MarkdownTable';
-import mtu from '~/components/PageEditor/MarkdownTableUtil';
+import { replaceFocusedMarkdownTableWithEditor, getMarkdownTable } from '~/components/PageEditor/markdown-table-util-for-editor';
 import { useHandsontableModal } from '~/stores/modal';
 import { useHandsontableModal } from '~/stores/modal';
 
 
 import ExpandOrContractButton from '../ExpandOrContractButton';
 import ExpandOrContractButton from '../ExpandOrContractButton';
@@ -32,11 +33,13 @@ export const HandsontableModal = (): JSX.Element => {
 
 
   const { t } = useTranslation('commons');
   const { t } = useTranslation('commons');
   const { data: handsontableModalData, close: closeHandsontableModal } = useHandsontableModal();
   const { data: handsontableModalData, close: closeHandsontableModal } = useHandsontableModal();
+  const { data: handsontableModalForEditorData } = useHandsontableModalForEditor();
 
 
   const isOpened = handsontableModalData?.isOpened ?? false;
   const isOpened = handsontableModalData?.isOpened ?? false;
+  const isOpendInEditor = handsontableModalForEditorData?.isOpened ?? false;
   const table = handsontableModalData?.table;
   const table = handsontableModalData?.table;
   const autoFormatMarkdownTable = handsontableModalData?.autoFormatMarkdownTable ?? false;
   const autoFormatMarkdownTable = handsontableModalData?.autoFormatMarkdownTable ?? false;
-  const editor = handsontableModalData?.editor;
+  const editor = handsontableModalForEditorData?.editor;
   const onSave = handsontableModalData?.onSave;
   const onSave = handsontableModalData?.onSave;
 
 
   const defaultMarkdownTable = () => {
   const defaultMarkdownTable = () => {
@@ -102,8 +105,9 @@ export const HandsontableModal = (): JSX.Element => {
   const debouncedHandleWindowExpandedChange = debounce(100, handleWindowExpandedChange);
   const debouncedHandleWindowExpandedChange = debounce(100, handleWindowExpandedChange);
 
 
   const handleModalOpen = () => {
   const handleModalOpen = () => {
-    const initTableInstance = table == null ? defaultMarkdownTable : table.clone();
-    setMarkdownTable(table ?? defaultMarkdownTable);
+    const markdownTableState = table == null && editor != null ? getMarkdownTable(editor) : table;
+    const initTableInstance = markdownTableState == null ? defaultMarkdownTable : markdownTableState.clone();
+    setMarkdownTable(markdownTableState ?? defaultMarkdownTable);
     setMarkdownTableOnInit(initTableInstance);
     setMarkdownTableOnInit(initTableInstance);
     debouncedHandleWindowExpandedChange();
     debouncedHandleWindowExpandedChange();
   };
   };
@@ -163,7 +167,10 @@ export const HandsontableModal = (): JSX.Element => {
       return;
       return;
     }
     }
 
 
-    mtu.replaceFocusedMarkdownTableWithEditor(editor, newMarkdownTable);
+    if (editor == null) {
+      return;
+    }
+    replaceFocusedMarkdownTableWithEditor(editor, newMarkdownTable);
     cancel();
     cancel();
   };
   };
 
 
@@ -441,7 +448,7 @@ export const HandsontableModal = (): JSX.Element => {
 
 
   return (
   return (
     <Modal
     <Modal
-      isOpen={isOpened}
+      isOpen={isOpened || isOpendInEditor}
       toggle={cancel}
       toggle={cancel}
       backdrop="static"
       backdrop="static"
       keyboard={false}
       keyboard={false}

+ 1 - 1
apps/app/src/components/PageEditor/MarkdownTableDataImportForm.tsx

@@ -56,7 +56,7 @@ export const MarkdownTableDataImportForm = (props: MarkdownTableDataImportFormPr
         <label htmlFor="data-import-form-type-select" className="form-label">{t('select_data_format')}</label>
         <label htmlFor="data-import-form-type-select" className="form-label">{t('select_data_format')}</label>
         <select
         <select
           id="data-import-form-type-select"
           id="data-import-form-type-select"
-          className="form-control"
+          className="form-select"
           placeholder="select"
           placeholder="select"
           value={dataFormat}
           value={dataFormat}
           onChange={(e) => { return setDataFormat(e.target.value) }}
           onChange={(e) => { return setDataFormat(e.target.value) }}

+ 0 - 190
apps/app/src/components/PageEditor/MarkdownTableUtil.js

@@ -1,190 +0,0 @@
-import MarkdownTable from '~/client/models/MarkdownTable';
-
-/**
- * Utility for markdown table
- */
-class MarkdownTableUtil {
-
-  constructor() {
-    // https://github.com/markdown-it/markdown-it/blob/d29f421927e93e88daf75f22089a3e732e195bd2/lib/rules_block/table.js#L83
-    this.tableAlignmentLineRE = /^[-:|][-:|\s]*$/;
-    this.tableAlignmentLineNegRE = /^[^-:]*$/; // it is need to check to ignore empty row which is matched above RE
-    // https://regex101.com/r/7BN2fR/10
-    this.linePartOfTableRE = /^([^\r\n|]*)\|(([^\r\n|]*\|)+)$/;
-    // https://regex101.com/r/1UuWBJ/3
-    this.emptyLineOfTableRE = /^([^\r\n|]*)\|((\s*\|)+)$/;
-
-    this.getEot = this.getEot.bind(this);
-    this.getStrFromBot = this.getStrFromBot.bind(this);
-    this.getStrToEot = this.getStrToEot.bind(this);
-    this.isInTable = this.isInTable.bind(this);
-    this.replaceFocusedMarkdownTableWithEditor = this.replaceFocusedMarkdownTableWithEditor.bind(this);
-    this.replaceMarkdownTableWithReformed = this.replaceFocusedMarkdownTableWithEditor; // alias
-  }
-
-  /**
-   * return the postion of the BOT(beginning of table)
-   * (If the cursor is not in a table, return its position)
-   */
-  getBot(editor) {
-    const curPos = editor.getCursor();
-    if (!this.isInTable(editor)) {
-      return { line: curPos.line, ch: curPos.ch };
-    }
-
-    const firstLine = editor.getDoc().firstLine();
-    let line = curPos.line - 1;
-    for (; line >= firstLine; line--) {
-      const strLine = editor.getDoc().getLine(line);
-      if (!this.linePartOfTableRE.test(strLine)) {
-        break;
-      }
-    }
-    const botLine = Math.max(firstLine, line + 1);
-    return { line: botLine, ch: 0 };
-  }
-
-  /**
-   * return the postion of the EOT(end of table)
-   * (If the cursor is not in a table, return its position)
-   */
-  getEot(editor) {
-    const curPos = editor.getCursor();
-    if (!this.isInTable(editor)) {
-      return { line: curPos.line, ch: curPos.ch };
-    }
-
-    const lastLine = editor.getDoc().lastLine();
-    let line = curPos.line + 1;
-    for (; line <= lastLine; line++) {
-      const strLine = editor.getDoc().getLine(line);
-      if (!this.linePartOfTableRE.test(strLine)) {
-        break;
-      }
-    }
-    const eotLine = Math.min(line - 1, lastLine);
-    const lineLength = editor.getDoc().getLine(eotLine).length;
-    return { line: eotLine, ch: lineLength };
-  }
-
-  /**
-   * return strings from BOT(beginning of table) to the cursor position
-   */
-  getStrFromBot(editor) {
-    const curPos = editor.getCursor();
-    return editor.getDoc().getRange(this.getBot(editor), curPos);
-  }
-
-  /**
-   * return strings from the cursor position to EOT(end of table)
-   */
-  getStrToEot(editor) {
-    const curPos = editor.getCursor();
-    return editor.getDoc().getRange(curPos, this.getEot(editor));
-  }
-
-  /**
-   * return MarkdownTable instance of the table where the cursor is
-   * (If the cursor is not in a table, return null)
-   */
-  getMarkdownTable(editor) {
-    if (!this.isInTable(editor)) {
-      return null;
-    }
-
-    const strFromBotToEot = editor.getDoc().getRange(this.getBot(editor), this.getEot(editor));
-    return MarkdownTable.fromMarkdownString(strFromBotToEot);
-  }
-
-  getMarkdownTableFromLine(markdown, bol, eol) {
-    const tableLines = markdown.split(/\r\n|\r|\n/).slice(bol - 1, eol).join('\n');
-    return MarkdownTable.fromMarkdownString(tableLines);
-  }
-
-  /**
-   * return boolean value whether the cursor position is end of line
-   */
-  isEndOfLine(editor) {
-    const curPos = editor.getCursor();
-    return (curPos.ch === editor.getDoc().getLine(curPos.line).length);
-  }
-
-  /**
-   * return boolean value whether the cursor position is in a table
-   */
-  isInTable(editor) {
-    const curPos = editor.getCursor();
-    return this.linePartOfTableRE.test(editor.getDoc().getLine(curPos.line));
-  }
-
-  /**
-   * add a row at the end
-   * (This function overwrite directory markdown table specified as argument.)
-   * @param {MarkdownTable} markdown table
-   */
-  addRowToMarkdownTable(mdtable) {
-    const numCol = mdtable.table.length > 0 ? mdtable.table[0].length : 1;
-    const newRow = [];
-    (new Array(numCol)).forEach(() => { return newRow.push('') }); // create cols
-    mdtable.table.push(newRow);
-  }
-
-  /**
-   * return markdown table that is merged all of markdown table in array
-   * (The merged markdown table options are used for the first markdown table.)
-   * @param {Array} array of markdown table
-   */
-  mergeMarkdownTable(mdtableList) {
-    if (mdtableList == null || !(mdtableList instanceof Array)) {
-      return undefined;
-    }
-
-    let newTable = [];
-    const options = mdtableList[0].options; // use option of first markdown-table
-    mdtableList.forEach((mdtable) => {
-      newTable = newTable.concat(mdtable.table);
-    });
-    return (new MarkdownTable(newTable, options));
-  }
-
-  /**
-   * replace focused markdown table with editor
-   * (A replaced table is reformed by markdown-table.)
-   * @param {MarkdownTable} table
-   */
-  replaceFocusedMarkdownTableWithEditor(editor, table) {
-    const curPos = editor.getCursor();
-    editor.getDoc().replaceRange(table.toString(), this.getBot(editor), this.getEot(editor));
-    editor.getDoc().setCursor(curPos.line + 1, 2);
-  }
-
-  /**
-   * return markdown where the markdown table specified by line number params is replaced to the markdown table specified by table param
-   * @param {string} markdown
-   * @param {MarkdownTable} table
-   * @param beginLineNumber
-   * @param endLineNumber
-   */
-  replaceMarkdownTableInMarkdown(table, markdown, beginLineNumber, endLineNumber) {
-    const splitMarkdown = markdown.split(/\r\n|\r|\n/);
-    const markdownBeforeTable = splitMarkdown.slice(0, beginLineNumber - 1);
-    const markdownAfterTable = splitMarkdown.slice(endLineNumber);
-
-    let newMarkdown = '';
-    if (markdownBeforeTable.length > 0) {
-      newMarkdown += `${markdownBeforeTable.join('\n')}\n`;
-    }
-    newMarkdown += table;
-    if (markdownAfterTable.length > 0) {
-      newMarkdown += `\n${markdownAfterTable.join('\n')}`;
-    }
-
-    return newMarkdown;
-  }
-
-}
-
-// singleton pattern
-const instance = new MarkdownTableUtil();
-Object.freeze(instance);
-export default instance;

+ 11 - 22
apps/app/src/components/PageEditor/PageEditor.tsx

@@ -16,8 +16,9 @@ import { useTranslation } from 'next-i18next';
 import { useRouter } from 'next/router';
 import { useRouter } from 'next/router';
 import { throttle, debounce } from 'throttle-debounce';
 import { throttle, debounce } from 'throttle-debounce';
 
 
+import { useShouldExpandContent } from '~/client/services/layout';
 import { useUpdateStateAfterSave, useSaveOrUpdate } from '~/client/services/page-operation';
 import { useUpdateStateAfterSave, useSaveOrUpdate } from '~/client/services/page-operation';
-import { apiGet, apiPostForm } from '~/client/util/apiv1-client';
+import { apiv3Get, apiv3PostForm } from '~/client/util/apiv3-client';
 import { toastError, toastSuccess } from '~/client/util/toastr';
 import { toastError, toastSuccess } from '~/client/util/toastr';
 import { OptionsToSave } from '~/interfaces/page-operation';
 import { OptionsToSave } from '~/interfaces/page-operation';
 import { SocketEventName } from '~/interfaces/websocket';
 import { SocketEventName } from '~/interfaces/websocket';
@@ -57,11 +58,11 @@ import loggerFactory from '~/utils/logger';
 // import { ConflictDiffModal } from './PageEditor/ConflictDiffModal';
 // import { ConflictDiffModal } from './PageEditor/ConflictDiffModal';
 // import { ConflictDiffModal } from './ConflictDiffModal';
 // import { ConflictDiffModal } from './ConflictDiffModal';
 // import Editor from './Editor';
 // import Editor from './Editor';
+import EditorNavbarBottom from './EditorNavbarBottom';
 import Preview from './Preview';
 import Preview from './Preview';
 import scrollSyncHelper from './ScrollSyncHelper';
 import scrollSyncHelper from './ScrollSyncHelper';
 
 
 import '@growi/editor/dist/style.css';
 import '@growi/editor/dist/style.css';
-import EditorNavbarBottom from './EditorNavbarBottom';
 
 
 
 
 const logger = loggerFactory('growi:PageEditor');
 const logger = loggerFactory('growi:PageEditor');
@@ -127,6 +128,8 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
 
 
   const { mutate: mutateResolvedTheme } = useResolvedThemeForEditor();
   const { mutate: mutateResolvedTheme } = useResolvedThemeForEditor();
 
 
+  const shouldExpandContent = useShouldExpandContent(currentPage);
+
   const saveOrUpdate = useSaveOrUpdate();
   const saveOrUpdate = useSaveOrUpdate();
   const updateStateAfterSave = useUpdateStateAfterSave(pageId, { supressEditingMarkdownMutation: true });
   const updateStateAfterSave = useUpdateStateAfterSave(pageId, { supressEditingMarkdownMutation: true });
 
 
@@ -309,10 +312,7 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
   const uploadHandler = useCallback((files: File[]) => {
   const uploadHandler = useCallback((files: File[]) => {
     files.forEach(async(file) => {
     files.forEach(async(file) => {
       try {
       try {
-        // eslint-disable-next-line @typescript-eslint/no-explicit-any
-        const resLimit: any = await apiGet('/attachments.limit', {
-          fileSize: file.size,
-        });
+        const { data: resLimit } = await apiv3Get('/attachment/limit', { fileSize: file.size });
 
 
         if (!resLimit.isUploadable) {
         if (!resLimit.isUploadable) {
           throw new Error(resLimit.errorMessage);
           throw new Error(resLimit.errorMessage);
@@ -320,17 +320,12 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
 
 
         const formData = new FormData();
         const formData = new FormData();
         formData.append('file', file);
         formData.append('file', file);
-        if (currentPagePath != null) {
-          formData.append('path', currentPagePath);
-        }
         if (pageId != null) {
         if (pageId != null) {
           formData.append('page_id', pageId);
           formData.append('page_id', pageId);
         }
         }
-        if (pageId == null) {
-          formData.append('page_body', codeMirrorEditor?.getDoc() ?? '');
-        }
 
 
-        const resAdd: any = await apiPostForm('/attachments.add', formData);
+        const { data: resAdd } = await apiv3PostForm('/attachment', formData);
+
         const attachment = resAdd.attachment;
         const attachment = resAdd.attachment;
         const fileName = attachment.originalName;
         const fileName = attachment.originalName;
 
 
@@ -340,23 +335,16 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
           // modify to "![fileName](url)" syntax
           // modify to "![fileName](url)" syntax
           insertText = `!${insertText}`;
           insertText = `!${insertText}`;
         }
         }
-        // TODO: implement
-        // refs: https://redmine.weseek.co.jp/issues/126528
-        // editorRef.current.insertText(insertText);
+
         codeMirrorEditor?.insertText(insertText);
         codeMirrorEditor?.insertText(insertText);
       }
       }
       catch (e) {
       catch (e) {
         logger.error('failed to upload', e);
         logger.error('failed to upload', e);
         toastError(e);
         toastError(e);
       }
       }
-      finally {
-        // TODO: implement
-        // refs: https://redmine.weseek.co.jp/issues/126528
-        // editorRef.current.terminateUploadingState();
-      }
     });
     });
 
 
-  }, [codeMirrorEditor, currentPagePath, pageId]);
+  }, [codeMirrorEditor, pageId]);
 
 
   const acceptedFileType = useMemo(() => {
   const acceptedFileType = useMemo(() => {
     if (!isUploadEnabled) {
     if (!isUploadEnabled) {
@@ -597,6 +585,7 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
             rendererOptions={rendererOptions}
             rendererOptions={rendererOptions}
             markdown={markdownToPreview}
             markdown={markdownToPreview}
             pagePath={currentPagePath}
             pagePath={currentPagePath}
+            expandContentWidth={shouldExpandContent}
             // TODO: implement
             // TODO: implement
             // refs: https://redmine.weseek.co.jp/issues/126519
             // refs: https://redmine.weseek.co.jp/issues/126519
             // onScroll={offset => scrollEditorByPreviewScrollWithThrottle(offset)}
             // onScroll={offset => scrollEditorByPreviewScrollWithThrottle(offset)}

+ 9 - 20
apps/app/src/components/PageEditor/Preview.module.scss

@@ -1,29 +1,18 @@
-@use '~/styles/variables' as var;
 @use '~/styles/mixins';
 @use '~/styles/mixins';
 
 
-@include mixins.editing(true) {
-  .page-editor-preview-body :global {
+.page-editor-preview-body :global {
+  .wiki {
+    max-width: 980px;
+    margin: 0 auto;
   }
   }
 }
 }
 
 
 // modify width for fluid layout
 // modify width for fluid layout
-@include mixins.editing(true) {
-  .dynamic-layout-root:not(.growi-layout-fluid) {
-    :local {
-      .page-editor-preview-body :global {
-        .wiki {
-          max-width: 980px;
-          margin: 0 auto;
-        }
-      }
-    }
-  }
-  .dynamic-layout-root.growi-layout-fluid {
-    :local {
-      .page-editor-preview-body :global {
-        .wiki {
-          margin: 0 auto;
-        }
+.page-editor-preview-body {
+  &:global {
+    &.fluid-layout {
+      .wiki {
+        @include mixins.fluid-layout();
       }
       }
     }
     }
   }
   }

+ 6 - 2
apps/app/src/components/PageEditor/Preview.tsx

@@ -9,13 +9,14 @@ import RevisionRenderer from '../Page/RevisionRenderer';
 
 
 import styles from './Preview.module.scss';
 import styles from './Preview.module.scss';
 
 
-const moduleClass = styles['page-editor-preview-body'];
+const moduleClass = styles['page-editor-preview-body'] ?? '';
 
 
 
 
 type Props = {
 type Props = {
   rendererOptions: RendererOptions,
   rendererOptions: RendererOptions,
   markdown?: string,
   markdown?: string,
   pagePath?: string | null,
   pagePath?: string | null,
+  expandContentWidth?: boolean,
   onScroll?: (scrollTop: number) => void,
   onScroll?: (scrollTop: number) => void,
 }
 }
 
 
@@ -24,11 +25,14 @@ const Preview = React.forwardRef((props: Props, ref: RefObject<HTMLDivElement>):
   const {
   const {
     rendererOptions,
     rendererOptions,
     markdown, pagePath,
     markdown, pagePath,
+    expandContentWidth,
   } = props;
   } = props;
 
 
+  const fluidLayoutClass = expandContentWidth ? 'fluid-layout' : '';
+
   return (
   return (
     <div
     <div
-      className={`${moduleClass} ${pagePath === '/Sidebar' ? 'preview-sidebar' : ''}`}
+      className={`${moduleClass} ${fluidLayoutClass} ${pagePath === '/Sidebar' ? 'preview-sidebar' : ''}`}
       ref={ref}
       ref={ref}
       onScroll={(event: SyntheticEvent<HTMLDivElement>) => {
       onScroll={(event: SyntheticEvent<HTMLDivElement>) => {
         if (props.onScroll != null) {
         if (props.onScroll != null) {

+ 147 - 0
apps/app/src/components/PageEditor/markdown-table-util-for-editor.ts

@@ -0,0 +1,147 @@
+import type { EditorView } from '@codemirror/view';
+
+import MarkdownTable from '~/client/models/MarkdownTable';
+
+// https://regex101.com/r/7BN2fR/10
+const linePartOfTableRE = /^([^\r\n|]*)\|(([^\r\n|]*\|)+)$/;
+// https://regex101.com/r/1UuWBJ/3
+export const emptyLineOfTableRE = /^([^\r\n|]*)\|((\s*\|)+)$/;
+
+const curPos = (editor: EditorView): number => {
+  return editor.state.selection.main.head;
+};
+
+/**
+   * return boolean value whether the cursor position is in a table
+   */
+export const isInTable = (editor: EditorView): boolean => {
+  const lineText = editor.state.doc.lineAt(curPos(editor)).text;
+  return linePartOfTableRE.test(lineText);
+};
+
+/**
+   * return the postion of the BOT(beginning of table)
+   * (If the cursor is not in a table, return its position)
+   */
+const getBot = (editor: EditorView): number => {
+  if (!isInTable(editor)) {
+    return curPos(editor);
+  }
+
+  const doc = editor.state.doc;
+  const firstLine = 1;
+  let line = doc.lineAt(curPos(editor)).number - 1;
+  for (; line >= firstLine; line--) {
+    const strLine = doc.line(line).text;
+    if (!linePartOfTableRE.test(strLine)) {
+      break;
+    }
+  }
+  const botLine = Math.max(firstLine, line + 1);
+  return doc.line(botLine).from;
+};
+
+/**
+   * return the postion of the EOT(end of table)
+   * (If the cursor is not in a table, return its position)
+   */
+const getEot = (editor: EditorView): number => {
+  if (!isInTable(editor)) {
+    return curPos(editor);
+  }
+
+  const doc = editor.state.doc;
+  const lastLine = doc.lines;
+  let line = doc.lineAt(curPos(editor)).number + 1;
+  for (; line <= lastLine; line++) {
+    const strLine = doc.line(line).text;
+    if (!linePartOfTableRE.test(strLine)) {
+      break;
+    }
+  }
+  const eotLine = Math.min(line - 1, lastLine);
+  return doc.line(eotLine).to;
+};
+
+/**
+   * return strings from BOT(beginning of table) to the cursor position
+   */
+export const getStrFromBot = (editor: EditorView): string => {
+  return editor.state.sliceDoc(getBot(editor), curPos(editor));
+};
+
+/**
+   * return strings from the cursor position to EOT(end of table)
+   */
+export const getStrToEot = (editor: EditorView): string => {
+  return editor.state.sliceDoc(curPos(editor), getEot(editor));
+};
+
+/**
+   * return MarkdownTable instance of the table where the cursor is
+   * (If the cursor is not in a table, return null)
+   */
+export const getMarkdownTable = (editor: EditorView): MarkdownTable | undefined => {
+  if (!isInTable(editor)) {
+    return;
+  }
+
+  const strFromBotToEot = editor.state.sliceDoc(getBot(editor), getEot(editor));
+  return MarkdownTable.fromMarkdownString(strFromBotToEot);
+};
+
+/**
+   * return boolean value whether the cursor position is end of line
+   */
+export const isEndOfLine = (editor: EditorView): boolean => {
+  return curPos(editor) === editor.state.doc.lineAt(curPos(editor)).to;
+};
+
+/**
+   * add a row at the end
+   * (This function overwrite directory markdown table specified as argument.)
+   */
+export const addRowToMarkdownTable = (mdtable: MarkdownTable): any => {
+  const numCol = mdtable.table.length > 0 ? mdtable.table[0].length : 1;
+  const newRow: string[] = [];
+  (new Array(numCol)).forEach(() => { return newRow.push('') }); // create cols
+  mdtable.table.push(newRow);
+};
+
+/**
+   * return markdown table that is merged all of markdown table in array
+   * (The merged markdown table options are used for the first markdown table.)
+   */
+export const mergeMarkdownTable = (mdtableList: MarkdownTable): MarkdownTable | undefined => {
+  if (mdtableList == null || !(mdtableList instanceof Array)) {
+    return undefined;
+  }
+
+  let newTable = [];
+  const options = mdtableList[0].options; // use option of first markdown-table
+  mdtableList.forEach((mdtable) => {
+    newTable = newTable.concat(mdtable.table);
+  });
+  return (new MarkdownTable(newTable, options));
+};
+
+/**
+   * replace focused markdown table with editor
+   * (A replaced table is reformed by markdown-table.)
+   */
+export const replaceFocusedMarkdownTableWithEditor = (editor: EditorView, table: MarkdownTable): void => {
+  const botPos = getBot(editor);
+  const eotPos = getEot(editor);
+
+  editor.dispatch({
+    changes: {
+      from: botPos,
+      to: eotPos,
+      insert: table.toString(),
+    },
+  });
+  editor.dispatch({
+    selection: { anchor: editor.state.doc.lineAt(curPos(editor)).to },
+  });
+  editor.focus();
+};

+ 26 - 0
apps/app/src/components/PageEditor/markdown-table-util-for-view.ts

@@ -0,0 +1,26 @@
+import MarkdownTable from '~/client/models/MarkdownTable';
+
+export const getMarkdownTableFromLine = (markdown: string, bol: number, eol: number): MarkdownTable => {
+  const tableLines = markdown.split(/\r\n|\r|\n/).slice(bol - 1, eol).join('\n');
+  return MarkdownTable.fromMarkdownString(tableLines);
+};
+
+/**
+   * return markdown where the markdown table specified by line number params is replaced to the markdown table specified by table param
+   */
+export const replaceMarkdownTableInMarkdown = (table: MarkdownTable, markdown: string, beginLineNumber: number, endLineNumber: number): string => {
+  const splitMarkdown = markdown.split(/\r\n|\r|\n/);
+  const markdownBeforeTable = splitMarkdown.slice(0, beginLineNumber - 1);
+  const markdownAfterTable = splitMarkdown.slice(endLineNumber);
+
+  let newMarkdown = '';
+  if (markdownBeforeTable.length > 0) {
+    newMarkdown += `${markdownBeforeTable.join('\n')}\n`;
+  }
+  newMarkdown += table;
+  if (markdownAfterTable.length > 0) {
+    newMarkdown += `\n${markdownAfterTable.join('\n')}`;
+  }
+
+  return newMarkdown;
+};

+ 8 - 0
apps/app/src/components/SearchPage/SearchResultContent.module.scss

@@ -1,2 +1,10 @@
+@use '~/styles/mixins';
+
 .search-result-content :global {
 .search-result-content :global {
 }
 }
+
+.fluid-layout :global {
+  .grw-container-convertible {
+    @include mixins.fluid-layout();
+  }
+}

+ 13 - 13
apps/app/src/components/SearchPage/SearchResultContent.tsx

@@ -10,12 +10,12 @@ import { animateScroll } from 'react-scroll';
 import { DropdownItem } from 'reactstrap';
 import { DropdownItem } from 'reactstrap';
 import { debounce } from 'throttle-debounce';
 import { debounce } from 'throttle-debounce';
 
 
-import { useLayoutFluidClassName } from '~/client/services/layout';
+import { useShouldExpandContent } from '~/client/services/layout';
 import { exportAsMarkdown, updateContentWidth } from '~/client/services/page-operation';
 import { exportAsMarkdown, updateContentWidth } from '~/client/services/page-operation';
 import { toastSuccess } from '~/client/util/toastr';
 import { toastSuccess } from '~/client/util/toastr';
 import type { IPageWithSearchMeta } from '~/interfaces/search';
 import type { IPageWithSearchMeta } from '~/interfaces/search';
 import type { OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction } from '~/interfaces/ui';
 import type { OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction } from '~/interfaces/ui';
-import { useCurrentUser, useIsContainerFluid } from '~/stores/context';
+import { useCurrentUser } from '~/stores/context';
 import {
 import {
   usePageDuplicateModal, usePageRenameModal, usePageDeleteModal,
   usePageDuplicateModal, usePageRenameModal, usePageDeleteModal,
 } from '~/stores/modal';
 } from '~/stores/modal';
@@ -32,9 +32,10 @@ import type { PageContentFooterProps } from '../PageContentFooter';
 import styles from './SearchResultContent.module.scss';
 import styles from './SearchResultContent.module.scss';
 
 
 const moduleClass = styles['search-result-content'];
 const moduleClass = styles['search-result-content'];
+const _fluidLayoutClass = styles['fluid-layout'];
 
 
 
 
-const SubNavButtons = dynamic(() => import('../PageControls').then(mod => mod.PageControls), { ssr: false });
+const PageControls = dynamic(() => import('../PageControls').then(mod => mod.PageControls), { ssr: false });
 const RevisionLoader = dynamic<RevisionLoaderProps>(() => import('../Page/RevisionLoader').then(mod => mod.RevisionLoader), { ssr: false });
 const RevisionLoader = dynamic<RevisionLoaderProps>(() => import('../Page/RevisionLoader').then(mod => mod.RevisionLoader), { ssr: false });
 const PageComment = dynamic<PageCommentProps>(() => import('../PageComment').then(mod => mod.PageComment), { ssr: false });
 const PageComment = dynamic<PageCommentProps>(() => import('../PageComment').then(mod => mod.PageComment), { ssr: false });
 const PageContentFooter = dynamic<PageContentFooterProps>(() => import('../PageContentFooter').then(mod => mod.PageContentFooter), { ssr: false });
 const PageContentFooter = dynamic<PageContentFooterProps>(() => import('../PageContentFooter').then(mod => mod.PageContentFooter), { ssr: false });
@@ -123,12 +124,8 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
   const { open: openDeleteModal } = usePageDeleteModal();
   const { open: openDeleteModal } = usePageDeleteModal();
   const { data: rendererOptions } = useSearchResultOptions(pageWithMeta.data.path, highlightKeywords);
   const { data: rendererOptions } = useSearchResultOptions(pageWithMeta.data.path, highlightKeywords);
   const { data: currentUser } = useCurrentUser();
   const { data: currentUser } = useCurrentUser();
-  const { data: isContainerFluid } = useIsContainerFluid();
 
 
-  const [isExpandContentWidth, setIsExpandContentWidth] = useState(page.expandContentWidth);
-
-  // TODO: determine className by the 'expandContentWidth' from the updated page
-  const growiLayoutFluidClass = useLayoutFluidClassName(isExpandContentWidth);
+  const shouldExpandContent = useShouldExpandContent(page);
 
 
   const duplicateItemClickedHandler = useCallback(async(pageToDuplicate) => {
   const duplicateItemClickedHandler = useCallback(async(pageToDuplicate) => {
     // eslint-disable-next-line @typescript-eslint/no-unused-vars
     // eslint-disable-next-line @typescript-eslint/no-unused-vars
@@ -176,7 +173,8 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
 
 
   const switchContentWidthHandler = useCallback(async(pageId: string, value: boolean) => {
   const switchContentWidthHandler = useCallback(async(pageId: string, value: boolean) => {
     await updateContentWidth(pageId, value);
     await updateContentWidth(pageId, value);
-    setIsExpandContentWidth(value);
+
+    // TODO: revalidate page data and update shouldExpandContent
   }, []);
   }, []);
 
 
   const RightComponent = useCallback(() => {
   const RightComponent = useCallback(() => {
@@ -188,11 +186,11 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
 
 
     return (
     return (
       <div className="d-flex flex-column align-items-end justify-content-center px-2 py-1">
       <div className="d-flex flex-column align-items-end justify-content-center px-2 py-1">
-        <SubNavButtons
+        <PageControls
           pageId={page._id}
           pageId={page._id}
           revisionId={revisionId}
           revisionId={revisionId}
           path={page.path}
           path={page.path}
-          expandContentWidth={isExpandContentWidth ?? isContainerFluid}
+          expandContentWidth={shouldExpandContent}
           showPageControlDropdown={showPageControlDropdown}
           showPageControlDropdown={showPageControlDropdown}
           forceHideMenuItems={forceHideMenuItems}
           forceHideMenuItems={forceHideMenuItems}
           additionalMenuItemRenderer={props => <AdditionalMenuItems {...props} pageId={page._id} revisionId={revisionId} />}
           additionalMenuItemRenderer={props => <AdditionalMenuItems {...props} pageId={page._id} revisionId={revisionId} />}
@@ -203,16 +201,18 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
         />
         />
       </div>
       </div>
     );
     );
-  }, [page, isExpandContentWidth, showPageControlDropdown, forceHideMenuItems, isContainerFluid,
+  }, [page, shouldExpandContent, showPageControlDropdown, forceHideMenuItems,
       duplicateItemClickedHandler, renameItemClickedHandler, deleteItemClickedHandler, switchContentWidthHandler]);
       duplicateItemClickedHandler, renameItemClickedHandler, deleteItemClickedHandler, switchContentWidthHandler]);
 
 
   const isRenderable = page != null && rendererOptions != null;
   const isRenderable = page != null && rendererOptions != null;
 
 
+  const fluidLayoutClass = shouldExpandContent ? _fluidLayoutClass : '';
+
   return (
   return (
     <div
     <div
       key={page._id}
       key={page._id}
       data-testid="search-result-content"
       data-testid="search-result-content"
-      className={`dynamic-layout-root ${growiLayoutFluidClass} ${moduleClass}`}
+      className={`dynamic-layout-root ${moduleClass} ${fluidLayoutClass}`}
     >
     >
       <RightComponent />
       <RightComponent />
 
 

+ 4 - 0
apps/app/src/components/ShareLinkPageView.tsx

@@ -3,6 +3,7 @@ import React, { useMemo } from 'react';
 import type { IPagePopulatedToShowRevision } from '@growi/core';
 import type { IPagePopulatedToShowRevision } from '@growi/core';
 import dynamic from 'next/dynamic';
 import dynamic from 'next/dynamic';
 
 
+import { useShouldExpandContent } from '~/client/services/layout';
 import type { RendererConfig } from '~/interfaces/services/renderer';
 import type { RendererConfig } from '~/interfaces/services/renderer';
 import type { IShareLinkHasId } from '~/interfaces/share-link';
 import type { IShareLinkHasId } from '~/interfaces/share-link';
 import { generateSSRViewOptions } from '~/services/renderer/renderer';
 import { generateSSRViewOptions } from '~/services/renderer/renderer';
@@ -44,6 +45,8 @@ export const ShareLinkPageView = (props: Props): JSX.Element => {
 
 
   const { data: viewOptions } = useViewOptions();
   const { data: viewOptions } = useViewOptions();
 
 
+  const shouldExpandContent = useShouldExpandContent(page);
+
   const isNotFound = isNotFoundMeta || page == null || shareLink == null;
   const isNotFound = isNotFoundMeta || page == null || shareLink == null;
 
 
   const specialContents = useMemo(() => {
   const specialContents = useMemo(() => {
@@ -93,6 +96,7 @@ export const ShareLinkPageView = (props: Props): JSX.Element => {
     <PageViewLayout
     <PageViewLayout
       headerContents={headerContents}
       headerContents={headerContents}
       sideContents={sideContents}
       sideContents={sideContents}
+      expandContentWidth={shouldExpandContent}
     >
     >
       { specialContents }
       { specialContents }
       { specialContents == null && (
       { specialContents == null && (

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

@@ -40,13 +40,14 @@
   // set width for truncation
   // set width for truncation
   $grw-page-controls-width: 280px;
   $grw-page-controls-width: 280px;
   $grw-page-editor-mode-manager-width: 90px;
   $grw-page-editor-mode-manager-width: 90px;
-  $grw-contextual-subnavigation-padding-right: 8px;
+  $grw-contextual-subnavigation-padding-right: 12px;
   $gap: 8px;
   $gap: 8px;
   width: calc(100vw - #{$grw-page-controls-width + $grw-page-editor-mode-manager-width + $grw-contextual-subnavigation-padding-right + $gap * 2});
   width: calc(100vw - #{$grw-page-controls-width + $grw-page-editor-mode-manager-width + $grw-contextual-subnavigation-padding-right + $gap * 2});
 
 
   @include bs.media-breakpoint-up(md) {
   @include bs.media-breakpoint-up(md) {
     $grw-page-editor-mode-manager-width: 140px;
     $grw-page-editor-mode-manager-width: 140px;
     $gap: 24px;
     $gap: 24px;
+    $grw-contextual-subnavigation-padding-right: 24px;
     width: calc(100vw - #{var.$grw-sidebar-nav-width + $grw-page-controls-width + $grw-page-editor-mode-manager-width + $grw-contextual-subnavigation-padding-right + $gap * 2});
     width: calc(100vw - #{var.$grw-sidebar-nav-width + $grw-page-controls-width + $grw-page-editor-mode-manager-width + $grw-contextual-subnavigation-padding-right + $gap * 2});
   }
   }
 }
 }

+ 6 - 6
apps/app/src/components/Sidebar/PageCreateButton/DropendMenu.tsx

@@ -2,12 +2,13 @@ import React from 'react';
 
 
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 
 
+import { LabelType } from '~/interfaces/template';
+
 type DropendMenuProps = {
 type DropendMenuProps = {
   todaysPath: string,
   todaysPath: string,
   onClickCreateNewPageButtonHandler: () => Promise<void>
   onClickCreateNewPageButtonHandler: () => Promise<void>
   onClickCreateTodaysButtonHandler: () => Promise<void>
   onClickCreateTodaysButtonHandler: () => Promise<void>
-  onClickTemplateForChildrenButtonHandler: () => Promise<void>
-  onClickTemplateForDescendantsButtonHandler: () => Promise<void>
+  onClickTemplateButtonHandler: (label: LabelType) => Promise<void>
 }
 }
 
 
 export const DropendMenu = React.memo((props: DropendMenuProps): JSX.Element => {
 export const DropendMenu = React.memo((props: DropendMenuProps): JSX.Element => {
@@ -15,8 +16,7 @@ export const DropendMenu = React.memo((props: DropendMenuProps): JSX.Element =>
     todaysPath,
     todaysPath,
     onClickCreateNewPageButtonHandler,
     onClickCreateNewPageButtonHandler,
     onClickCreateTodaysButtonHandler,
     onClickCreateTodaysButtonHandler,
-    onClickTemplateForChildrenButtonHandler,
-    onClickTemplateForDescendantsButtonHandler,
+    onClickTemplateButtonHandler,
   } = props;
   } = props;
 
 
   const { t } = useTranslation('commons');
   const { t } = useTranslation('commons');
@@ -48,7 +48,7 @@ export const DropendMenu = React.memo((props: DropendMenuProps): JSX.Element =>
       <li>
       <li>
         <button
         <button
           className="dropdown-item"
           className="dropdown-item"
-          onClick={onClickTemplateForChildrenButtonHandler}
+          onClick={() => onClickTemplateButtonHandler('_template')}
           type="button"
           type="button"
         >
         >
           {t('create_page_dropdown.template.children')}
           {t('create_page_dropdown.template.children')}
@@ -57,7 +57,7 @@ export const DropendMenu = React.memo((props: DropendMenuProps): JSX.Element =>
       <li>
       <li>
         <button
         <button
           className="dropdown-item"
           className="dropdown-item"
-          onClick={onClickTemplateForDescendantsButtonHandler}
+          onClick={() => onClickTemplateButtonHandler('__template')}
           type="button"
           type="button"
         >
         >
           {t('create_page_dropdown.template.descendants')}
           {t('create_page_dropdown.template.descendants')}

+ 21 - 145
apps/app/src/components/Sidebar/PageCreateButton/PageCreateButton.tsx

@@ -1,172 +1,49 @@
-import React, { useCallback, useState } from 'react';
+import React, { useState, useCallback } from 'react';
 
 
 import { pagePathUtils } from '@growi/core/dist/utils';
 import { pagePathUtils } from '@growi/core/dist/utils';
 import { format } from 'date-fns';
 import { format } from 'date-fns';
-import { useRouter } from 'next/router';
 
 
-import { createPage, exist } from '~/client/services/page-operation';
+import { useOnTemplateButtonClicked } from '~/client/services/use-on-template-button-clicked';
 import { toastError } from '~/client/util/toastr';
 import { toastError } from '~/client/util/toastr';
+import { LabelType } from '~/interfaces/template';
 import { useCurrentUser } from '~/stores/context';
 import { useCurrentUser } from '~/stores/context';
 import { useSWRxCurrentPage } from '~/stores/page';
 import { useSWRxCurrentPage } from '~/stores/page';
-import loggerFactory from '~/utils/logger';
 
 
 import { CreateButton } from './CreateButton';
 import { CreateButton } from './CreateButton';
 import { DropendMenu } from './DropendMenu';
 import { DropendMenu } from './DropendMenu';
 import { DropendToggle } from './DropendToggle';
 import { DropendToggle } from './DropendToggle';
-
-const logger = loggerFactory('growi:cli:PageCreateButton');
+import { useOnNewButtonClicked, useOnTodaysButtonClicked } from './hooks';
 
 
 export const PageCreateButton = React.memo((): JSX.Element => {
 export const PageCreateButton = React.memo((): JSX.Element => {
-  const router = useRouter();
   const { data: currentPage, isLoading } = useSWRxCurrentPage();
   const { data: currentPage, isLoading } = useSWRxCurrentPage();
   const { data: currentUser } = useCurrentUser();
   const { data: currentUser } = useCurrentUser();
 
 
   const [isHovered, setIsHovered] = useState(false);
   const [isHovered, setIsHovered] = useState(false);
-  const [isCreating, setIsCreating] = useState(false);
 
 
   const now = format(new Date(), 'yyyy/MM/dd');
   const now = format(new Date(), 'yyyy/MM/dd');
   const userHomepagePath = pagePathUtils.userHomepagePath(currentUser);
   const userHomepagePath = pagePathUtils.userHomepagePath(currentUser);
   const todaysPath = `${userHomepagePath}/memo/${now}`;
   const todaysPath = `${userHomepagePath}/memo/${now}`;
 
 
-  const onMouseEnterHandler = () => {
-    setIsHovered(true);
-  };
-
-  const onMouseLeaveHandler = () => {
-    setIsHovered(false);
-  };
-
-  const onClickCreateNewPageButtonHandler = useCallback(async() => {
-    if (isLoading) return;
-
-    try {
-      setIsCreating(true);
-
-      const parentPath = currentPage == null
-        ? '/'
-        : currentPage.path;
-
-      const params = {
-        isSlackEnabled: false,
-        slackChannels: '',
-        grant: 4,
-        // grant: currentPage?.grant || 1,
-        // grantUserGroupId: currentPage?.grantedGroup?._id,
-        shouldGeneratePath: true,
-      };
-
-      const response = await createPage(parentPath, '', params);
-
-      router.push(`${response.page.id}#edit`);
-    }
-    catch (err) {
-      logger.warn(err);
-      toastError(err);
-    }
-    finally {
-      setIsCreating(false);
-    }
-  }, [currentPage, isLoading, router]);
-
-  const onClickCreateTodaysButtonHandler = useCallback(async() => {
-    if (currentUser == null) {
-      return;
-    }
-
-    try {
-      setIsCreating(true);
-
-      // TODO: get grant, grantUserGroupId data from parent page
-      // https://redmine.weseek.co.jp/issues/133892
-      const params = {
-        isSlackEnabled: false,
-        slackChannels: '',
-        grant: 4,
-      };
-
-      const res = await exist(JSON.stringify([todaysPath]));
-      if (!res.pages[todaysPath]) {
-        await createPage(todaysPath, '', params);
-      }
-
-      router.push(`${todaysPath}#edit`);
-    }
-    catch (err) {
-      logger.warn(err);
-      toastError(err);
-    }
-    finally {
-      setIsCreating(false);
-    }
-  }, [currentUser, router, todaysPath]);
-
-  const onClickTemplateForChildrenButtonHandler = useCallback(async() => {
-    if (isLoading) return;
+  const { onClickHandler: onClickNewButton, isPageCreating: isNewPageCreating } = useOnNewButtonClicked(isLoading, currentPage);
+  const { onClickHandler: onClickTodaysButton, isPageCreating: isTodaysPageCreating } = useOnTodaysButtonClicked(todaysPath, currentUser);
+  const { onClickHandler: onClickTemplateButton, isPageCreating: isTemplatePageCreating } = useOnTemplateButtonClicked(currentPage?.path);
 
 
+  const onClickTemplateButtonHandler = useCallback(async(label: LabelType) => {
     try {
     try {
-      setIsCreating(true);
-
-      const path = currentPage == null || currentPage.path === '/'
-        ? '/_template'
-        : `${currentPage.path}/_template`;
-
-      const params = {
-        isSlackEnabled: false,
-        slackChannels: '',
-        grant: 4,
-        // grant: currentPage?.grant || 1,
-        // grantUserGroupId: currentPage?.grantedGroup?._id,
-      };
-
-      const res = await exist(JSON.stringify([path]));
-      if (!res.pages[path]) {
-        await createPage(path, '', params);
-      }
-
-      router.push(`${path}#edit`);
+      await onClickTemplateButton(label);
     }
     }
     catch (err) {
     catch (err) {
-      logger.warn(err);
       toastError(err);
       toastError(err);
     }
     }
-    finally {
-      setIsCreating(false);
-    }
-  }, [currentPage, isLoading, router]);
-
-  const onClickTemplateForDescendantsButtonHandler = useCallback(async() => {
-    if (isLoading) return;
-
-    try {
-      setIsCreating(true);
+  }, [onClickTemplateButton]);
 
 
-      const path = currentPage == null || currentPage.path === '/'
-        ? '/__template'
-        : `${currentPage.path}/__template`;
-
-      const params = {
-        isSlackEnabled: false,
-        slackChannels: '',
-        grant: 4,
-        // grant: currentPage?.grant || 1,
-        // grantUserGroupId: currentPage?.grantedGroup?._id,
-      };
-
-      const res = await exist(JSON.stringify([path]));
-      if (!res.pages[path]) {
-        await createPage(path, '', params);
-      }
+  const onMouseEnterHandler = () => {
+    setIsHovered(true);
+  };
 
 
-      router.push(`${path}#edit`);
-    }
-    catch (err) {
-      logger.warn(err);
-      toastError(err);
-    }
-    finally {
-      setIsCreating(false);
-    }
-  }, [currentPage, isLoading, router]);
+  const onMouseLeaveHandler = () => {
+    setIsHovered(false);
+  };
 
 
   return (
   return (
     <div
     <div
@@ -177,8 +54,8 @@ export const PageCreateButton = React.memo((): JSX.Element => {
       <div className="btn-group flex-grow-1">
       <div className="btn-group flex-grow-1">
         <CreateButton
         <CreateButton
           className="z-2"
           className="z-2"
-          onClick={onClickCreateNewPageButtonHandler}
-          disabled={isCreating}
+          onClick={onClickNewButton}
+          disabled={isNewPageCreating || isTodaysPageCreating || isTemplatePageCreating}
         />
         />
       </div>
       </div>
       { isHovered && (
       { isHovered && (
@@ -190,10 +67,9 @@ export const PageCreateButton = React.memo((): JSX.Element => {
           />
           />
           <DropendMenu
           <DropendMenu
             todaysPath={todaysPath}
             todaysPath={todaysPath}
-            onClickCreateNewPageButtonHandler={onClickCreateNewPageButtonHandler}
-            onClickCreateTodaysButtonHandler={onClickCreateTodaysButtonHandler}
-            onClickTemplateForChildrenButtonHandler={onClickTemplateForChildrenButtonHandler}
-            onClickTemplateForDescendantsButtonHandler={onClickTemplateForDescendantsButtonHandler}
+            onClickCreateNewPageButtonHandler={onClickNewButton}
+            onClickCreateTodaysButtonHandler={onClickTodaysButton}
+            onClickTemplateButtonHandler={onClickTemplateButtonHandler}
           />
           />
         </div>
         </div>
       )}
       )}

+ 95 - 0
apps/app/src/components/Sidebar/PageCreateButton/hooks.tsx

@@ -0,0 +1,95 @@
+import { useCallback, useState } from 'react';
+
+import type { Nullable, IPagePopulatedToShowRevision, IUserHasId } from '@growi/core';
+import { useRouter } from 'next/router';
+
+import { createPage, exist } from '~/client/services/page-operation';
+import { toastError } from '~/client/util/toastr';
+
+export const useOnNewButtonClicked = (
+    isLoading: boolean,
+    currentPage?: IPagePopulatedToShowRevision | null,
+): {
+  onClickHandler: () => Promise<void>,
+  isPageCreating: boolean
+} => {
+  const router = useRouter();
+  const [isPageCreating, setIsPageCreating] = useState(false);
+
+  const onClickHandler = useCallback(async() => {
+    if (isLoading) return;
+
+    try {
+      setIsPageCreating(true);
+
+      const parentPath = currentPage == null
+        ? '/'
+        : currentPage.path;
+
+      const params = {
+        isSlackEnabled: false,
+        slackChannels: '',
+        grant: 4,
+        // grant: currentPage?.grant || 1,
+        // grantUserGroupId: currentPage?.grantedGroup?._id,
+        shouldGeneratePath: true,
+      };
+
+      const response = await createPage(parentPath, '', params);
+
+      router.push(`${response.page.id}#edit`);
+    }
+    catch (err) {
+      toastError(err);
+    }
+    finally {
+      setIsPageCreating(false);
+    }
+  }, [currentPage, isLoading, router]);
+
+  return { onClickHandler, isPageCreating };
+};
+
+export const useOnTodaysButtonClicked = (
+    todaysPath: string,
+    currentUser?: Nullable<IUserHasId> | undefined,
+): {
+  onClickHandler: () => Promise<void>,
+  isPageCreating: boolean
+} => {
+  const router = useRouter();
+  const [isPageCreating, setIsPageCreating] = useState(false);
+
+  const onClickHandler = useCallback(async() => {
+    if (currentUser == null) {
+      return;
+    }
+
+    try {
+      setIsPageCreating(true);
+
+      // TODO: get grant, grantUserGroupId data from parent page
+      // https://redmine.weseek.co.jp/issues/133892
+      const params = {
+        isSlackEnabled: false,
+        slackChannels: '',
+        grant: 4,
+      };
+
+      const res = await exist(JSON.stringify([todaysPath]));
+      if (!res.pages[todaysPath]) {
+        await createPage(todaysPath, '', params);
+      }
+
+      router.push(`${todaysPath}#edit`);
+    }
+    catch (err) {
+      toastError(err);
+    }
+    finally {
+      setIsPageCreating(false);
+    }
+  }, [currentUser, router, todaysPath]);
+
+  return { onClickHandler, isPageCreating };
+};

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

@@ -23,7 +23,7 @@ import { Ellipsis } from './Ellipsis';
 const logger = loggerFactory('growi:cli:Item');
 const logger = loggerFactory('growi:cli:Item');
 
 
 type PageTreeItemPropsOptional = 'itemRef' | 'itemClass' | 'mainClassName';
 type PageTreeItemPropsOptional = 'itemRef' | 'itemClass' | 'mainClassName';
-type PageTreeItemProps = Omit<SimpleItemProps, PageTreeItemPropsOptional> & {key};
+type PageTreeItemProps = Omit<SimpleItemProps, PageTreeItemPropsOptional>;
 
 
 export const PageTreeItem: FC<PageTreeItemProps> = (props) => {
 export const PageTreeItem: FC<PageTreeItemProps> = (props) => {
   const getNewPathAfterMoved = (droppedPagePath: string, newParentPagePath: string): string => {
   const getNewPathAfterMoved = (droppedPagePath: string, newParentPagePath: string): string => {
@@ -158,7 +158,6 @@ export const PageTreeItem: FC<PageTreeItemProps> = (props) => {
 
 
   return (
   return (
     <SimpleItem
     <SimpleItem
-      key={props.key}
       targetPathOrId={props.targetPathOrId}
       targetPathOrId={props.targetPathOrId}
       itemNode={props.itemNode}
       itemNode={props.itemNode}
       isOpen
       isOpen

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

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

+ 6 - 4
apps/app/src/components/TreeItem/SimpleItem.tsx

@@ -252,13 +252,15 @@ export const SimpleItem: FC<SimpleItemProps> = (props) => {
             </button>
             </button>
           )}
           )}
         </div>
         </div>
-        {SimpleItemContent.map(ItemContent => (
-          <ItemContent {...SimpleItemContentProps} />
+        {SimpleItemContent.map((ItemContent, index) => (
+          // eslint-disable-next-line react/no-array-index-key
+          <ItemContent key={index} {...SimpleItemContentProps} />
         ))}
         ))}
       </li>
       </li>
 
 
-      {CustomNextComponents?.map(UnderItemContent => (
-        <UnderItemContent {...SimpleItemContentProps} />
+      {CustomNextComponents?.map((UnderItemContent, index) => (
+        // eslint-disable-next-line react/no-array-index-key
+        <UnderItemContent key={index} {...SimpleItemContentProps} />
       ))}
       ))}
 
 
       {
       {

+ 0 - 2
apps/app/src/interfaces/in-app-notification.ts

@@ -8,8 +8,6 @@ export enum InAppNotificationStatuses {
   STATUS_OPENED = 'OPENED',
   STATUS_OPENED = 'OPENED',
 }
 }
 
 
-// TODO: do not use any type
-// https://redmine.weseek.co.jp/issues/120632
 export interface IInAppNotification<T = unknown> {
 export interface IInAppNotification<T = unknown> {
   user: IUser
   user: IUser
   targetModel: SupportedTargetModelType
   targetModel: SupportedTargetModelType

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

@@ -5,7 +5,7 @@ import EventEmitter from 'events';
 
 
 import { isIPageInfoForEntity } from '@growi/core';
 import { isIPageInfoForEntity } from '@growi/core';
 import type {
 import type {
-  IDataWithMeta, IPageInfoForEntity, IPagePopulatedToShowRevision, IUserHasId,
+  IDataWithMeta, IPageInfoForEntity, IPagePopulatedToShowRevision,
 } from '@growi/core';
 } from '@growi/core';
 import {
 import {
   isClient, pagePathUtils, pathUtils,
   isClient, pagePathUtils, pathUtils,
@@ -20,7 +20,7 @@ import Head from 'next/head';
 import { useRouter } from 'next/router';
 import { useRouter } from 'next/router';
 import superjson from 'superjson';
 import superjson from 'superjson';
 
 
-import { useEditorModeClassName, useLayoutFluidClassNameByPage } from '~/client/services/layout';
+import { useEditorModeClassName } from '~/client/services/layout';
 import { PageView } from '~/components/Page/PageView';
 import { PageView } from '~/components/Page/PageView';
 import { DrawioViewerScript } from '~/components/Script/DrawioViewerScript'; import type { CrowiRequest } from '~/interfaces/crowi-request';
 import { DrawioViewerScript } from '~/components/Script/DrawioViewerScript'; import type { CrowiRequest } from '~/interfaces/crowi-request';
 import type { EditorConfig } from '~/interfaces/editor-settings';
 import type { EditorConfig } from '~/interfaces/editor-settings';
@@ -249,8 +249,6 @@ const Page: NextPageWithLayout<Props> = (props: Props) => {
   useSetupGlobalSocket();
   useSetupGlobalSocket();
   useSetupGlobalSocketForPage(pageId);
   useSetupGlobalSocketForPage(pageId);
 
 
-  const growiLayoutFluidClass = useLayoutFluidClassNameByPage(pageWithMeta?.data);
-
   // Store initial data (When revisionBody is not SSR)
   // Store initial data (When revisionBody is not SSR)
   useEffect(() => {
   useEffect(() => {
     if (!props.skipSSR) {
     if (!props.skipSSR) {
@@ -323,7 +321,7 @@ const Page: NextPageWithLayout<Props> = (props: Props) => {
       <Head>
       <Head>
         <title>{title}</title>
         <title>{title}</title>
       </Head>
       </Head>
-      <div className={`dynamic-layout-root ${growiLayoutFluidClass} justify-content-between`}>
+      <div className="dynamic-layout-root justify-content-between">
         <nav className="sticky-top">
         <nav className="sticky-top">
           <GrowiContextualSubNavigation isLinkSharingDisabled={props.disableLinkSharing} />
           <GrowiContextualSubNavigation isLinkSharingDisabled={props.disableLinkSharing} />
         </nav>
         </nav>

+ 1 - 1
apps/app/src/pages/me/[[...path]].page.tsx

@@ -125,7 +125,7 @@ const MePage: NextPageWithLayout<Props> = (props: Props) => {
         <div id="grw-fav-sticky-trigger" className="sticky-top"></div>
         <div id="grw-fav-sticky-trigger" className="sticky-top"></div>
 
 
         <div id="main" className="main">
         <div id="main" className="main">
-          <div id="content-main" className="content-main container-lg grw-container-convertible">
+          <div id="content-main" className="content-main container-lg">
             {targetPage.component}
             {targetPage.component}
           </div>
           </div>
         </div>
         </div>

+ 2 - 5
apps/app/src/pages/share/[[...path]].page.tsx

@@ -1,6 +1,6 @@
 import React, { useEffect } from 'react';
 import React, { useEffect } from 'react';
 
 
-import { type IUserHasId, type IPagePopulatedToShowRevision, getIdForRef } from '@growi/core';
+import { type IPagePopulatedToShowRevision, getIdForRef } from '@growi/core';
 import type {
 import type {
   GetServerSideProps, GetServerSidePropsContext,
   GetServerSideProps, GetServerSidePropsContext,
 } from 'next';
 } from 'next';
@@ -8,7 +8,6 @@ import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
 import Head from 'next/head';
 import Head from 'next/head';
 import superjson from 'superjson';
 import superjson from 'superjson';
 
 
-import { useLayoutFluidClassNameByPage } from '~/client/services/layout';
 import { ShareLinkLayout } from '~/components/Layout/ShareLinkLayout';
 import { ShareLinkLayout } from '~/components/Layout/ShareLinkLayout';
 import GrowiContextualSubNavigationSubstance from '~/components/Navbar/GrowiContextualSubNavigation';
 import GrowiContextualSubNavigationSubstance from '~/components/Navbar/GrowiContextualSubNavigation';
 import { DrawioViewerScript } from '~/components/Script/DrawioViewerScript';
 import { DrawioViewerScript } from '~/components/Script/DrawioViewerScript';
@@ -108,8 +107,6 @@ const SharedPage: NextPageWithLayout<Props> = (props: Props) => {
   }, [mutateCurrentPage, props.isNotFound, props.shareLink?.relatedPage._id, props.skipSSR]);
   }, [mutateCurrentPage, props.isNotFound, props.shareLink?.relatedPage._id, props.skipSSR]);
 
 
 
 
-  const growiLayoutFluidClass = useLayoutFluidClassNameByPage(props.shareLinkRelatedPage);
-
   const pagePath = props.shareLinkRelatedPage?.path ?? '';
   const pagePath = props.shareLinkRelatedPage?.path ?? '';
 
 
   const title = generateCustomTitleForPage(props, pagePath);
   const title = generateCustomTitleForPage(props, pagePath);
@@ -120,7 +117,7 @@ const SharedPage: NextPageWithLayout<Props> = (props: Props) => {
         <title>{title}</title>
         <title>{title}</title>
       </Head>
       </Head>
 
 
-      <div className={`dynamic-layout-root ${growiLayoutFluidClass} justify-content-between`}>
+      <div className="dynamic-layout-root justify-content-between">
         <nav className="sticky-top">
         <nav className="sticky-top">
           <GrowiContextualSubNavigationForSharedPage page={currentPage ?? props.shareLinkRelatedPage} isLinkSharingDisabled={props.disableLinkSharing} />
           <GrowiContextualSubNavigationForSharedPage page={currentPage ?? props.shareLinkRelatedPage} isLinkSharingDisabled={props.disableLinkSharing} />
         </nav>
         </nav>

+ 1 - 1
apps/app/src/pages/tags.page.tsx

@@ -73,7 +73,7 @@ const TagPage: NextPageWithLayout<CommonProps> = (props: Props) => {
         <title>{title}</title>
         <title>{title}</title>
       </Head>
       </Head>
       <div className="dynamic-layout-root">
       <div className="dynamic-layout-root">
-        <div className="grw-container-convertible container-lg mb-5 pb-5" data-testid="tags-page">
+        <div className="container-lg mb-5 pb-5" data-testid="tags-page">
           <h2 className="my-3">{`${t('Tags')}(${totalCount})`}</h2>
           <h2 className="my-3">{`${t('Tags')}(${totalCount})`}</h2>
           <div className="px-3 mb-5 text-center">
           <div className="px-3 mb-5 text-center">
             <TagCloudBox tags={tagData} minSize={20} />
             <TagCloudBox tags={tagData} minSize={20} />

+ 1 - 1
apps/app/src/pages/trash.page.tsx

@@ -68,7 +68,7 @@ const TrashPage: NextPageWithLayout<CommonProps> = (props: Props) => {
           TODO: implement navigation for /trash
           TODO: implement navigation for /trash
         </nav>
         </nav>
 
 
-        <div className="content-main container-lg grw-container-convertible mb-5 pb-5">
+        <div className="content-main container-lg mb-5 pb-5">
           <PagePathNavSticky pagePath="/trash" />
           <PagePathNavSticky pagePath="/trash" />
           <TrashPageList />
           <TrashPageList />
         </div>
         </div>

+ 2 - 2
apps/app/src/server/models/activity.ts

@@ -112,8 +112,8 @@ activitySchema.statics.createByParameters = async function(parameters): Promise<
 };
 };
 
 
 // When using this method, ensure that activity updates are allowed using ActivityService.shoudUpdateActivity
 // When using this method, ensure that activity updates are allowed using ActivityService.shoudUpdateActivity
-activitySchema.statics.updateByParameters = async function(activityId: string, parameters): Promise<IActivity> {
-  const activity = await this.findOneAndUpdate({ _id: activityId }, parameters, { new: true }) as unknown as IActivity;
+activitySchema.statics.updateByParameters = async function(activityId: string, parameters): Promise<ActivityDocument | null> {
+  const activity = await this.findOneAndUpdate({ _id: activityId }, parameters, { new: true }).exec();
 
 
   return activity;
   return activity;
 };
 };

+ 246 - 1
apps/app/src/server/routes/apiv3/attachment.js

@@ -1,17 +1,28 @@
 import { ErrorV3 } from '@growi/core/dist/models';
 import { ErrorV3 } from '@growi/core/dist/models';
+import multer from 'multer';
+import autoReap from 'multer-autoreap';
 
 
+import { SupportedAction } from '~/interfaces/activity';
+import { AttachmentType } from '~/server/interfaces/attachment';
 import { Attachment } from '~/server/models';
 import { Attachment } from '~/server/models';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
+import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 import { certifySharedPageAttachmentMiddleware } from '../../middlewares/certify-shared-page-attachment';
 import { certifySharedPageAttachmentMiddleware } from '../../middlewares/certify-shared-page-attachment';
+import { excludeReadOnlyUser } from '../../middlewares/exclude-read-only-user';
+
 
 
 const logger = loggerFactory('growi:routes:apiv3:attachment'); // eslint-disable-line no-unused-vars
 const logger = loggerFactory('growi:routes:apiv3:attachment'); // eslint-disable-line no-unused-vars
 const express = require('express');
 const express = require('express');
 
 
 const router = express.Router();
 const router = express.Router();
-const { query, param } = require('express-validator');
+const {
+  query, param, body,
+} = require('express-validator');
 
 
+const { serializePageSecurely } = require('../../models/serializers/page-serializer');
+const { serializeRevisionSecurely } = require('../../models/serializers/revision-serializer');
 const { serializeUserSecurely } = require('../../models/serializers/user-serializer');
 const { serializeUserSecurely } = require('../../models/serializers/user-serializer');
 
 
 /**
 /**
@@ -20,11 +31,75 @@ const { serializeUserSecurely } = require('../../models/serializers/user-seriali
  *    name: Attachment
  *    name: Attachment
  */
  */
 
 
+
+/**
+ * @swagger
+ *
+ *  components:
+ *    schemas:
+ *      Attachment:
+ *        description: Attachment
+ *        type: object
+ *        properties:
+ *          _id:
+ *            type: string
+ *            description: attachment ID
+ *            example: 5e0734e072560e001761fa67
+ *          __v:
+ *            type: number
+ *            description: attachment version
+ *            example: 0
+ *          fileFormat:
+ *            type: string
+ *            description: file format in MIME
+ *            example: text/plain
+ *          fileName:
+ *            type: string
+ *            description: file name
+ *            example: 601b7c59d43a042c0117e08dd37aad0aimage.txt
+ *          originalName:
+ *            type: string
+ *            description: original file name
+ *            example: file.txt
+ *          creator:
+ *            $ref: '#/components/schemas/User'
+ *          page:
+ *            type: string
+ *            description: page ID attached at
+ *            example: 5e07345972560e001761fa63
+ *          createdAt:
+ *            type: string
+ *            description: date created at
+ *            example: 2010-01-01T00:00:00.000Z
+ *          fileSize:
+ *            type: number
+ *            description: file size
+ *            example: 3494332
+ *          url:
+ *            type: string
+ *            description: attachment URL
+ *            example: http://localhost/files/5e0734e072560e001761fa67
+ *          filePathProxied:
+ *            type: string
+ *            description: file path proxied
+ *            example: "/attachment/5e0734e072560e001761fa67"
+ *          downloadPathProxied:
+ *            type: string
+ *            description: download path proxied
+ *            example: "/download/5e0734e072560e001761fa67"
+ */
+
 module.exports = (crowi) => {
 module.exports = (crowi) => {
   const accessTokenParser = require('../../middlewares/access-token-parser')(crowi);
   const accessTokenParser = require('../../middlewares/access-token-parser')(crowi);
   const loginRequired = require('../../middlewares/login-required')(crowi, true);
   const loginRequired = require('../../middlewares/login-required')(crowi, true);
+  const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
   const Page = crowi.model('Page');
   const Page = crowi.model('Page');
   const User = crowi.model('User');
   const User = crowi.model('User');
+  const { attachmentService } = crowi;
+  const uploads = multer({ dest: `${crowi.tmpDir}uploads` });
+  const addActivity = generateAddActivityMiddleware(crowi);
+
+  const activityEvent = crowi.event('activity');
 
 
   const validator = {
   const validator = {
     retrieveAttachment: [
     retrieveAttachment: [
@@ -35,6 +110,12 @@ module.exports = (crowi) => {
       query('pageNumber').optional().isInt().withMessage('pageNumber must be a number'),
       query('pageNumber').optional().isInt().withMessage('pageNumber must be a number'),
       query('limit').optional().isInt({ max: 100 }).withMessage('You should set less than 100 or not to set limit.'),
       query('limit').optional().isInt({ max: 100 }).withMessage('You should set less than 100 or not to set limit.'),
     ],
     ],
+    retrieveFileLimit: [
+      query('fileSize').isNumeric().exists({ checkNull: true }).withMessage('fileSize is required'),
+    ],
+    retrieveAddAttachment: [
+      body('page_id').isString().exists({ checkNull: true }).withMessage('page_id is required'),
+    ],
   };
   };
 
 
   /**
   /**
@@ -95,6 +176,170 @@ module.exports = (crowi) => {
     }
     }
   });
   });
 
 
+
+  /**
+   * @swagger
+   *
+   *    /attachment/limit:
+   *      get:
+   *        tags: [Attachment]
+   *        operationId: getAttachmentLimit
+   *        summary: /attachment/limit
+   *        description: Get available capacity of uploaded file with GridFS
+   *        parameters:
+   *          - in: query
+   *            name: fileSize
+   *            schema:
+   *              type: number
+   *              description: file size
+   *              example: 23175
+   *            required: true
+   *        responses:
+   *          200:
+   *            description: Succeeded to get available capacity of uploaded file with GridFS.
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    isUploadable:
+   *                      type: boolean
+   *                      description: uploadable
+   *                      example: true
+   *          403:
+   *            $ref: '#/components/responses/403'
+   *          500:
+   *            $ref: '#/components/responses/500'
+   */
+  /**
+   * @api {get} /attachment/limit get available capacity of uploaded file with GridFS
+   * @apiName AddAttachment
+   * @apiGroup Attachment
+   */
+  router.get('/limit', accessTokenParser, loginRequiredStrictly, validator.retrieveFileLimit, apiV3FormValidator, async(req, res) => {
+    const { fileUploadService } = crowi;
+    const fileSize = Number(req.query.fileSize);
+    try {
+      return res.apiv3(await fileUploadService.checkLimit(fileSize));
+    }
+    catch (err) {
+      logger.error('File limit retrieval failed', err);
+      return res.apiv3Err(err, 500);
+    }
+  });
+
+  /**
+   * @swagger
+   *
+   *    /attachment:
+   *      post:
+   *        tags: [Attachment, CrowiCompatibles]
+   *        operationId: addAttachment
+   *        summary: /attachment
+   *        description: Add attachment to the page
+   *        requestBody:
+   *          content:
+   *            "multipart/form-data":
+   *              schema:
+   *                properties:
+   *                  page_id:
+   *                    nullable: true
+   *                    type: string
+   *                  path:
+   *                    nullable: true
+   *                    type: string
+   *                  file:
+   *                    type: string
+   *                    format: binary
+   *                    description: attachment data
+   *              encoding:
+   *                path:
+   *                  contentType: application/x-www-form-urlencoded
+   *            "*\/*":
+   *              schema:
+   *                properties:
+   *                  page_id:
+   *                    nullable: true
+   *                    type: string
+   *                  path:
+   *                    nullable: true
+   *                    type: string
+   *                  file:
+   *                    type: string
+   *                    format: binary
+   *                    description: attachment data
+   *              encoding:
+   *                path:
+   *                  contentType: application/x-www-form-urlencoded
+   *        responses:
+   *          200:
+   *            description: Succeeded to add attachment.
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    page:
+   *                      $ref: '#/components/schemas/Page'
+   *                    attachment:
+   *                      $ref: '#/components/schemas/Attachment'
+   *                    url:
+   *                      $ref: '#/components/schemas/Attachment/properties/url'
+   *                    pageCreated:
+   *                      type: boolean
+   *                      description: whether the page was created
+   *                      example: false
+   *          403:
+   *            $ref: '#/components/responses/403'
+   *          500:
+   *            $ref: '#/components/responses/500'
+   */
+  /**
+   * @api {post} /attachment Add attachment to the page
+   * @apiName AddAttachment
+   * @apiGroup Attachment
+   *
+   * @apiParam {String} page_id
+   * @apiParam {String} path
+   * @apiParam {File} file
+   */
+  router.post('/', uploads.single('file'), autoReap, accessTokenParser, loginRequiredStrictly, excludeReadOnlyUser,
+    validator.retrieveAddAttachment, apiV3FormValidator, addActivity,
+    async(req, res) => {
+
+      const pageId = req.body.page_id;
+
+      // check params
+      const file = req.file || null;
+      if (file == null) {
+        return res.apiv3Err('File error.');
+      }
+
+      try {
+        const page = await Page.findById(pageId);
+
+        // check the user is accessible
+        const isAccessible = await Page.isAccessiblePageByViewer(page.id, req.user);
+        if (!isAccessible) {
+          return res.apiv3Err(`Forbidden to access to the page '${page.id}'`);
+        }
+
+        const attachment = await attachmentService.createAttachment(file, req.user, pageId, AttachmentType.WIKI_PAGE);
+
+        const result = {
+          page: serializePageSecurely(page),
+          revision: serializeRevisionSecurely(page.revision),
+          attachment: attachment.toObject({ virtuals: true }),
+        };
+
+        activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ATTACHMENT_ADD });
+
+        res.apiv3(result);
+      }
+      catch (err) {
+        logger.error(err);
+        return res.apiv3Err(err.message);
+      }
+    });
+
   /**
   /**
    * @swagger
    * @swagger
    *
    *

+ 3 - 1
apps/app/src/server/routes/apiv3/bookmarks.js

@@ -1,6 +1,7 @@
 import { SupportedAction, SupportedTargetModel } from '~/interfaces/activity';
 import { SupportedAction, SupportedTargetModel } from '~/interfaces/activity';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
 import { serializeBookmarkSecurely } from '~/server/models/serializers/bookmark-serializer';
 import { serializeBookmarkSecurely } from '~/server/models/serializers/bookmark-serializer';
+import { preNotifyService } from '~/server/service/pre-notify';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
@@ -301,7 +302,8 @@ module.exports = (crowi) => {
       target: page,
       target: page,
       action: bool ? SupportedAction.ACTION_PAGE_BOOKMARK : SupportedAction.ACTION_PAGE_UNBOOKMARK,
       action: bool ? SupportedAction.ACTION_PAGE_BOOKMARK : SupportedAction.ACTION_PAGE_UNBOOKMARK,
     };
     };
-    activityEvent.emit('update', res.locals.activity._id, parameters, page);
+
+    activityEvent.emit('update', res.locals.activity._id, parameters, page, preNotifyService.generatePreNotify);
 
 
     return res.apiv3({ bookmark });
     return res.apiv3({ bookmark });
   });
   });

+ 4 - 1
apps/app/src/server/routes/apiv3/page.js

@@ -14,6 +14,7 @@ import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
 import { excludeReadOnlyUser } from '~/server/middlewares/exclude-read-only-user';
 import { excludeReadOnlyUser } from '~/server/middlewares/exclude-read-only-user';
 import Subscription from '~/server/models/subscription';
 import Subscription from '~/server/models/subscription';
 import UserGroup from '~/server/models/user-group';
 import UserGroup from '~/server/models/user-group';
+import { preNotifyService } from '~/server/service/pre-notify';
 import { divideByType } from '~/server/util/granted-group';
 import { divideByType } from '~/server/util/granted-group';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
@@ -362,7 +363,9 @@ module.exports = (crowi) => {
       target: page,
       target: page,
       action: isLiked ? SupportedAction.ACTION_PAGE_LIKE : SupportedAction.ACTION_PAGE_UNLIKE,
       action: isLiked ? SupportedAction.ACTION_PAGE_LIKE : SupportedAction.ACTION_PAGE_UNLIKE,
     };
     };
-    activityEvent.emit('update', res.locals.activity._id, parameters, page);
+
+    activityEvent.emit('update', res.locals.activity._id, parameters, page, preNotifyService.generatePreNotify);
+
 
 
     res.apiv3({ result });
     res.apiv3({ result });
 
 

+ 3 - 1
apps/app/src/server/routes/apiv3/pages.js

@@ -6,6 +6,7 @@ import { normalizePath, addHeadingSlash, attachTitleHeader } from '@growi/core/d
 
 
 import { SupportedTargetModel, SupportedAction } from '~/interfaces/activity';
 import { SupportedTargetModel, SupportedAction } from '~/interfaces/activity';
 import { subscribeRuleNames } from '~/interfaces/in-app-notification';
 import { subscribeRuleNames } from '~/interfaces/in-app-notification';
+import { preNotifyService } from '~/server/service/pre-notify';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
 import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
@@ -849,7 +850,8 @@ module.exports = (crowi) => {
         target: page,
         target: page,
         action: SupportedAction.ACTION_PAGE_DUPLICATE,
         action: SupportedAction.ACTION_PAGE_DUPLICATE,
       };
       };
-      activityEvent.emit('update', res.locals.activity._id, parameters, page);
+
+      activityEvent.emit('update', res.locals.activity._id, parameters, page, preNotifyService.generatePreNotify);
 
 
       return res.apiv3(result);
       return res.apiv3(result);
     });
     });

+ 0 - 162
apps/app/src/server/routes/attachment/api.js

@@ -176,168 +176,6 @@ export const routesFactory = (crowi) => {
   //   return responseForAttachment(req, res, attachment, true);
   //   return responseForAttachment(req, res, attachment, true);
   // };
   // };
 
 
-  /**
-   * @swagger
-   *
-   *    /attachments.limit:
-   *      get:
-   *        tags: [Attachments]
-   *        operationId: getAttachmentsLimit
-   *        summary: /attachments.limit
-   *        description: Get available capacity of uploaded file with GridFS
-   *        parameters:
-   *          - in: query
-   *            name: fileSize
-   *            schema:
-   *              type: number
-   *              description: file size
-   *              example: 23175
-   *            required: true
-   *        responses:
-   *          200:
-   *            description: Succeeded to get available capacity of uploaded file with GridFS.
-   *            content:
-   *              application/json:
-   *                schema:
-   *                  properties:
-   *                    isUploadable:
-   *                      type: boolean
-   *                      description: uploadable
-   *                      example: true
-   *                    ok:
-   *                      $ref: '#/components/schemas/V1Response/properties/ok'
-   *          403:
-   *            $ref: '#/components/responses/403'
-   *          500:
-   *            $ref: '#/components/responses/500'
-   */
-  /**
-   * @api {get} /attachments.limit get available capacity of uploaded file with GridFS
-   * @apiName AddAttachments
-   * @apiGroup Attachment
-   */
-  api.limit = async function(req, res) {
-    const { fileUploadService } = crowi;
-    const fileSize = Number(req.query.fileSize);
-    return res.json(ApiResponse.success(await fileUploadService.checkLimit(fileSize)));
-  };
-
-  /**
-   * @swagger
-   *
-   *    /attachments.add:
-   *      post:
-   *        tags: [Attachments, CrowiCompatibles]
-   *        operationId: addAttachment
-   *        summary: /attachments.add
-   *        description: Add attachment to the page
-   *        requestBody:
-   *          content:
-   *            "multipart/form-data":
-   *              schema:
-   *                properties:
-   *                  page_id:
-   *                    nullable: true
-   *                    type: string
-   *                  path:
-   *                    nullable: true
-   *                    type: string
-   *                  file:
-   *                    type: string
-   *                    format: binary
-   *                    description: attachment data
-   *              encoding:
-   *                path:
-   *                  contentType: application/x-www-form-urlencoded
-   *            "*\/*":
-   *              schema:
-   *                properties:
-   *                  page_id:
-   *                    nullable: true
-   *                    type: string
-   *                  path:
-   *                    nullable: true
-   *                    type: string
-   *                  file:
-   *                    type: string
-   *                    format: binary
-   *                    description: attachment data
-   *              encoding:
-   *                path:
-   *                  contentType: application/x-www-form-urlencoded
-   *        responses:
-   *          200:
-   *            description: Succeeded to add attachment.
-   *            content:
-   *              application/json:
-   *                schema:
-   *                  properties:
-   *                    ok:
-   *                      $ref: '#/components/schemas/V1Response/properties/ok'
-   *                    page:
-   *                      $ref: '#/components/schemas/Page'
-   *                    attachment:
-   *                      $ref: '#/components/schemas/Attachment'
-   *                    url:
-   *                      $ref: '#/components/schemas/Attachment/properties/url'
-   *                    pageCreated:
-   *                      type: boolean
-   *                      description: whether the page was created
-   *                      example: false
-   *          403:
-   *            $ref: '#/components/responses/403'
-   *          500:
-   *            $ref: '#/components/responses/500'
-   */
-  /**
-   * @api {post} /attachments.add Add attachment to the page
-   * @apiName AddAttachments
-   * @apiGroup Attachment
-   *
-   * @apiParam {String} page_id
-   * @apiParam {File} file
-   */
-  api.add = async function(req, res) {
-    const pageId = req.body.page_id || null;
-    const pagePath = req.body.path || null;
-
-    // check params
-    if (pageId == null && pagePath == null) {
-      return res.json(ApiResponse.error('Either page_id or path is required.'));
-    }
-    if (req.file == null) {
-      return res.json(ApiResponse.error('File error.'));
-    }
-
-    const file = req.file;
-
-    try {
-      const page = await Page.findById(pageId);
-
-      // check the user is accessible
-      const isAccessible = await Page.isAccessiblePageByViewer(page.id, req.user);
-      if (!isAccessible) {
-        return res.json(ApiResponse.error(`Forbidden to access to the page '${page.id}'`));
-      }
-
-      const attachment = await attachmentService.createAttachment(file, req.user, pageId, AttachmentType.WIKI_PAGE);
-
-      const result = {
-        page: serializePageSecurely(page),
-        revision: serializeRevisionSecurely(page.revision),
-        attachment: attachment.toObject({ virtuals: true }),
-      };
-
-      activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ATTACHMENT_ADD });
-
-      res.json(ApiResponse.success(result));
-    }
-    catch (err) {
-      logger.error(err);
-      return res.json(ApiResponse.error(err.message));
-    }
-  };
-
   /**
   /**
    * @swagger
    * @swagger
    *
    *

+ 10 - 1
apps/app/src/server/routes/comment.js

@@ -3,6 +3,8 @@ import { Comment, CommentEvent, commentEvent } from '~/features/comment/server';
 import { SupportedAction, SupportedTargetModel, SupportedEventModel } from '~/interfaces/activity';
 import { SupportedAction, SupportedTargetModel, SupportedEventModel } from '~/interfaces/activity';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
+import { preNotifyService } from '../service/pre-notify';
+
 /**
 /**
  * @swagger
  * @swagger
  *  tags:
  *  tags:
@@ -266,7 +268,14 @@ module.exports = function(crowi, app) {
       event: createdComment,
       event: createdComment,
       action: SupportedAction.ACTION_COMMENT_CREATE,
       action: SupportedAction.ACTION_COMMENT_CREATE,
     };
     };
-    activityEvent.emit('update', res.locals.activity._id, parameters, page);
+
+    const getAdditionalTargetUsers = async(activity) => {
+      const mentionedUsers = await crowi.commentService.getMentionedUsers(activity.event);
+
+      return mentionedUsers;
+    };
+
+    activityEvent.emit('update', res.locals.activity._id, parameters, page, preNotifyService.generatePreNotify, getAdditionalTargetUsers);
 
 
     res.json(ApiResponse.success({ comment: createdComment }));
     res.json(ApiResponse.success({ comment: createdComment }));
 
 

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

@@ -139,11 +139,9 @@ module.exports = function(crowi, app) {
   apiV1Router.post('/comments.update'    , comment.api.validators.add(), accessTokenParser , loginRequiredStrictly , excludeReadOnlyUser, addActivity, comment.api.update);
   apiV1Router.post('/comments.update'    , comment.api.validators.add(), accessTokenParser , loginRequiredStrictly , excludeReadOnlyUser, addActivity, comment.api.update);
   apiV1Router.post('/comments.remove'    , accessTokenParser , loginRequiredStrictly , excludeReadOnlyUser, addActivity, comment.api.remove);
   apiV1Router.post('/comments.remove'    , accessTokenParser , loginRequiredStrictly , excludeReadOnlyUser, addActivity, comment.api.remove);
 
 
-  apiV1Router.post('/attachments.add'                  , uploads.single('file'), autoReap, accessTokenParser, loginRequiredStrictly , excludeReadOnlyUser, addActivity , attachmentApi.add);
   apiV1Router.post('/attachments.uploadProfileImage'   , uploads.single('file'), autoReap, accessTokenParser, loginRequiredStrictly , excludeReadOnlyUser, attachmentApi.uploadProfileImage);
   apiV1Router.post('/attachments.uploadProfileImage'   , uploads.single('file'), autoReap, accessTokenParser, loginRequiredStrictly , excludeReadOnlyUser, attachmentApi.uploadProfileImage);
   apiV1Router.post('/attachments.remove'               , accessTokenParser , loginRequiredStrictly , excludeReadOnlyUser, addActivity ,attachmentApi.remove);
   apiV1Router.post('/attachments.remove'               , accessTokenParser , loginRequiredStrictly , excludeReadOnlyUser, addActivity ,attachmentApi.remove);
   apiV1Router.post('/attachments.removeProfileImage'   , accessTokenParser , loginRequiredStrictly , excludeReadOnlyUser, attachmentApi.removeProfileImage);
   apiV1Router.post('/attachments.removeProfileImage'   , accessTokenParser , loginRequiredStrictly , excludeReadOnlyUser, attachmentApi.removeProfileImage);
-  apiV1Router.get('/attachments.limit'   , accessTokenParser , loginRequiredStrictly, attachmentApi.limit);
 
 
   // API v1
   // API v1
   app.use('/_api', unavailableWhenMaintenanceModeForApi, apiV1Router);
   app.use('/_api', unavailableWhenMaintenanceModeForApi, apiV1Router);

+ 9 - 2
apps/app/src/server/routes/login.js

@@ -44,13 +44,20 @@ module.exports = function(crowi, app) {
   }
   }
 
 
   async function sendNotificationToAllAdmins(user) {
   async function sendNotificationToAllAdmins(user) {
-    const adminUsers = await User.findAdmins();
+
     const activity = await activityService.createActivity({
     const activity = await activityService.createActivity({
       action: SupportedAction.ACTION_USER_REGISTRATION_APPROVAL_REQUEST,
       action: SupportedAction.ACTION_USER_REGISTRATION_APPROVAL_REQUEST,
       target: user,
       target: user,
       targetModel: SupportedTargetModel.MODEL_USER,
       targetModel: SupportedTargetModel.MODEL_USER,
     });
     });
-    await activityEvent.emit('updated', activity, user, adminUsers);
+
+    const preNotify = async(props) => {
+      const adminUsers = await User.findAdmins();
+
+      props.push(...adminUsers);
+    };
+
+    await activityEvent.emit('updated', activity, user, preNotify);
     return;
     return;
   }
   }
 
 

+ 5 - 1
apps/app/src/server/routes/page.js

@@ -7,6 +7,7 @@ import loggerFactory from '~/utils/logger';
 
 
 import { PathAlreadyExistsError } from '../models/errors';
 import { PathAlreadyExistsError } from '../models/errors';
 import UpdatePost from '../models/update-post';
 import UpdatePost from '../models/update-post';
+import { preNotifyService } from '../service/pre-notify';
 
 
 const { serializePageSecurely } = require('../models/serializers/page-serializer');
 const { serializePageSecurely } = require('../models/serializers/page-serializer');
 const { serializeRevisionSecurely } = require('../models/serializers/revision-serializer');
 const { serializeRevisionSecurely } = require('../models/serializers/revision-serializer');
@@ -522,7 +523,10 @@ module.exports = function(crowi, app) {
       target: page,
       target: page,
       action: SupportedAction.ACTION_PAGE_UPDATE,
       action: SupportedAction.ACTION_PAGE_UPDATE,
     };
     };
-    activityEvent.emit('update', res.locals.activity._id, parameters, { path: page.path, creator: page.creator._id.toString() });
+
+    activityEvent.emit(
+      'update', res.locals.activity._id, parameters, { path: page.path, creator: page.creator._id.toString() }, preNotifyService.generatePreNotify,
+    );
   };
   };
 
 
   /**
   /**

+ 18 - 5
apps/app/src/server/service/activity.ts

@@ -1,16 +1,18 @@
-import type { Ref, IPage, IUser } from '@growi/core';
+import type { IPage } from '@growi/core';
 import mongoose from 'mongoose';
 import mongoose from 'mongoose';
 
 
 import {
 import {
   IActivity, SupportedActionType, AllSupportedActions, ActionGroupSize,
   IActivity, SupportedActionType, AllSupportedActions, ActionGroupSize,
   AllEssentialActions, AllSmallGroupActions, AllMediumGroupActions, AllLargeGroupActions,
   AllEssentialActions, AllSmallGroupActions, AllMediumGroupActions, AllLargeGroupActions,
 } from '~/interfaces/activity';
 } from '~/interfaces/activity';
-import Activity from '~/server/models/activity';
+import Activity, { ActivityDocument } from '~/server/models/activity';
 
 
 import loggerFactory from '../../utils/logger';
 import loggerFactory from '../../utils/logger';
 import Crowi from '../crowi';
 import Crowi from '../crowi';
 
 
 
 
+import type { GeneratePreNotify, GetAdditionalTargetUsers } from './pre-notify';
+
 const logger = loggerFactory('growi:service:ActivityService');
 const logger = loggerFactory('growi:service:ActivityService');
 
 
 const parseActionString = (actionsString: string): SupportedActionType[] => {
 const parseActionString = (actionsString: string): SupportedActionType[] => {
@@ -39,8 +41,10 @@ class ActivityService {
   }
   }
 
 
   initActivityEventListeners(): void {
   initActivityEventListeners(): void {
-    this.activityEvent.on('update', async(activityId: string, parameters, target?: IPage, descendantsSubscribedUsers?: Ref<IUser>[]) => {
-      let activity: IActivity;
+    this.activityEvent.on('update', async(
+        activityId: string, parameters, target: IPage, generatePreNotify?: GeneratePreNotify, getAdditionalTargetUsers?: GetAdditionalTargetUsers,
+    ) => {
+      let activity: ActivityDocument;
       const shoudUpdate = this.shoudUpdateActivity(parameters.action);
       const shoudUpdate = this.shoudUpdateActivity(parameters.action);
 
 
       if (shoudUpdate) {
       if (shoudUpdate) {
@@ -52,7 +56,16 @@ class ActivityService {
           return;
           return;
         }
         }
 
 
-        this.activityEvent.emit('updated', activity, target, descendantsSubscribedUsers);
+        if (generatePreNotify != null) {
+          const preNotify = generatePreNotify(activity, getAdditionalTargetUsers);
+
+          this.activityEvent.emit('updated', activity, target, preNotify);
+
+          return;
+        }
+
+        this.activityEvent.emit('updated', activity, target);
+
       }
       }
     });
     });
   }
   }

+ 20 - 36
apps/app/src/server/service/in-app-notification.ts

@@ -1,14 +1,12 @@
 import type {
 import type {
-  HasObjectId, Ref, IUser,
+  HasObjectId, IUser, IPage,
 } from '@growi/core';
 } from '@growi/core';
 import { SubscriptionStatusType } from '@growi/core';
 import { SubscriptionStatusType } from '@growi/core';
 import { subDays } from 'date-fns';
 import { subDays } from 'date-fns';
 import { Types, FilterQuery, UpdateQuery } from 'mongoose';
 import { Types, FilterQuery, UpdateQuery } from 'mongoose';
 
 
-import { AllEssentialActions, SupportedAction } from '~/interfaces/activity';
+import { AllEssentialActions } from '~/interfaces/activity';
 import { InAppNotificationStatuses, PaginateResult } from '~/interfaces/in-app-notification';
 import { InAppNotificationStatuses, PaginateResult } from '~/interfaces/in-app-notification';
-import * as pageSerializers from '~/models/serializers/in-app-notification-snapshot/page';
-import * as userSerializers from '~/models/serializers/in-app-notification-snapshot/user';
 import { ActivityDocument } from '~/server/models/activity';
 import { ActivityDocument } from '~/server/models/activity';
 import {
 import {
   InAppNotification,
   InAppNotification,
@@ -21,12 +19,14 @@ import loggerFactory from '~/utils/logger';
 import Crowi from '../crowi';
 import Crowi from '../crowi';
 import { RoomPrefix, getRoomNameWithId } from '../util/socket-io-helpers';
 import { RoomPrefix, getRoomNameWithId } from '../util/socket-io-helpers';
 
 
+import { generateSnapshot } from './in-app-notification/in-app-notification-utils';
+import { preNotifyService, type PreNotify } from './pre-notify';
+
 
 
 const { STATUS_UNREAD, STATUS_UNOPENED, STATUS_OPENED } = InAppNotificationStatuses;
 const { STATUS_UNREAD, STATUS_UNOPENED, STATUS_OPENED } = InAppNotificationStatuses;
 
 
 const logger = loggerFactory('growi:service:inAppNotification');
 const logger = loggerFactory('growi:service:inAppNotification');
 
 
-
 export default class InAppNotificationService {
 export default class InAppNotificationService {
 
 
   crowi!: Crowi;
   crowi!: Crowi;
@@ -49,13 +49,11 @@ export default class InAppNotificationService {
   }
   }
 
 
   initActivityEventListeners(): void {
   initActivityEventListeners(): void {
-    // TODO: do not use any type
-    // https://redmine.weseek.co.jp/issues/120632
-    this.activityEvent.on('updated', async(activity: ActivityDocument, target: any, users?: Ref<IUser>[]) => {
+    this.activityEvent.on('updated', async(activity: ActivityDocument, target: IUser | IPage, preNotify: PreNotify) => {
       try {
       try {
         const shouldNotification = activity != null && target != null && (AllEssentialActions as ReadonlyArray<string>).includes(activity.action);
         const shouldNotification = activity != null && target != null && (AllEssentialActions as ReadonlyArray<string>).includes(activity.action);
         if (shouldNotification) {
         if (shouldNotification) {
-          await this.createInAppNotification(activity, target, users);
+          await this.createInAppNotification(activity, target, preNotify);
         }
         }
       }
       }
       catch (err) {
       catch (err) {
@@ -199,38 +197,24 @@ export default class InAppNotificationService {
     return;
     return;
   };
   };
 
 
-  // TODO: do not use any type
-  // https://redmine.weseek.co.jp/issues/120632
-  createInAppNotification = async function(activity: ActivityDocument, target, users?: Ref<IUser>[]): Promise<void> {
-    if (activity.action === SupportedAction.ACTION_USER_REGISTRATION_APPROVAL_REQUEST) {
-      const snapshot = userSerializers.stringifySnapshot(target);
-      await this.upsertByActivity(users, activity, snapshot);
-      await this.emitSocketIo(users);
-      return;
-    }
+  createInAppNotification = async function(activity: ActivityDocument, target: IUser | IPage, preNotify: PreNotify): Promise<void> {
 
 
     const shouldNotification = activity != null && target != null && (AllEssentialActions as ReadonlyArray<string>).includes(activity.action);
     const shouldNotification = activity != null && target != null && (AllEssentialActions as ReadonlyArray<string>).includes(activity.action);
-    const snapshot = pageSerializers.stringifySnapshot(target);
+
+    const targetModel = activity.targetModel;
+
+    const snapshot = generateSnapshot(targetModel, target);
+
     if (shouldNotification) {
     if (shouldNotification) {
-      let mentionedUsers: IUser[] = [];
-      if (activity.action === SupportedAction.ACTION_COMMENT_CREATE) {
-        mentionedUsers = await this.crowi.commentService.getMentionedUsers(activity.event);
-      }
-      const notificationTargetUsers = await activity?.getNotificationTargetUsers();
-      let notificationDescendantsUsers = [];
-      if (users != null) {
-        const User = this.crowi.model('User');
-        const descendantsUsers = users.filter(item => (item.toString() !== activity.user._id.toString()));
-        notificationDescendantsUsers = await User.find({
-          _id: { $in: descendantsUsers },
-          status: User.STATUS_ACTIVE,
-        }).distinct('_id');
-      }
-      await this.upsertByActivity([...notificationTargetUsers, ...mentionedUsers, ...notificationDescendantsUsers], activity, snapshot);
-      await this.emitSocketIo([...notificationTargetUsers, notificationDescendantsUsers]);
+      const props = preNotifyService.generateInitialPreNotifyProps();
+
+      await preNotify(props);
+
+      await this.upsertByActivity(props.notificationTargetUsers, activity, snapshot);
+      await this.emitSocketIo(props.notificationTargetUsers);
     }
     }
     else {
     else {
-      throw Error('No activity to notify');
+      throw Error('no activity to notify');
     }
     }
     return;
     return;
   };
   };

+ 19 - 0
apps/app/src/server/service/in-app-notification/in-app-notification-utils.ts

@@ -0,0 +1,19 @@
+import type { IUser, IPage } from '@growi/core';
+
+import { SupportedTargetModel } from '~/interfaces/activity';
+import * as pageSerializers from '~/models/serializers/in-app-notification-snapshot/page';
+
+const isIPage = (targetModel: string, target: IUser | IPage): target is IPage => {
+  return targetModel === SupportedTargetModel.MODEL_PAGE;
+};
+
+export const generateSnapshot = (targetModel: string, target: IUser | IPage) => {
+
+  let snapshot;
+
+  if (isIPage(targetModel, target)) {
+    snapshot = pageSerializers.stringifySnapshot(target);
+  }
+
+  return snapshot;
+};

+ 41 - 15
apps/app/src/server/service/page.ts

@@ -2,7 +2,7 @@ import pathlib from 'path';
 import { Readable, Writable } from 'stream';
 import { Readable, Writable } from 'stream';
 
 
 import type {
 import type {
-  Ref, HasObjectId, IUserHasId,
+  Ref, HasObjectId, IUserHasId, IUser,
   IPage, IPageInfo, IPageInfoAll, IPageInfoForEntity, IPageWithMeta, IGrantedGroup,
   IPage, IPageInfo, IPageInfoAll, IPageInfoForEntity, IPageWithMeta, IGrantedGroup,
 } from '@growi/core';
 } from '@growi/core';
 import { PageGrant, PageStatus } from '@growi/core';
 import { PageGrant, PageStatus } from '@growi/core';
@@ -29,6 +29,7 @@ import {
   type CreateMethod, type PageCreateOptions, type PageModel, type PageDocument, pushRevision, PageQueryBuilder,
   type CreateMethod, type PageCreateOptions, type PageModel, type PageDocument, pushRevision, PageQueryBuilder,
 } from '~/server/models/page';
 } from '~/server/models/page';
 import { createBatchStream } from '~/server/util/batch-stream';
 import { createBatchStream } from '~/server/util/batch-stream';
+import { getModelSafely } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 import { prepareDeleteConfigValuesForCalc } from '~/utils/page-delete-config';
 import { prepareDeleteConfigValuesForCalc } from '~/utils/page-delete-config';
 
 
@@ -45,6 +46,8 @@ import UserGroupRelation from '../models/user-group-relation';
 import { V5ConversionError } from '../models/vo/v5-conversion-error';
 import { V5ConversionError } from '../models/vo/v5-conversion-error';
 import { divideByType } from '../util/granted-group';
 import { divideByType } from '../util/granted-group';
 
 
+import { preNotifyService } from './pre-notify';
+
 const debug = require('debug')('growi:services:page');
 const debug = require('debug')('growi:services:page');
 
 
 const logger = loggerFactory('growi:services:page');
 const logger = loggerFactory('growi:services:page');
@@ -443,7 +446,9 @@ class PageService {
       throw err;
       throw err;
     }
     }
     if (page.descendantCount < 1) {
     if (page.descendantCount < 1) {
-      this.activityEvent.emit('updated', activity, page);
+      const preNotify = preNotifyService.generatePreNotify(activity);
+
+      this.activityEvent.emit('updated', activity, page, preNotify);
     }
     }
     return renamedPage;
     return renamedPage;
   }
   }
@@ -548,8 +553,11 @@ class PageService {
     // update descendants first
     // update descendants first
       const descendantsSubscribedSets = new Set();
       const descendantsSubscribedSets = new Set();
       await this.renameDescendantsWithStream(page, newPagePath, user, options, false, descendantsSubscribedSets);
       await this.renameDescendantsWithStream(page, newPagePath, user, options, false, descendantsSubscribedSets);
-      const descendantsSubscribedUsers = Array.from(descendantsSubscribedSets);
-      this.activityEvent.emit('updated', activity, page, descendantsSubscribedUsers);
+      const descendantsSubscribedUsers = Array.from(descendantsSubscribedSets) as Ref<IUser>[];
+
+      const preNotify = preNotifyService.generatePreNotify(activity, () => { return descendantsSubscribedUsers });
+
+      this.activityEvent.emit('updated', activity, page, preNotify);
     }
     }
     catch (err) {
     catch (err) {
       logger.warn(err);
       logger.warn(err);
@@ -1481,7 +1489,9 @@ class PageService {
       })();
       })();
     }
     }
     else {
     else {
-      this.activityEvent.emit('updated', activity, page);
+      const preNotify = preNotifyService.generatePreNotify(activity);
+
+      this.activityEvent.emit('updated', activity, page, preNotify);
     }
     }
 
 
     return deletedPage;
     return deletedPage;
@@ -1517,8 +1527,11 @@ class PageService {
     const descendantsSubscribedSets = new Set();
     const descendantsSubscribedSets = new Set();
     await this.deleteDescendantsWithStream(page, user, false, descendantsSubscribedSets);
     await this.deleteDescendantsWithStream(page, user, false, descendantsSubscribedSets);
 
 
-    const descendantsSubscribedUsers = Array.from(descendantsSubscribedSets);
-    this.activityEvent.emit('updated', activity, page, descendantsSubscribedUsers);
+    const descendantsSubscribedUsers = Array.from(descendantsSubscribedSets) as Ref<IUser>[];
+
+    const preNotify = preNotifyService.generatePreNotify(activity, () => { return descendantsSubscribedUsers });
+
+    this.activityEvent.emit('updated', activity, page, preNotify);
 
 
     await PageOperation.findByIdAndDelete(pageOpId);
     await PageOperation.findByIdAndDelete(pageOpId);
 
 
@@ -1829,7 +1842,9 @@ class PageService {
       })();
       })();
     }
     }
     else {
     else {
-      this.activityEvent.emit('updated', activity, page);
+      const preNotify = preNotifyService.generatePreNotify(activity);
+
+      this.activityEvent.emit('updated', activity, page, preNotify);
     }
     }
 
 
     return;
     return;
@@ -1838,8 +1853,11 @@ class PageService {
   async deleteCompletelyRecursivelyMainOperation(page, user, options, pageOpId: ObjectIdLike, activity?): Promise<void> {
   async deleteCompletelyRecursivelyMainOperation(page, user, options, pageOpId: ObjectIdLike, activity?): Promise<void> {
     const descendantsSubscribedSets = new Set();
     const descendantsSubscribedSets = new Set();
     await this.deleteCompletelyDescendantsWithStream(page, user, options, false, descendantsSubscribedSets);
     await this.deleteCompletelyDescendantsWithStream(page, user, options, false, descendantsSubscribedSets);
-    const descendantsSubscribedUsers = Array.from(descendantsSubscribedSets);
-    this.activityEvent.emit('updated', activity, page, descendantsSubscribedUsers);
+    const descendantsSubscribedUsers = Array.from(descendantsSubscribedSets) as Ref<IUser>[];
+
+    const preNotify = preNotifyService.generatePreNotify(activity, () => { return descendantsSubscribedUsers });
+
+    this.activityEvent.emit('updated', activity, page, preNotify);
 
 
     await PageOperation.findByIdAndDelete(pageOpId);
     await PageOperation.findByIdAndDelete(pageOpId);
 
 
@@ -1882,9 +1900,11 @@ class PageService {
 
 
     const descendantsSubscribedSets = new Set();
     const descendantsSubscribedSets = new Set();
     const pages = await this.deleteCompletelyDescendantsWithStream(page, user, options, true, descendantsSubscribedSets);
     const pages = await this.deleteCompletelyDescendantsWithStream(page, user, options, true, descendantsSubscribedSets);
-    const descendantsSubscribedUsers = Array.from(descendantsSubscribedSets);
+    const descendantsSubscribedUsers = Array.from(descendantsSubscribedSets) as Ref<IUser>[];
 
 
-    this.activityEvent.emit('updated', activity, page, descendantsSubscribedUsers);
+    const preNotify = preNotifyService.generatePreNotify(activity, () => { return descendantsSubscribedUsers });
+
+    this.activityEvent.emit('updated', activity, page, preNotify);
 
 
     return pages;
     return pages;
   }
   }
@@ -2161,7 +2181,10 @@ class PageService {
 
 
     if (!isRecursively) {
     if (!isRecursively) {
       await this.updateDescendantCountOfAncestors(parent._id, 1, true);
       await this.updateDescendantCountOfAncestors(parent._id, 1, true);
-      this.activityEvent.emit('updated', activity, page);
+
+      const preNotify = preNotifyService.generatePreNotify(activity);
+
+      this.activityEvent.emit('updated', activity, page, preNotify);
     }
     }
     else {
     else {
       let pageOp;
       let pageOp;
@@ -2207,8 +2230,11 @@ class PageService {
 
 
     const descendantsSubscribedSets = new Set();
     const descendantsSubscribedSets = new Set();
     await this.revertDeletedDescendantsWithStream(page, user, options, false, descendantsSubscribedSets);
     await this.revertDeletedDescendantsWithStream(page, user, options, false, descendantsSubscribedSets);
-    const descendantsSubscribedUsers = Array.from(descendantsSubscribedSets);
-    this.activityEvent.emit('updated', activity, page, descendantsSubscribedUsers);
+    const descendantsSubscribedUsers = Array.from(descendantsSubscribedSets) as Ref<IUser>[];
+
+    const preNotify = preNotifyService.generatePreNotify(activity, () => { return descendantsSubscribedUsers });
+
+    this.activityEvent.emit('updated', activity, page, preNotify);
 
 
     const newPath = Page.getRevertDeletedPageName(page.path);
     const newPath = Page.getRevertDeletedPageName(page.path);
     // normalize parent of descendant pages
     // normalize parent of descendant pages

+ 66 - 0
apps/app/src/server/service/pre-notify.ts

@@ -0,0 +1,66 @@
+import type {
+  IPage, IUser, Ref,
+} from '@growi/core';
+
+import { ActivityDocument } from '../models/activity';
+import Subscription from '../models/subscription';
+import { getModelSafely } from '../util/mongoose-utils';
+
+export type PreNotifyProps = {
+  notificationTargetUsers?: Ref<IUser>[],
+}
+
+export type PreNotify = (props: PreNotifyProps) => Promise<void>;
+export type GeneratePreNotify = (activity: ActivityDocument, getAdditionalTargetUsers?: (activity?: ActivityDocument) => Ref<IUser>[]) => PreNotify;
+
+export type GetAdditionalTargetUsers = (activity: ActivityDocument) => Ref<IUser>[];
+
+interface IPreNotifyService {
+  generateInitialPreNotifyProps: (PreNotifyProps) => { notificationTargetUsers?: Ref<IUser>[] },
+  generatePreNotify: GeneratePreNotify
+}
+
+class PreNotifyService implements IPreNotifyService {
+
+  generateInitialPreNotifyProps = (): PreNotifyProps => {
+
+    const initialPreNotifyProps: Ref<IUser>[] = [];
+
+    return { notificationTargetUsers: initialPreNotifyProps };
+  };
+
+  generatePreNotify = (activity: ActivityDocument, getAdditionalTargetUsers?: GetAdditionalTargetUsers): PreNotify => {
+
+    const preNotify = async(props: PreNotifyProps) => {
+      const { notificationTargetUsers } = props;
+
+      const User = getModelSafely('User') || require('~/server/models/user')();
+      const actionUser = activity.user;
+      const target = activity.target;
+      const subscribedUsers = await Subscription.getSubscription(target as unknown as Ref<IPage>);
+      const notificationUsers = subscribedUsers.filter(item => (item.toString() !== actionUser._id.toString()));
+      const activeNotificationUsers = await User.find({
+        _id: { $in: notificationUsers },
+        status: User.STATUS_ACTIVE,
+      }).distinct('_id');
+
+      if (getAdditionalTargetUsers == null) {
+        notificationTargetUsers?.push(...activeNotificationUsers);
+      }
+      else {
+        const AdditionalTargetUsers = getAdditionalTargetUsers(activity);
+
+        notificationTargetUsers?.push(
+          ...activeNotificationUsers,
+          ...AdditionalTargetUsers,
+        );
+      }
+
+    };
+
+    return preNotify;
+  };
+
+}
+
+export const preNotifyService = new PreNotifyService();

+ 3 - 7
apps/app/src/stores/modal.tsx

@@ -507,8 +507,6 @@ type HandsonTableModalSaveHandler = (table: MarkdownTable) => void;
 type HandsontableModalStatus = {
 type HandsontableModalStatus = {
   isOpened: boolean,
   isOpened: boolean,
   table: MarkdownTable,
   table: MarkdownTable,
-  // TODO: Define editor type
-  editor?: any,
   autoFormatMarkdownTable?: boolean,
   autoFormatMarkdownTable?: boolean,
   // onSave is passed only when editing table directly from the page.
   // onSave is passed only when editing table directly from the page.
   onSave?: HandsonTableModalSaveHandler
   onSave?: HandsonTableModalSaveHandler
@@ -517,7 +515,6 @@ type HandsontableModalStatus = {
 type HandsontableModalStatusUtils = {
 type HandsontableModalStatusUtils = {
   open(
   open(
     table: MarkdownTable,
     table: MarkdownTable,
-    editor?: any,
     autoFormatMarkdownTable?: boolean,
     autoFormatMarkdownTable?: boolean,
     onSave?: HandsonTableModalSaveHandler
     onSave?: HandsonTableModalSaveHandler
   ): void
   ): void
@@ -541,7 +538,6 @@ export const useHandsontableModal = (status?: HandsontableModalStatus): SWRRespo
   const initialData: HandsontableModalStatus = {
   const initialData: HandsontableModalStatus = {
     isOpened: false,
     isOpened: false,
     table: defaultMarkdownTable(),
     table: defaultMarkdownTable(),
-    editor: undefined,
     autoFormatMarkdownTable: false,
     autoFormatMarkdownTable: false,
   };
   };
 
 
@@ -549,14 +545,14 @@ export const useHandsontableModal = (status?: HandsontableModalStatus): SWRRespo
 
 
   const { mutate } = swrResponse;
   const { mutate } = swrResponse;
 
 
-  const open = useCallback((table: MarkdownTable, editor?: any, autoFormatMarkdownTable?: boolean, onSave?: HandsonTableModalSaveHandler): void => {
+  const open = useCallback((table: MarkdownTable, autoFormatMarkdownTable?: boolean, onSave?: HandsonTableModalSaveHandler): void => {
     mutate({
     mutate({
-      isOpened: true, table, editor, autoFormatMarkdownTable, onSave,
+      isOpened: true, table, autoFormatMarkdownTable, onSave,
     });
     });
   }, [mutate]);
   }, [mutate]);
   const close = useCallback((): void => {
   const close = useCallback((): void => {
     mutate({
     mutate({
-      isOpened: false, table: defaultMarkdownTable(), editor: undefined, autoFormatMarkdownTable: false, onSave: undefined,
+      isOpened: false, table: defaultMarkdownTable(), autoFormatMarkdownTable: false, onSave: undefined,
     });
     });
   }, [mutate]);
   }, [mutate]);
 
 

+ 0 - 5
apps/app/src/styles/_layout.scss

@@ -6,11 +6,6 @@
   @extend .flex-expand-vert;
   @extend .flex-expand-vert;
 }
 }
 
 
-.dynamic-layout-root.growi-layout-fluid .grw-container-convertible {
-  width: 100%;
-  max-width: none;
-}
-
 .grw-bg-image-wrapper {
 .grw-bg-image-wrapper {
   position: fixed;
   position: fixed;
   width: 100%;
   width: 100%;

+ 1 - 0
apps/app/src/styles/_mixins.scss

@@ -2,6 +2,7 @@
 @use './variables' as var;
 @use './variables' as var;
 
 
 @import './mixins/editing';
 @import './mixins/editing';
+@import './mixins/fluid-layout';
 @import './mixins/share-link';
 @import './mixins/share-link';
 
 
 @mixin variable-font-size($basesize) {
 @mixin variable-font-size($basesize) {

+ 4 - 0
apps/app/src/styles/mixins/_fluid-layout.scss

@@ -0,0 +1,4 @@
+@mixin fluid-layout() {
+  width: 100%;
+  max-width: none;
+}

+ 3 - 1
apps/app/test/cypress/e2e/50-sidebar/50-sidebar--access-to-side-bar.cy.ts

@@ -269,7 +269,9 @@ describe('Access to sidebar', () => {
         });
         });
 
 
         it('Succesfully click all tags button', () => {
         it('Succesfully click all tags button', () => {
-          cy.get('.grw-container-convertible > div > .btn-primary').click({force: true});
+          cy.getByTestid('grw-sidebar-content-tags').within(() => {
+            cy.get('.btn-primary').click({force: true});
+          });
           cy.collapseSidebar(true);
           cy.collapseSidebar(true);
           cy.getByTestid('grw-tags-list').should('be.visible');
           cy.getByTestid('grw-tags-list').should('be.visible');
 
 

+ 2 - 2
packages/editor/src/components/CodeMirrorEditor/CodeMirrorEditor.tsx

@@ -139,12 +139,12 @@ export const CodeMirrorEditor = (props: Props): JSX.Element => {
     }
     }
 
 
     return '';
     return '';
-  }, [isUploading, isDragAccept,isDragReject, acceptedFileType]);
+  }, [isUploading, isDragAccept, isDragReject, acceptedFileType]);
 
 
   return (
   return (
     <div className={`${style['codemirror-editor']} flex-expand-vert`}>
     <div className={`${style['codemirror-editor']} flex-expand-vert`}>
       <div {...getRootProps()} className={`dropzone ${fileUploadState} flex-expand-vert`}>
       <div {...getRootProps()} className={`dropzone ${fileUploadState} flex-expand-vert`}>
-        <FileDropzoneOverlay isEnabled={isDragActive}/>
+        <FileDropzoneOverlay isEnabled={isDragActive} />
         <CodeMirrorEditorContainer ref={containerRef} />
         <CodeMirrorEditorContainer ref={containerRef} />
         <Toolbar editorKey={editorKey} onFileOpen={open} acceptedFileType={acceptedFileType} />
         <Toolbar editorKey={editorKey} onFileOpen={open} acceptedFileType={acceptedFileType} />
       </div>
       </div>

+ 19 - 2
packages/editor/src/components/CodeMirrorEditor/Toolbar/TableButton.tsx

@@ -1,6 +1,23 @@
-export const TableButton = (): JSX.Element => {
+import { useCallback } from 'react';
+
+import { useCodeMirrorEditorIsolated } from '../../../stores';
+import { useHandsontableModalForEditor } from '../../../stores/use-handsontable';
+
+type Props = {
+  editorKey: string,
+}
+
+export const TableButton = (props: Props): JSX.Element => {
+  const { editorKey } = props;
+  const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(editorKey);
+  const { open: openTableModal } = useHandsontableModalForEditor();
+  const editor = codeMirrorEditor?.view;
+  const onClickTableButton = useCallback(() => {
+    openTableModal(editor);
+  }, [editor, openTableModal]);
+
   return (
   return (
-    <button type="button" className="btn btn-toolbar-button">
+    <button type="button" className="btn btn-toolbar-button" onClick={onClickTableButton}>
       <span className="material-symbols-outlined fs-5">table_chart</span>
       <span className="material-symbols-outlined fs-5">table_chart</span>
     </button>
     </button>
   );
   );

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

@@ -27,7 +27,7 @@ export const Toolbar = memo((props: Props): JSX.Element => {
       <EmojiButton
       <EmojiButton
         editorKey={editorKey}
         editorKey={editorKey}
       />
       />
-      <TableButton />
+      <TableButton editorKey={editorKey} />
       <DiagramButton />
       <DiagramButton />
       <TemplateButton />
       <TemplateButton />
     </div>
     </div>

+ 45 - 0
packages/editor/src/stores/use-handsontable.ts

@@ -0,0 +1,45 @@
+import { useCallback } from 'react';
+
+import { EditorView } from '@codemirror/view';
+import { useSWRStatic } from '@growi/core/dist/swr';
+import type { SWRResponse } from 'swr';
+
+type HandsontableModalStatus = {
+  isOpened: boolean,
+  editor?: EditorView,
+}
+
+type HandsontableModalStatusUtils = {
+  open(
+    editor?: EditorView,
+  ): void
+  close(): void
+}
+
+export const useHandsontableModalForEditor = (status?: HandsontableModalStatus): SWRResponse<HandsontableModalStatus, Error> & HandsontableModalStatusUtils => {
+  const initialData: HandsontableModalStatus = {
+    isOpened: false,
+    editor: undefined,
+  };
+
+  const swrResponse = useSWRStatic<HandsontableModalStatus, Error>('handsontableModalStatus', status, { fallbackData: initialData });
+
+  const { mutate } = swrResponse;
+
+  const open = useCallback((editor?: EditorView): void => {
+    mutate({
+      isOpened: true, editor,
+    });
+  }, [mutate]);
+  const close = useCallback((): void => {
+    mutate({
+      isOpened: false, editor: undefined,
+    });
+  }, [mutate]);
+
+  return {
+    ...swrResponse,
+    open,
+    close,
+  };
+};