فهرست منبع

Merge branch 'dev/7.0.x' into fix/141309-141311-multi-group-grant-page-becomes-public-when-one-of-groups-deleted

Futa Arai 2 سال پیش
والد
کامیت
5c9258f8a8
100فایلهای تغییر یافته به همراه903 افزوده شده و 419 حذف شده
  1. 1 1
      apps/app/package.json
  2. 1 1
      apps/app/public/static/locales/en_US/admin.json
  3. 12 0
      apps/app/public/static/locales/en_US/translation.json
  4. 1 1
      apps/app/public/static/locales/ja_JP/admin.json
  5. 12 0
      apps/app/public/static/locales/ja_JP/translation.json
  6. 1 1
      apps/app/public/static/locales/zh_CN/admin.json
  7. 12 0
      apps/app/public/static/locales/zh_CN/translation.json
  8. 1 1
      apps/app/src/client/services/create-page/use-create-template-page.ts
  9. 11 0
      apps/app/src/client/services/page-operation.ts
  10. 9 10
      apps/app/src/components/Admin/AuditLog/SearchUsernameTypeahead.tsx
  11. 5 1
      apps/app/src/components/Admin/Security/SamlSecuritySettingContents.jsx
  12. 1 1
      apps/app/src/components/Admin/Security/SecurityManagementContents.jsx
  13. 2 2
      apps/app/src/components/Admin/Security/SecuritySetting.jsx
  14. 9 3
      apps/app/src/components/Admin/SlackIntegration/Bridge.jsx
  15. 1 1
      apps/app/src/components/Admin/SlackIntegration/CustomBotWithoutProxySettingsAccordion.jsx
  16. 1 1
      apps/app/src/components/Admin/SlackIntegration/WithProxyAccordions.jsx
  17. 2 3
      apps/app/src/components/Admin/UserGroup/UserGroupModal.tsx
  18. 1 1
      apps/app/src/components/Admin/UserGroup/UserGroupTable.tsx
  19. 1 1
      apps/app/src/components/Admin/UserGroupDetail/UpdateParentConfirmModal.tsx
  20. 0 169
      apps/app/src/components/Admin/UserGroupDetail/UserGroupUserFormByInput.jsx
  21. 122 0
      apps/app/src/components/Admin/UserGroupDetail/UserGroupUserFormByInput.tsx
  22. 6 5
      apps/app/src/components/Admin/UserGroupDetail/UserGroupUserModal.tsx
  23. 2 2
      apps/app/src/components/Admin/UserGroupDetail/UserGroupUserTable.tsx
  24. 1 1
      apps/app/src/components/Admin/UserManagement.module.scss
  25. 4 3
      apps/app/src/components/Admin/UserManagement.tsx
  26. 1 1
      apps/app/src/components/Admin/Users/ExternalAccountTable.tsx
  27. 2 1
      apps/app/src/components/Admin/Users/UserMenu.tsx
  28. 1 1
      apps/app/src/components/Bookmarks/BookmarkFolderItemControl.tsx
  29. 0 4
      apps/app/src/components/Bookmarks/BookmarkFolderTree.module.scss
  30. 1 1
      apps/app/src/components/Bookmarks/BookmarkItem.tsx
  31. 3 2
      apps/app/src/components/Common/PagePathHierarchicalLink/PagePathHierarchicalLink.tsx
  32. 6 2
      apps/app/src/components/Common/PagePathNav/PagePathNav.tsx
  33. 4 2
      apps/app/src/components/ExpandOrContractButton.tsx
  34. 0 5
      apps/app/src/components/ItemsTree/ItemsTree.module.scss
  35. 4 2
      apps/app/src/components/ItemsTree/ItemsTree.tsx
  36. 1 1
      apps/app/src/components/Me/ExternalAccountLinkedMe.jsx
  37. 14 0
      apps/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  38. 3 1
      apps/app/src/components/Navbar/PageEditorModeManager.tsx
  39. 1 1
      apps/app/src/components/Page/PageView.tsx
  40. 2 0
      apps/app/src/components/PageAlert/PageAlerts.tsx
  41. 53 0
      apps/app/src/components/PageAlert/WipPageAlert.tsx
  42. 2 2
      apps/app/src/components/PageAttachment/DeleteAttachmentModal.tsx
  43. 3 3
      apps/app/src/components/PageComment/CommentEditor.tsx
  44. 2 2
      apps/app/src/components/PageComment/ReplyComments.tsx
  45. 1 1
      apps/app/src/components/PageControls/PageControls.tsx
  46. 1 1
      apps/app/src/components/PageEditor/Editor.tsx
  47. 1 1
      apps/app/src/components/PageEditor/HandsontableModal.tsx
  48. 16 3
      apps/app/src/components/PageEditor/PageEditor.tsx
  49. 1 0
      apps/app/src/components/PageEditor/Preview.module.scss
  50. 5 1
      apps/app/src/components/PageHeader/PageTitleHeader.tsx
  51. 2 1
      apps/app/src/components/PagePresentationModal.tsx
  52. 7 19
      apps/app/src/components/PageTags/TagsInput.tsx
  53. 29 1
      apps/app/src/components/SavePageControls.tsx
  54. 5 5
      apps/app/src/components/SavePageControls/GrantSelector/GrantSelector.tsx
  55. 11 8
      apps/app/src/components/SearchTypeahead.tsx
  56. 4 6
      apps/app/src/components/Sidebar/Bookmarks.tsx
  57. 4 6
      apps/app/src/components/Sidebar/Custom/CustomSidebar.tsx
  58. 13 4
      apps/app/src/components/Sidebar/Custom/CustomSidebarNotFound.tsx
  59. 1 1
      apps/app/src/components/Sidebar/Custom/CustomSidebarSubstance.tsx
  60. 3 5
      apps/app/src/components/Sidebar/InAppNotification/InAppNotification.tsx
  61. 1 1
      apps/app/src/components/Sidebar/PageCreateButton/hooks/use-create-new-page.ts
  62. 1 1
      apps/app/src/components/Sidebar/PageCreateButton/hooks/use-create-todays-memo.tsx
  63. 10 6
      apps/app/src/components/Sidebar/PageTree/PageTree.tsx
  64. 40 2
      apps/app/src/components/Sidebar/PageTree/PageTreeSubstance.tsx
  65. 3 2
      apps/app/src/components/Sidebar/PageTree/PrivateLegacyPagesLink.tsx
  66. 1 1
      apps/app/src/components/Sidebar/PageTreeItem/Ellipsis.tsx
  67. 2 2
      apps/app/src/components/Sidebar/PageTreeItem/PageTreeItem.module.scss
  68. 2 0
      apps/app/src/components/Sidebar/PageTreeItem/PageTreeItem.tsx
  69. 10 6
      apps/app/src/components/Sidebar/RecentChanges/RecentChanges.tsx
  70. 0 1
      apps/app/src/components/Sidebar/RecentChanges/RecentChangesSubstance.module.scss
  71. 55 18
      apps/app/src/components/Sidebar/RecentChanges/RecentChangesSubstance.tsx
  72. 3 0
      apps/app/src/components/Sidebar/SidebarContents.module.scss
  73. 1 1
      apps/app/src/components/Sidebar/SidebarHeaderReloadButton.tsx
  74. 7 8
      apps/app/src/components/Sidebar/Tag.tsx
  75. 1 1
      apps/app/src/components/TagList.tsx
  76. 1 1
      apps/app/src/components/TreeItem/NewPageInput/NewPageCreateButton.tsx
  77. 2 0
      apps/app/src/components/TreeItem/NewPageInput/use-new-page-input.tsx
  78. 12 4
      apps/app/src/components/TreeItem/SimpleItem.tsx
  79. 1 0
      apps/app/src/components/TreeItem/interfaces/index.ts
  80. 1 2
      apps/app/src/components/User/UserInfo.tsx
  81. 1 1
      apps/app/src/features/growi-plugin/client/components/Admin/PluginsExtensionPageContents/PluginsExtensionPageContents.tsx
  82. 10 3
      apps/app/src/features/search/client/components/SearchForm.tsx
  83. 10 10
      apps/app/src/features/search/client/components/SearchHelp.tsx
  84. 1 1
      apps/app/src/features/search/client/components/SearchMenuItem.tsx
  85. 22 16
      apps/app/src/features/search/client/components/SearchMethodMenuItem.tsx
  86. 4 3
      apps/app/src/features/search/client/components/SearchModal.tsx
  87. 5 6
      apps/app/src/features/search/client/components/SearchResultMenuItem.tsx
  88. 2 0
      apps/app/src/interfaces/page.ts
  89. 1 1
      apps/app/src/pages/installer.page.tsx
  90. 1 0
      apps/app/src/server/crowi/index.js
  91. 39 1
      apps/app/src/server/models/page.ts
  92. 12 3
      apps/app/src/server/routes/apiv3/page/create-page.ts
  93. 7 0
      apps/app/src/server/routes/apiv3/page/index.js
  94. 62 0
      apps/app/src/server/routes/apiv3/page/publish-page.ts
  95. 63 0
      apps/app/src/server/routes/apiv3/page/unpublish-page.ts
  96. 9 1
      apps/app/src/server/routes/apiv3/pages/index.js
  97. 8 3
      apps/app/src/server/service/config-loader.ts
  98. 6 6
      apps/app/src/server/service/page-operation.ts
  99. 57 1
      apps/app/src/server/service/page/index.ts
  100. 3 3
      apps/app/src/server/util/collect-ancestor-paths.ts

+ 1 - 1
apps/app/package.json

@@ -163,7 +163,7 @@
     "qs": "^6.11.1",
     "qs": "^6.11.1",
     "rate-limiter-flexible": "^2.3.7",
     "rate-limiter-flexible": "^2.3.7",
     "react": "^18.2.0",
     "react": "^18.2.0",
-    "react-bootstrap-typeahead": "^5.2.2",
+    "react-bootstrap-typeahead": "^6.3.2",
     "react-card-flip": "^1.0.10",
     "react-card-flip": "^1.0.10",
     "react-datepicker": "^4.7.0",
     "react-datepicker": "^4.7.0",
     "react-disable": "^0.1.1",
     "react-disable": "^0.1.1",

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

@@ -746,7 +746,7 @@
       "description1":"Temporarily issue new users by email addresses.",
       "description1":"Temporarily issue new users by email addresses.",
       "description2":"A temporary password will be generated for the first login.",
       "description2":"A temporary password will be generated for the first login.",
       "invite_thru_email": "Send invitation email",
       "invite_thru_email": "Send invitation email",
-      "mail_setting_link":"<i class='icon-settings me-2'></i><a href='/admin/app'>Email settings</a>",
+      "mail_setting_link":"<span className='material-symbols-outlined me-2'>settings</span><a href='/admin/app'>Email settings</a>",
       "valid_email": "Valid email address is required",
       "valid_email": "Valid email address is required",
       "temporary_password": "The created user has a temporary password",
       "temporary_password": "The created user has a temporary password",
       "send_new_password": "Please send the new password to the user.",
       "send_new_password": "Please send the new password to the user.",

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

@@ -826,5 +826,17 @@
   },
   },
   "page_select_modal": {
   "page_select_modal": {
     "select_page_location": "Select page location"
     "select_page_location": "Select page location"
+  },
+  "wip_page": {
+    "save_as_wip": "Save as WIP (Currently drafting)",
+    "success_save_as_wip": "Successfully saved as a WIP page",
+    "fail_save_as_wip": "Failed to save as a WIP page",
+    "alert": "This page is a work in progress",
+    "publish_page": "Publish page",
+    "success_publish_page": "Page has been published",
+    "fail_publish_page": "Failed to publish the Page"
+  },
+  "sidebar_header": {
+    "show_wip_page": "Show WIP"
   }
   }
 }
 }

+ 1 - 1
apps/app/public/static/locales/ja_JP/admin.json

@@ -756,7 +756,7 @@
       "description1": "メールアドレスを使用して新規ユーザーを仮発行します。",
       "description1": "メールアドレスを使用して新規ユーザーを仮発行します。",
       "description2": "初回のログイン時に使用する仮パスワードが生成されます。",
       "description2": "初回のログイン時に使用する仮パスワードが生成されます。",
       "invite_thru_email": "招待メールを送信する",
       "invite_thru_email": "招待メールを送信する",
-      "mail_setting_link": "<i class='icon-settings me-2'></i><a href='/admin/app'>メールの設定</a>",
+      "mail_setting_link": "<span className='material-symbols-outlined me-2'>settings</span><a href='/admin/app'>メールの設定</a>",
       "valid_email": "メールアドレスを入力してください。",
       "valid_email": "メールアドレスを入力してください。",
       "temporary_password": "作成したユーザーは仮パスワードが設定されています。",
       "temporary_password": "作成したユーザーは仮パスワードが設定されています。",
       "send_new_password": "新規発行したパスワードを、対象ユーザーへ連絡してください。",
       "send_new_password": "新規発行したパスワードを、対象ユーザーへ連絡してください。",

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

@@ -859,5 +859,17 @@
   },
   },
   "page_select_modal": {
   "page_select_modal": {
     "select_page_location": "ページの場所を選択"
     "select_page_location": "ページの場所を選択"
+  },
+  "wip_page": {
+    "save_as_wip": "WIP (執筆中) として保存",
+    "success_save_as_wip": "WIP ページとして保存しました",
+    "fail_save_as_wip": "WIP ページとして保存できませんでした",
+    "alert": "このページは作業途中です",
+    "publish_page": "WIP を解除",
+    "success_publish_page": "WIP を解除しました",
+    "fail_publish_page": "WIP を解除できませんでした"
+  },
+  "sidebar_header": {
+    "show_wip_page": "WIP を表示"
   }
   }
 }
 }

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

@@ -754,7 +754,7 @@
       "emails": "电子邮件",
       "emails": "电子邮件",
       "description1": "通过电子邮件地址临时发布新用户。",
       "description1": "通过电子邮件地址临时发布新用户。",
       "description2": "将为首次登录生成一个临时密码。",
       "description2": "将为首次登录生成一个临时密码。",
-      "mail_setting_link": "<i class='icon-settings me-2'></i><a href='/admin/app'>Email settings</a>",
+      "mail_setting_link": "<span className='material-symbols-outlined me-2'>settings</span><a href='/admin/app'>Email settings</a>",
       "valid_email": "需要有效的电子邮件地址",
       "valid_email": "需要有效的电子邮件地址",
       "invite_thru_email": "发送邀请电子邮件",
       "invite_thru_email": "发送邀请电子邮件",
       "temporary_password": "创建的用户具有临时密码",
       "temporary_password": "创建的用户具有临时密码",

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

@@ -829,5 +829,17 @@
   },
   },
   "page_select_modal": {
   "page_select_modal": {
     "select_page_location": "选择页面位置"
     "select_page_location": "选择页面位置"
+  },
+  "wip_page": {
+    "save_as_wip": "保存为 WIP(书面)",
+    "success_save_as_wip": "成功保存为 WIP 页面",
+    "fail_save_as_wip": "保存为 WIP 页失败",
+    "alert": "本页面正在制作中",
+    "publish_page": "发布 WIP",
+    "success_publish_page": "WIP 已停用",
+    "fail_publish_page": "无法停用 WIP"
+  },
+  "sidebar_header": {
+    "show_wip_page": "显示 WIP"
   }
   }
 }
 }

+ 1 - 1
apps/app/src/client/services/create-page/use-create-template-page.ts

@@ -25,7 +25,7 @@ export const useCreateTemplatePage: UseCreateTemplatePage = () => {
     if (isLoadingPagePath || !isCreatable) return;
     if (isLoadingPagePath || !isCreatable) return;
 
 
     return createAndTransit(
     return createAndTransit(
-      { path: normalizePath(`${currentPagePath}/${label}`) },
+      { path: normalizePath(`${currentPagePath}/${label}`), wip: false },
       { shouldCheckPageExists: true },
       { shouldCheckPageExists: true },
     );
     );
   }, [currentPagePath, isCreatable, isLoadingPagePath, createAndTransit]);
   }, [currentPagePath, isCreatable, isLoadingPagePath, createAndTransit]);

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

@@ -1,5 +1,6 @@
 import { useCallback } from 'react';
 import { useCallback } from 'react';
 
 
+import type { IPageHasId } from '@growi/core';
 import { SubscriptionStatusType } from '@growi/core';
 import { SubscriptionStatusType } from '@growi/core';
 import urljoin from 'url-join';
 import urljoin from 'url-join';
 
 
@@ -159,3 +160,13 @@ export const exist = async(path: string): Promise<PageExistResponse> => {
   const res = await apiv3Get<PageExistResponse>('/page/exist', { path });
   const res = await apiv3Get<PageExistResponse>('/page/exist', { path });
   return res.data;
   return res.data;
 };
 };
+
+export const publish = async(pageId: string): Promise<IPageHasId> => {
+  const res = await apiv3Put(`/page/${pageId}/publish`);
+  return res.data;
+};
+
+export const unpublish = async(pageId: string): Promise<IPageHasId> => {
+  const res = await apiv3Put(`/page/${pageId}/unpublish`);
+  return res.data;
+};

+ 9 - 10
apps/app/src/components/Admin/AuditLog/SearchUsernameTypeahead.tsx

@@ -1,11 +1,13 @@
+import type { ForwardRefRenderFunction } from 'react';
 import React, {
 import React, {
-  Fragment, useState, useCallback, useRef, ForwardRefRenderFunction, forwardRef, useImperativeHandle,
+  Fragment, useState, useCallback, forwardRef, useRef, useImperativeHandle,
 } from 'react';
 } from 'react';
 
 
+import type { TypeaheadRef } from 'react-bootstrap-typeahead';
 import { AsyncTypeahead, Menu, MenuItem } from 'react-bootstrap-typeahead';
 import { AsyncTypeahead, Menu, MenuItem } from 'react-bootstrap-typeahead';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 
 
-import { IClearable } from '~/client/interfaces/clearable';
+import type { IClearable } from '~/client/interfaces/clearable';
 import { useSWRxUsernames } from '~/stores/user';
 import { useSWRxUsernames } from '~/stores/user';
 
 
 
 
@@ -30,7 +32,7 @@ const SearchUsernameTypeaheadSubstance: ForwardRefRenderFunction<IClearable, Pro
   const { onChange } = props;
   const { onChange } = props;
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
-  const typeaheadRef = useRef<IClearable>(null);
+  const typeaheadRef = useRef<TypeaheadRef>(null);
 
 
   /*
   /*
    * State
    * State
@@ -41,11 +43,11 @@ const SearchUsernameTypeaheadSubstance: ForwardRefRenderFunction<IClearable, Pro
    * Fetch
    * Fetch
    */
    */
   const requestOptions = { isIncludeActiveUser: true, isIncludeInactiveUser: true, isIncludeActivitySnapshotUser: true };
   const requestOptions = { isIncludeActiveUser: true, isIncludeInactiveUser: true, isIncludeActivitySnapshotUser: true };
-  const { data: usernameData, error } = useSWRxUsernames(searchKeyword, 0, 5, requestOptions);
+  const { data: usernameData, error, isLoading: _isLoading } = useSWRxUsernames(searchKeyword, 0, 5, requestOptions);
   const activeUsernames = usernameData?.activeUser?.usernames != null ? usernameData.activeUser.usernames : [];
   const activeUsernames = usernameData?.activeUser?.usernames != null ? usernameData.activeUser.usernames : [];
   const inactiveUsernames = usernameData?.inactiveUser?.usernames != null ? usernameData.inactiveUser.usernames : [];
   const inactiveUsernames = usernameData?.inactiveUser?.usernames != null ? usernameData.inactiveUser.usernames : [];
   const activitySnapshotUsernames = usernameData?.activitySnapshotUser?.usernames != null ? usernameData.activitySnapshotUser.usernames : [];
   const activitySnapshotUsernames = usernameData?.activitySnapshotUser?.usernames != null ? usernameData.activitySnapshotUser.usernames : [];
-  const isLoading = usernameData === undefined && error == null;
+  const isLoading = _isLoading === true && error == null;
 
 
   const allUser: UserDataType[] = [];
   const allUser: UserDataType[] = [];
   const pushToAllUser = (usernames: string[], category: CategoryType) => {
   const pushToAllUser = (usernames: string[], category: CategoryType) => {
@@ -59,10 +61,8 @@ const SearchUsernameTypeaheadSubstance: ForwardRefRenderFunction<IClearable, Pro
    * Functions
    * Functions
    */
    */
   const changeHandler = useCallback((userData: UserDataType[]) => {
   const changeHandler = useCallback((userData: UserDataType[]) => {
-    if (onChange != null) {
-      const usernames = userData.map(user => user.username);
-      onChange(usernames);
-    }
+    const usernames = userData.map(user => user.username);
+    onChange(usernames);
   }, [onChange]);
   }, [onChange]);
 
 
   const searchHandler = useCallback((text: string) => {
   const searchHandler = useCallback((text: string) => {
@@ -120,7 +120,6 @@ const SearchUsernameTypeaheadSubstance: ForwardRefRenderFunction<IClearable, Pro
         delay={400}
         delay={400}
         minLength={0}
         minLength={0}
         placeholder={t('admin:audit_log_management.username')}
         placeholder={t('admin:audit_log_management.username')}
-        caseSensitive={false}
         isLoading={isLoading}
         isLoading={isLoading}
         options={allUser}
         options={allUser}
         onSearch={searchHandler}
         onSearch={searchHandler}

+ 5 - 1
apps/app/src/components/Admin/Security/SamlSecuritySettingContents.jsx

@@ -484,7 +484,11 @@ pWVdnzS1VCO8fKsJ7YYIr+JmHvseph3kFUOI5RqkCcMZlKUv83aUThsTHw==
                               aria-expanded="true"
                               aria-expanded="true"
                               aria-controls="ablchelp"
                               aria-controls="ablchelp"
                             >
                             >
-                              <i className={`icon-fw ${this.state.isHelpOpened ? 'icon-arrow-down' : 'icon-arrow-right'} small`}></i> Show more...
+                              <span
+                                className="material-symbols-outlined me-1"
+                                small
+                              >{this.state.isHelpOpened ? 'expand_more' : 'chevron_right'}
+                              </span> Show more...
                             </button>
                             </button>
                           </h2>
                           </h2>
                           <Collapse isOpen={this.state.isHelpOpened}>
                           <Collapse isOpen={this.state.isHelpOpened}>

+ 1 - 1
apps/app/src/components/Admin/Security/SecurityManagementContents.jsx

@@ -81,7 +81,7 @@ const SecurityManagementContents = () => {
             href="/admin/markdown/#preventXSS"
             href="/admin/markdown/#preventXSS"
             style={{ fontSize: 'large' }}
             style={{ fontSize: 'large' }}
           >
           >
-            <i className="fa-fw icon-login"></i> {t('security_settings.xss_prevent_setting_link')}
+            <span className="material-symbols-outlined me-1">login</span> {t('security_settings.xss_prevent_setting_link')}
           </Link>
           </Link>
         </div>
         </div>
       </div>
       </div>

+ 2 - 2
apps/app/src/components/Admin/Security/SecuritySetting.jsx

@@ -303,7 +303,7 @@ class SecuritySetting extends React.Component {
                     <div className="pb-4">
                     <div className="pb-4">
                       <p className="card custom-card">
                       <p className="card custom-card">
                         <span className="text-warning">
                         <span className="text-warning">
-                          <i className="icon-info"></i>
+                          <span className="material-symbols-outlined">info</span>
                           {/* eslint-disable-next-line react/no-danger */}
                           {/* eslint-disable-next-line react/no-danger */}
                           <span dangerouslySetInnerHTML={{ __html: t('security_settings.page_delete_rights_caution') }} />
                           <span dangerouslySetInnerHTML={{ __html: t('security_settings.page_delete_rights_caution') }} />
                         </span>
                         </span>
@@ -526,7 +526,7 @@ class SecuritySetting extends React.Component {
             <p className="form-text text-muted" dangerouslySetInnerHTML={{ __html: t('security_settings.max_age_desc') }} />
             <p className="form-text text-muted" dangerouslySetInnerHTML={{ __html: t('security_settings.max_age_desc') }} />
             <p className="card custom-card">
             <p className="card custom-card">
               <span className="text-warning">
               <span className="text-warning">
-                <i className="icon-info"></i> {t('security_settings.max_age_caution')}
+                <span className="material-symbols-outlined">info</span> {t('security_settings.max_age_caution')}
               </span>
               </span>
             </p>
             </p>
           </div>
           </div>

+ 9 - 3
apps/app/src/components/Admin/SlackIntegration/Bridge.jsx

@@ -15,14 +15,14 @@ const ProxyCircle = () => (
 
 
 const BridgeCore = (props) => {
 const BridgeCore = (props) => {
   const {
   const {
-    description, iconClass, hrClass, withProxy,
+    description, iconClass, iconName, hrClass, withProxy,
   } = props;
   } = props;
 
 
   return (
   return (
     <>
     <>
       <div id="grw-bridge-container" className={`grw-bridge-container ${withProxy ? 'with-proxy' : ''}`}>
       <div id="grw-bridge-container" className={`grw-bridge-container ${withProxy ? 'with-proxy' : ''}`}>
         <p className={`${withProxy ? 'mt-0' : 'mt-2'}`}>
         <p className={`${withProxy ? 'mt-0' : 'mt-2'}`}>
-          <i className={iconClass} />
+          <span className={iconClass}>{iconName}</span>
           <small
           <small
             className="ms-2 d-none d-lg-inline"
             className="ms-2 d-none d-lg-inline"
             // eslint-disable-next-line react/no-danger
             // eslint-disable-next-line react/no-danger
@@ -47,6 +47,7 @@ const BridgeCore = (props) => {
 BridgeCore.propTypes = {
 BridgeCore.propTypes = {
   description: PropTypes.string.isRequired,
   description: PropTypes.string.isRequired,
   iconClass: PropTypes.string.isRequired,
   iconClass: PropTypes.string.isRequired,
+  iconName: PropTypes.string.isRequired,
   hrClass: PropTypes.string.isRequired,
   hrClass: PropTypes.string.isRequired,
   withProxy: PropTypes.bool,
   withProxy: PropTypes.bool,
 };
 };
@@ -58,24 +59,28 @@ const Bridge = (props) => {
 
 
   let description;
   let description;
   let iconClass;
   let iconClass;
+  let iconName;
   let hrClass;
   let hrClass;
 
 
   // empty or all failed
   // empty or all failed
   if (totalCount === 0 || errorCount === totalCount) {
   if (totalCount === 0 || errorCount === totalCount) {
     description = t('admin:slack_integration.integration_sentence.integration_is_not_complete');
     description = t('admin:slack_integration.integration_sentence.integration_is_not_complete');
-    iconClass = 'icon-info text-danger';
+    iconClass = 'material-symbols-outlined text-danger';
+    iconName = 'info';
     hrClass = 'border-danger admin-border-failed';
     hrClass = 'border-danger admin-border-failed';
   }
   }
   // all green
   // all green
   else if (errorCount === 0) {
   else if (errorCount === 0) {
     description = t('admin:slack_integration.integration_sentence.integration_successful');
     description = t('admin:slack_integration.integration_sentence.integration_successful');
     iconClass = 'fa fa-check text-success';
     iconClass = 'fa fa-check text-success';
+    iconName = '';
     hrClass = 'border-success admin-border-success';
     hrClass = 'border-success admin-border-success';
   }
   }
   // some of them failed
   // some of them failed
   else {
   else {
     description = t('admin:slack_integration.integration_sentence.integration_some_ws_is_not_complete');
     description = t('admin:slack_integration.integration_sentence.integration_some_ws_is_not_complete');
     iconClass = 'fa fa-check text-warning';
     iconClass = 'fa fa-check text-warning';
+    iconName = '';
     hrClass = 'border-warning admin-border-failed';
     hrClass = 'border-warning admin-border-failed';
   }
   }
 
 
@@ -83,6 +88,7 @@ const Bridge = (props) => {
     <BridgeCore
     <BridgeCore
       description={description}
       description={description}
       iconClass={iconClass}
       iconClass={iconClass}
+      iconName={iconName}
       hrClass={hrClass}
       hrClass={hrClass}
       withProxy={withProxy}
       withProxy={withProxy}
     />
     />

+ 1 - 1
apps/app/src/components/Admin/SlackIntegration/CustomBotWithoutProxySettingsAccordion.jsx

@@ -142,7 +142,7 @@ const CustomBotWithoutProxySettingsAccordion = (props) => {
       >
       >
         <p className="text-center m-4">{t('admin:slack_integration.accordion.test_connection_by_pressing_button')}</p>
         <p className="text-center m-4">{t('admin:slack_integration.accordion.test_connection_by_pressing_button')}</p>
         <p className="text-center text-warning">
         <p className="text-center text-warning">
-          <i className="icon-info">{t('admin:slack_integration.accordion.test_connection_only_public_channel')}</i>
+          <span className="material-symbols-outlined">info</span>{t('admin:slack_integration.accordion.test_connection_only_public_channel')}
         </p>
         </p>
         <div className="d-flex justify-content-center">
         <div className="d-flex justify-content-center">
           <form className="align-items-center" onSubmit={e => submitForm(e)}>
           <form className="align-items-center" onSubmit={e => submitForm(e)}>

+ 1 - 1
apps/app/src/components/Admin/SlackIntegration/WithProxyAccordions.jsx

@@ -240,7 +240,7 @@ const TestProcess = ({
     <>
     <>
       <p className="text-center m-4">{t('admin:slack_integration.accordion.test_connection_by_pressing_button')}</p>
       <p className="text-center m-4">{t('admin:slack_integration.accordion.test_connection_by_pressing_button')}</p>
       <p className="text-center text-warning">
       <p className="text-center text-warning">
-        <i className="icon-info">{t('admin:slack_integration.accordion.test_connection_only_public_channel')}</i>
+        <span className="material-symbols-outlined me-1">info</span>{t('admin:slack_integration.accordion.test_connection_only_public_channel')}
       </p>
       </p>
       <div className="d-flex justify-content-center">
       <div className="d-flex justify-content-center">
         <form className="justify-content-center" onSubmit={e => submitForm(e)}>
         <form className="justify-content-center" onSubmit={e => submitForm(e)}>

+ 2 - 3
apps/app/src/components/Admin/UserGroup/UserGroupModal.tsx

@@ -1,6 +1,5 @@
-import React, {
-  FC, useState, useEffect, useCallback,
-} from 'react';
+import type { FC } from 'react';
+import React, { useState, useEffect, useCallback } from 'react';
 
 
 import type { Ref, IUserGroup, IUserGroupHasId } from '@growi/core';
 import type { Ref, IUserGroup, IUserGroupHasId } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';

+ 1 - 1
apps/app/src/components/Admin/UserGroup/UserGroupTable.tsx

@@ -205,7 +205,7 @@ export const UserGroupTable: FC<Props> = ({
                           className="btn btn-outline-secondary btn-sm dropdown-toggle"
                           className="btn btn-outline-secondary btn-sm dropdown-toggle"
                           data-bs-toggle="dropdown"
                           data-bs-toggle="dropdown"
                         >
                         >
-                          <i className="icon-settings"></i>
+                          <span className="material-symbols-outlined fs-5">settings</span>
                         </button>
                         </button>
                         <div className="dropdown-menu" role="menu" aria-labelledby={`admin-group-menu-button-${group._id}`}>
                         <div className="dropdown-menu" role="menu" aria-labelledby={`admin-group-menu-button-${group._id}`}>
                           <button className="dropdown-item" type="button" role="button" onClick={onClickEdit} data-user-group-id={group._id}>
                           <button className="dropdown-item" type="button" role="button" onClick={onClickEdit} data-user-group-id={group._id}>

+ 1 - 1
apps/app/src/components/Admin/UserGroupDetail/UpdateParentConfirmModal.tsx

@@ -28,7 +28,7 @@ export const UpdateParentConfirmModal: FC = () => {
   return (
   return (
     <Modal className="modal-md" isOpen={isOpened} toggle={closeModal}>
     <Modal className="modal-md" isOpen={isOpened} toggle={closeModal}>
       <ModalHeader tag="h4" toggle={closeModal} className="bg-warning text-light">
       <ModalHeader tag="h4" toggle={closeModal} className="bg-warning text-light">
-        <i className="icon icon-warning"></i> {t('admin:user_group_management.update_parent_confirm_modal.header')}
+        <span className="material-symbols-outlined">warning</span> {t('admin:user_group_management.update_parent_confirm_modal.header')}
       </ModalHeader>
       </ModalHeader>
       {
       {
         targetGroup != null && updateData != null ? (
         targetGroup != null && updateData != null ? (

+ 0 - 169
apps/app/src/components/Admin/UserGroupDetail/UserGroupUserFormByInput.jsx

@@ -1,169 +0,0 @@
-import React from 'react';
-
-import { UserPicture } from '@growi/ui/dist/components';
-import { useTranslation } from 'next-i18next';
-import PropTypes from 'prop-types';
-import { AsyncTypeahead } from 'react-bootstrap-typeahead';
-import { debounce } from 'throttle-debounce';
-
-import { toastSuccess, toastError } from '~/client/util/toastr';
-import Xss from '~/services/xss';
-
-class UserGroupUserFormByInput extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      keyword: '',
-      inputUser: '',
-      applicableUsers: [],
-      isLoading: false,
-      searchError: null,
-    };
-
-    this.xss = new Xss();
-
-    this.addUserBySubmit = this.addUserBySubmit.bind(this);
-    this.validateForm = this.validateForm.bind(this);
-    this.handleChange = this.handleChange.bind(this);
-    this.handleSearch = this.handleSearch.bind(this);
-    this.onKeyDown = this.onKeyDown.bind(this);
-    this.renderMenuItemChildren = this.renderMenuItemChildren.bind(this);
-
-    this.searhApplicableUsersDebounce = debounce(1000, this.searhApplicableUsers);
-  }
-
-  async addUserBySubmit() {
-    const { userGroup, onClickAddUserBtn } = this.props;
-
-    if (this.state.inputUser.length === 0) { return }
-    const userName = this.state.inputUser[0].username;
-
-    try {
-      await onClickAddUserBtn(userName);
-      toastSuccess(`Added "${this.xss.process(userName)}" to "${this.xss.process(userGroup.name)}"`);
-      this.setState({ inputUser: '' });
-    }
-    catch (err) {
-      toastError(new Error(`Unable to add "${this.xss.process(userName)}" to "${this.xss.process(userGroup.name)}"`));
-    }
-
-
-  }
-
-  validateForm() {
-    return this.state.inputUser !== '';
-  }
-
-  async searhApplicableUsers() {
-    const { onSearchApplicableUsers } = this.props;
-
-    try {
-      const users = await onSearchApplicableUsers(this.state.keyword);
-      this.setState({ applicableUsers: users, isLoading: false });
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }
-
-  /**
-   * Reflect when forecast is clicked
-   * @param {object} inputUser
-   */
-  handleChange(inputUser) {
-    this.setState({ inputUser });
-  }
-
-  handleSearch(keyword) {
-    if (keyword === '') {
-      return;
-    }
-
-    this.setState({ keyword, isLoading: true });
-    this.searhApplicableUsersDebounce();
-  }
-
-  onKeyDown(event) {
-    // 13 is Enter key
-    if (event.keyCode === 13) {
-      this.addUserBySubmit();
-    }
-  }
-
-  renderMenuItemChildren(option) {
-    const { isAlsoNameSearched, isAlsoMailSearched } = this.props;
-    const user = option;
-    return (
-      <>
-        <UserPicture user={user} size="sm" noLink noTooltip />
-        <strong className="ms-2">{user.username}</strong>
-        {isAlsoNameSearched && <span className="ms-2">{user.name}</span>}
-        {isAlsoMailSearched && <span className="ms-2">{user.email}</span>}
-      </>
-    );
-  }
-
-  getEmptyLabel() {
-    return (this.state.searchError !== null) && 'Error on searching.';
-  }
-
-  render() {
-    const { t } = this.props;
-
-    const inputProps = { autoComplete: 'off' };
-
-    return (
-      <div className="row">
-        <div className="col-8 pe-0">
-          <AsyncTypeahead
-            {...this.props}
-            id="name-typeahead-asynctypeahead"
-            ref={(c) => { this.typeahead = c }}
-            inputProps={inputProps}
-            isLoading={this.state.isLoading}
-            labelKey={user => `${user.username} ${user.name} ${user.email}`}
-            minLength={0}
-            options={this.state.applicableUsers} // Search result
-            searchText={(this.state.isLoading ? 'Searching...' : this.getEmptyLabel())}
-            renderMenuItemChildren={this.renderMenuItemChildren}
-            align="left"
-            onChange={this.handleChange}
-            onSearch={this.handleSearch}
-            onKeyDown={this.onKeyDown}
-            caseSensitive={false}
-            clearButton
-          />
-        </div>
-        <div className="col-2 ps-0">
-          <button
-            type="button"
-            className="btn btn-success"
-            disabled={!this.validateForm()}
-            onClick={this.addUserBySubmit}
-          >
-            {t('add')}
-          </button>
-        </div>
-      </div>
-    );
-  }
-
-}
-
-UserGroupUserFormByInput.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  isAlsoMailSearched: PropTypes.bool.isRequired,
-  isAlsoNameSearched: PropTypes.bool.isRequired,
-  onClickAddUserBtn: PropTypes.func,
-  onSearchApplicableUsers: PropTypes.func,
-  userGroup: PropTypes.object,
-};
-
-const UserGroupUserFormByInputWrapperFC = (props) => {
-  const { t } = useTranslation();
-  return <UserGroupUserFormByInput t={t} {...props} />;
-};
-
-export default UserGroupUserFormByInputWrapperFC;

+ 122 - 0
apps/app/src/components/Admin/UserGroupDetail/UserGroupUserFormByInput.tsx

@@ -0,0 +1,122 @@
+import type { FC, KeyboardEvent } from 'react';
+import React, { useState, useRef } from 'react';
+
+import type { IUserGroupHasId, IUserHasId } from '@growi/core';
+import { UserPicture } from '@growi/ui/dist/components';
+import { useTranslation } from 'next-i18next';
+import { AsyncTypeahead } from 'react-bootstrap-typeahead';
+
+import { toastSuccess, toastError } from '~/client/util/toastr';
+import type { SearchType } from '~/interfaces/user-group';
+import Xss from '~/services/xss';
+
+type Props = {
+  userGroup: IUserGroupHasId,
+  onClickAddUserBtn: (username: string) => Promise<void>,
+  onSearchApplicableUsers: (searchWord: string) => Promise<IUserHasId[]>,
+  isAlsoNameSearched: boolean,
+  isAlsoMailSearched: boolean,
+  searchType: SearchType,
+}
+
+export const UserGroupUserFormByInput: FC<Props> = (props) => {
+  const {
+    userGroup, onClickAddUserBtn, onSearchApplicableUsers, isAlsoNameSearched, isAlsoMailSearched, searchType,
+  } = props;
+
+  const { t } = useTranslation();
+  const typeaheadRef = useRef(null);
+  const [inputUser, setInputUser] = useState<IUserHasId[]>([]);
+  const [applicableUsers, setApplicableUsers] = useState<IUserHasId[]>([]);
+  const [isLoading, setIsLoading] = useState(false);
+  const [isSearchError, setIsSearchError] = useState(false);
+
+  const xss = new Xss();
+
+  const addUserBySubmit = async() => {
+    if (inputUser.length === 0) { return }
+    const userName = inputUser[0].username;
+
+    try {
+      await onClickAddUserBtn(userName);
+      toastSuccess(`Added "${xss.process(userName)}" to "${xss.process(userGroup.name)}"`);
+      setInputUser([]);
+    }
+    catch (err) {
+      toastError(new Error(`Unable to add "${xss.process(userName)}" to "${xss.process(userGroup.name)}"`));
+    }
+  };
+
+  const searchApplicableUsers = async(keyword: string) => {
+    try {
+      const users = await onSearchApplicableUsers(keyword);
+      setApplicableUsers(users);
+      setIsLoading(false);
+    }
+    catch (err) {
+      setIsSearchError(true);
+      toastError(err);
+    }
+  };
+
+  const handleChange = (inputUser: IUserHasId[]) => {
+    setInputUser(inputUser);
+  };
+
+  const handleSearch = async(keyword: string) => {
+    setIsLoading(true);
+    await searchApplicableUsers(keyword);
+  };
+
+  const onKeyDown = (event: KeyboardEvent) => {
+    if (event.key === 'Enter') {
+      addUserBySubmit();
+    }
+  };
+
+  const renderMenuItemChildren = (option: IUserHasId) => {
+    const user = option;
+
+    return (
+      <>
+        <UserPicture user={user} size="sm" noLink noTooltip />
+        <strong className="ms-2">{user.username}</strong>
+        {isAlsoNameSearched && <span className="ms-2">{user.name}</span>}
+        {isAlsoMailSearched && <span className="ms-2">{user.email}</span>}
+      </>
+    );
+  };
+
+  return (
+    <div className="row">
+      <div className="col-8 pe-0">
+        <AsyncTypeahead
+          key={`${searchType}-${isAlsoNameSearched}-${isAlsoMailSearched}`} // The searched keywords are not re-searched, so re-rendered by key.
+          id="name-typeahead-asynctypeahead"
+          inputProps={{ autoComplete: 'off' }}
+          isLoading={isLoading}
+          labelKey={(user: IUserHasId) => `${user.username} ${user.name} ${user.email}`}
+          options={applicableUsers} // Search result
+          onSearch={handleSearch}
+          onChange={handleChange}
+          onKeyDown={onKeyDown}
+          minLength={1}
+          searchText={isLoading ? 'Searching...' : (isSearchError && 'Error on searching.')}
+          renderMenuItemChildren={renderMenuItemChildren}
+          align="left"
+          clearButton
+        />
+      </div>
+      <div className="col-2 ps-0">
+        <button
+          type="button"
+          className="btn btn-success"
+          disabled={inputUser.length === 0}
+          onClick={addUserBySubmit}
+        >
+          {t('add')}
+        </button>
+      </div>
+    </div>
+  );
+};

+ 6 - 5
apps/app/src/components/Admin/UserGroupDetail/UserGroupUserModal.tsx

@@ -1,16 +1,17 @@
 import React from 'react';
 import React from 'react';
 
 
-import type { IUserGroupHasId } from '@growi/core';
+import type { IUserGroupHasId, IUserHasId } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import {
 import {
   Modal, ModalHeader, ModalBody,
   Modal, ModalHeader, ModalBody,
 } from 'reactstrap';
 } from 'reactstrap';
 
 
-import { SearchTypes, SearchType } from '~/interfaces/user-group';
+import type { SearchType } from '~/interfaces/user-group';
+import { SearchTypes } from '~/interfaces/user-group';
 
 
 import CheckBoxForSerchUserOption from './CheckBoxForSerchUserOption';
 import CheckBoxForSerchUserOption from './CheckBoxForSerchUserOption';
 import RadioButtonForSerchUserOption from './RadioButtonForSerchUserOption';
 import RadioButtonForSerchUserOption from './RadioButtonForSerchUserOption';
-import UserGroupUserFormByInput from './UserGroupUserFormByInput';
+import { UserGroupUserFormByInput } from './UserGroupUserFormByInput';
 
 
 type Props = {
 type Props = {
   isOpen: boolean,
   isOpen: boolean,
@@ -19,7 +20,7 @@ type Props = {
   isAlsoMailSearched: boolean,
   isAlsoMailSearched: boolean,
   isAlsoNameSearched: boolean,
   isAlsoNameSearched: boolean,
   onClickAddUserBtn: (username: string) => Promise<void>,
   onClickAddUserBtn: (username: string) => Promise<void>,
-  onSearchApplicableUsers: (searchWord: string) => Promise<void>,
+  onSearchApplicableUsers: (searchWord: string) => Promise<IUserHasId[]>,
   onSwitchSearchType: (searchType: SearchType) => void
   onSwitchSearchType: (searchType: SearchType) => void
   onClose: () => void,
   onClose: () => void,
   onToggleIsAlsoMailSearched: () => void,
   onToggleIsAlsoMailSearched: () => void,
@@ -54,9 +55,9 @@ const UserGroupUserModal = (props: Props): JSX.Element => {
             userGroup={userGroup}
             userGroup={userGroup}
             onClickAddUserBtn={onClickAddUserBtn}
             onClickAddUserBtn={onClickAddUserBtn}
             onSearchApplicableUsers={onSearchApplicableUsers}
             onSearchApplicableUsers={onSearchApplicableUsers}
-            onClose={onClose}
             isAlsoNameSearched={isAlsoNameSearched}
             isAlsoNameSearched={isAlsoNameSearched}
             isAlsoMailSearched={isAlsoMailSearched}
             isAlsoMailSearched={isAlsoMailSearched}
+            searchType={searchType}
           />
           />
         </div>
         </div>
         <h2 className="border-bottom">{t('admin:user_group_management.add_modal.search_option')}</h2>
         <h2 className="border-bottom">{t('admin:user_group_management.add_modal.search_option')}</h2>

+ 2 - 2
apps/app/src/components/Admin/UserGroupDetail/UserGroupUserTable.tsx

@@ -52,9 +52,9 @@ export const UserGroupUserTable = (props: Props): JSX.Element => {
                       type="button"
                       type="button"
                       id={`admin-group-menu-button-${relatedUser._id}`}
                       id={`admin-group-menu-button-${relatedUser._id}`}
                       className="btn btn-outline-secondary btn-sm dropdown-toggle"
                       className="btn btn-outline-secondary btn-sm dropdown-toggle"
-                      data-toggle="dropdown"
+                      data-bs-toggle="dropdown"
                     >
                     >
-                      <i className="icon-settings"></i>
+                      <span className="material-symbols-outlined fs-5">settings</span>
                     </button>
                     </button>
                     <div className="dropdown-menu" role="menu" aria-labelledby={`admin-group-menu-button-${relatedUser._id}`}>
                     <div className="dropdown-menu" role="menu" aria-labelledby={`admin-group-menu-button-${relatedUser._id}`}>
                       <button
                       <button

+ 1 - 1
apps/app/src/components/Admin/UserManagement.module.scss

@@ -12,7 +12,7 @@
   }
   }
   .search-clear {
   .search-clear {
     position: absolute;
     position: absolute;
-    top: 12px;
+    top: 15px;
     right: 1px;
     right: 1px;
     z-index: 3;
     z-index: 3;
     width: 24px;
     width: 24px;

+ 4 - 3
apps/app/src/components/Admin/UserManagement.tsx

@@ -149,15 +149,16 @@ const UserManagement = (props: UserManagementProps) => {
               {
               {
                 adminUsersContainer.state.searchText.length > 0
                 adminUsersContainer.state.searchText.length > 0
                   ? (
                   ? (
-                    <i
-                      className="icon-close search-clear"
+                    <span
+                      className="material-symbols-outlined me-1 search-clear"
                       onClick={async() => {
                       onClick={async() => {
                         await adminUsersContainer.clearSearchText();
                         await adminUsersContainer.clearSearchText();
                         if (inputRef.current != null) {
                         if (inputRef.current != null) {
                           inputRef.current.value = '';
                           inputRef.current.value = '';
                         }
                         }
                       }}
                       }}
-                    />
+                    >cancel
+                    </span>
                   )
                   )
                   : ''
                   : ''
               }
               }

+ 1 - 1
apps/app/src/components/Admin/Users/ExternalAccountTable.tsx

@@ -92,7 +92,7 @@ const ExternalAccountTable = (props: ExternalAccountTableProps): JSX.Element =>
                 <td>
                 <td>
                   <div className="btn-group admin-user-menu">
                   <div className="btn-group admin-user-menu">
                     <button type="button" className="btn btn-outline-secondary btn-sm dropdown-toggle" data-bs-toggle="dropdown">
                     <button type="button" className="btn btn-outline-secondary btn-sm dropdown-toggle" data-bs-toggle="dropdown">
-                      <i className="icon-settings"></i> <span className="caret"></span>
+                      <span className="material-symbols-outlined">settings</span> <span className="caret"></span>
                     </button>
                     </button>
                     <ul className="dropdown-menu" role="menu">
                     <ul className="dropdown-menu" role="menu">
                       <li className="dropdown-header">{t('user_management.user_table.edit_menu')}</li>
                       <li className="dropdown-header">{t('user_management.user_table.edit_menu')}</li>

+ 2 - 1
apps/app/src/components/Admin/Users/UserMenu.tsx

@@ -95,7 +95,8 @@ const UserMenu = (props: UserMenuProps) => {
   return (
   return (
     <UncontrolledDropdown id="userMenu" size="sm">
     <UncontrolledDropdown id="userMenu" size="sm">
       <DropdownToggle caret color="secondary" outline>
       <DropdownToggle caret color="secondary" outline>
-        <i className="icon-settings" />
+        {/* TODO:fontsize: 20px */}
+        <span className="material-symbols-outlined fs-5">settings</span>
         {(user.status === USER_STATUS.INVITED && !isInvitationEmailSended)
         {(user.status === USER_STATUS.INVITED && !isInvitationEmailSended)
         && <i className={`fa fa-circle text-danger grw-usermenu-notification-icon ${styles['grw-usermenu-notification-icon']}`} />}
         && <i className={`fa fa-circle text-danger grw-usermenu-notification-icon ${styles['grw-usermenu-notification-icon']}`} />}
       </DropdownToggle>
       </DropdownToggle>

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

@@ -23,7 +23,7 @@ export const BookmarkFolderItemControl: React.FC<{
     <Dropdown isOpen={isOpen} toggle={() => setIsOpen(!isOpen)}>
     <Dropdown isOpen={isOpen} toggle={() => setIsOpen(!isOpen)}>
       { children ?? (
       { children ?? (
         <DropdownToggle color="transparent" className="border-0 rounded btn-page-item-control d-flex align-items-center justify-content-center">
         <DropdownToggle color="transparent" className="border-0 rounded btn-page-item-control d-flex align-items-center justify-content-center">
-          <i className="icon-options"></i>
+          <span className="material-symbols-outlined">more_horiz</span>
         </DropdownToggle>
         </DropdownToggle>
       ) }
       ) }
       <DropdownMenu
       <DropdownMenu

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

@@ -24,10 +24,6 @@ $grw-bookmark-item-padding-left: 35px;
 
 
 .grw-foldertree :global {
 .grw-foldertree :global {
 
 
-  .btn-page-item-control .icon-plus::before {
-    font-size: 18px;
-  }
-
   .list-group-item {
   .list-group-item {
     .grw-visible-on-hover {
     .grw-visible-on-hover {
       display: none;
       display: none;

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

@@ -181,7 +181,7 @@ export const BookmarkItem = (props: Props): JSX.Element => {
               : undefined}
               : undefined}
           >
           >
             <DropdownToggle color="transparent" className="border-0 rounded btn-page-item-control p-0 grw-visible-on-hover me-1">
             <DropdownToggle color="transparent" className="border-0 rounded btn-page-item-control p-0 grw-visible-on-hover me-1">
-              <i className="icon-options fa fa-rotate-90 p-1"></i>
+              <span className="material-symbols-outlined p-1">more_vert</span>
             </DropdownToggle>
             </DropdownToggle>
           </PageItemControl>
           </PageItemControl>
         </div>
         </div>

+ 3 - 2
apps/app/src/components/Common/PagePathHierarchicalLink/PagePathHierarchicalLink.tsx

@@ -3,7 +3,7 @@ import React, { memo, useCallback } from 'react';
 import Link from 'next/link';
 import Link from 'next/link';
 import urljoin from 'url-join';
 import urljoin from 'url-join';
 
 
-import LinkedPagePath from '../../../models/linked-page-path';
+import type LinkedPagePath from '../../../models/linked-page-path';
 
 
 import styles from './PagePathHierarchicalLink.module.scss';
 import styles from './PagePathHierarchicalLink.module.scss';
 
 
@@ -51,7 +51,8 @@ export const PagePathHierarchicalLink = memo((props: PagePathHierarchicalLinkPro
         <RootElm>
         <RootElm>
           <span className="path-segment">
           <span className="path-segment">
             <Link href="/" prefetch={false}>
             <Link href="/" prefetch={false}>
-              <i className="icon-home"></i>
+              {/* TODO: Size adjust */}
+              <span className="material-symbols-outlined">home</span>
               <span className={`separator ${styles.separator}`}>/</span>
               <span className={`separator ${styles.separator}`}>/</span>
             </Link>
             </Link>
           </span>
           </span>

+ 6 - 2
apps/app/src/components/Common/PagePathNav/PagePathNav.tsx

@@ -20,6 +20,7 @@ const { isTrashPage } = pagePathUtils;
 type Props = {
 type Props = {
   pagePath: string,
   pagePath: string,
   pageId?: string | null,
   pageId?: string | null,
+  isWipPage?: boolean,
   isSingleLineMode?: boolean,
   isSingleLineMode?: boolean,
   isCollapseParents?: boolean,
   isCollapseParents?: boolean,
   formerLinkClassName?: string,
   formerLinkClassName?: string,
@@ -37,7 +38,7 @@ const Separator = (): JSX.Element => {
 
 
 export const PagePathNav: FC<Props> = (props: Props) => {
 export const PagePathNav: FC<Props> = (props: Props) => {
   const {
   const {
-    pageId, pagePath, isSingleLineMode, isCollapseParents,
+    pageId, pagePath, isWipPage, isSingleLineMode, isCollapseParents,
     formerLinkClassName, latterLinkClassName,
     formerLinkClassName, latterLinkClassName,
   } = props;
   } = props;
   const dPagePath = new DevidedPagePath(pagePath, false, true);
   const dPagePath = new DevidedPagePath(pagePath, false, true);
@@ -94,7 +95,10 @@ export const PagePathNav: FC<Props> = (props: Props) => {
           {latterLink}
           {latterLink}
         </h1>
         </h1>
         { pageId != null && !isNotFound && (
         { pageId != null && !isNotFound && (
-          <div className="mx-2">
+          <div className="d-flex align-items-center ms-2">
+            { isWipPage && (
+              <span className="badge rounded-pill text-bg-secondary ms-1 me-1">WIP</span>
+            )}
             <CopyDropdown pageId={pageId} pagePath={pagePath} dropdownToggleId={copyDropdownId} dropdownToggleClassName="p-2">
             <CopyDropdown pageId={pageId} pagePath={pagePath} dropdownToggleId={copyDropdownId} dropdownToggleClassName="p-2">
               <i className="ti ti-clipboard"></i>
               <i className="ti ti-clipboard"></i>
             </CopyDropdown>
             </CopyDropdown>

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

@@ -1,4 +1,5 @@
-import React, { FC } from 'react';
+import type { FC } from 'react';
+import React from 'react';
 
 
 type Props = {
 type Props = {
   isWindowExpanded: boolean,
   isWindowExpanded: boolean,
@@ -24,9 +25,10 @@ const ExpandOrContractButton: FC<Props> = (props: Props) => {
   return (
   return (
     <button
     <button
       type="button"
       type="button"
-      className={`btn ${isWindowExpanded ? 'icon-size-actual' : 'icon-size-fullscreen'}`}
+      className="btn material-symbols-outlined"
       onClick={isWindowExpanded ? clickContractButtonHandler : clickExpandButtonHandler}
       onClick={isWindowExpanded ? clickContractButtonHandler : clickExpandButtonHandler}
     >
     >
+      {isWindowExpanded ? 'close_fullscreen' : 'open_in_full'}
     </button>
     </button>
   );
   );
 };
 };

+ 0 - 5
apps/app/src/components/ItemsTree/ItemsTree.module.scss

@@ -17,11 +17,6 @@ $grw-pagetree-item-container-height: 40px;
   }
   }
 
 
   :global {
   :global {
-    .btn-page-item-control {
-      .icon-plus::before {
-        font-size: 18px;
-      }
-    }
 
 
     .list-group-item {
     .list-group-item {
       .grw-visible-on-hover {
       .grw-visible-on-hover {

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

@@ -91,6 +91,7 @@ const isSecondStageRenderingCondition = (condition: RenderingCondition|SecondSta
 type ItemsTreeProps = {
 type ItemsTreeProps = {
   isEnableActions: boolean
   isEnableActions: boolean
   isReadOnlyUser: boolean
   isReadOnlyUser: boolean
+  isWipPageShown?: boolean
   targetPath: string
   targetPath: string
   targetPathOrId?: Nullable<string>
   targetPathOrId?: Nullable<string>
   targetAndAncestorsData?: TargetAndAncestors
   targetAndAncestorsData?: TargetAndAncestors
@@ -103,7 +104,7 @@ type ItemsTreeProps = {
  */
  */
 export const ItemsTree = (props: ItemsTreeProps): JSX.Element => {
 export const ItemsTree = (props: ItemsTreeProps): JSX.Element => {
   const {
   const {
-    targetPath, targetPathOrId, targetAndAncestorsData, isEnableActions, isReadOnlyUser, CustomTreeItem, onClickTreeItem,
+    targetPath, targetPathOrId, targetAndAncestorsData, isEnableActions, isReadOnlyUser, isWipPageShown, CustomTreeItem, onClickTreeItem,
   } = props;
   } = props;
 
 
   const { t } = useTranslation();
   const { t } = useTranslation();
@@ -274,13 +275,14 @@ export const ItemsTree = (props: ItemsTreeProps): JSX.Element => {
 
 
   if (initialItemNode != null) {
   if (initialItemNode != null) {
     return (
     return (
-      <ul className={`grw-pagetree ${styles['grw-pagetree']} list-group py-3`} ref={rootElemRef}>
+      <ul className={`grw-pagetree ${styles['grw-pagetree']} list-group py-4`} ref={rootElemRef}>
         <CustomTreeItem
         <CustomTreeItem
           key={initialItemNode.page.path}
           key={initialItemNode.page.path}
           targetPathOrId={targetPathOrId}
           targetPathOrId={targetPathOrId}
           itemNode={initialItemNode}
           itemNode={initialItemNode}
           isOpen
           isOpen
           isEnableActions={isEnableActions}
           isEnableActions={isEnableActions}
+          isWipPageShown={isWipPageShown}
           isReadOnlyUser={isReadOnlyUser}
           isReadOnlyUser={isReadOnlyUser}
           onRenamed={onRenamed}
           onRenamed={onRenamed}
           onClickDuplicateMenuItem={onClickDuplicateMenuItem}
           onClickDuplicateMenuItem={onClickDuplicateMenuItem}

+ 1 - 1
apps/app/src/components/Me/ExternalAccountLinkedMe.jsx

@@ -64,7 +64,7 @@ class ExternalAccountLinkedMe extends React.Component {
             className="btn btn-outline-secondary btn-sm pull-right"
             className="btn btn-outline-secondary btn-sm pull-right"
             onClick={this.openAssociateModal}
             onClick={this.openAssociateModal}
           >
           >
-            <i className="icon-plus" aria-hidden="true" />
+            <span className="material-symbols-outlined" aria-hidden="true">add_circle</span>
             Add
             Add
           </button>
           </button>
           { t('admin:user_management.external_accounts') }
           { t('admin:user_management.external_accounts') }

+ 14 - 0
apps/app/src/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -8,6 +8,7 @@ import type {
 import { pagePathUtils } from '@growi/core/dist/utils';
 import { pagePathUtils } from '@growi/core/dist/utils';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import dynamic from 'next/dynamic';
 import dynamic from 'next/dynamic';
+import Link from 'next/link';
 import { useRouter } from 'next/router';
 import { useRouter } from 'next/router';
 import { DropdownItem } from 'reactstrap';
 import { DropdownItem } from 'reactstrap';
 
 
@@ -167,6 +168,8 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
 
 
   const { currentPage } = props;
   const { currentPage } = props;
 
 
+  const { t } = useTranslation();
+
   const router = useRouter();
   const router = useRouter();
 
 
   const { data: shareLinkId } = useShareLinkId();
   const { data: shareLinkId } = useShareLinkId();
@@ -317,6 +320,17 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
             // grantUserGroupId={grantUserGroupId}
             // grantUserGroupId={grantUserGroupId}
           />
           />
         )}
         )}
+
+        { isGuestUser && (
+          <>
+            <Link href="/login#register" className="btn" prefetch={false}>
+              <span className="material-symbols-outlined me-1">person_add</span>{t('Sign up')}
+            </Link>
+            <Link href="/login#login" className="btn btn-primary" prefetch={false}>
+              <span className="material-symbols-outlined me-1">login</span>{t('Sign in')}
+            </Link>
+          </>
+        ) }
       </div>
       </div>
 
 
       {path != null && currentUser != null && !isReadOnlyUser && (
       {path != null && currentUser != null && !isReadOnlyUser && (

+ 3 - 1
apps/app/src/components/Navbar/PageEditorModeManager.tsx

@@ -7,6 +7,8 @@ import { toastError } from '~/client/util/toastr';
 import { useIsNotFound } from '~/stores/page';
 import { useIsNotFound } from '~/stores/page';
 import { EditorMode, useEditorMode, useIsDeviceLargerThanMd } from '~/stores/ui';
 import { EditorMode, useEditorMode, useIsDeviceLargerThanMd } from '~/stores/ui';
 
 
+import { shouldCreateWipPage } from '../../utils/should-create-wip-page';
+
 
 
 import styles from './PageEditorModeManager.module.scss';
 import styles from './PageEditorModeManager.module.scss';
 
 
@@ -72,7 +74,7 @@ export const PageEditorModeManager = (props: Props): JSX.Element => {
 
 
     try {
     try {
       await createAndTransit(
       await createAndTransit(
-        { path },
+        { path, wip: shouldCreateWipPage(path) },
         { shouldCheckPageExists: true },
         { shouldCheckPageExists: true },
       );
       );
     }
     }

+ 1 - 1
apps/app/src/components/Page/PageView.tsx

@@ -103,7 +103,7 @@ export const PageView = (props: Props): JSX.Element => {
   }, [isForbidden, isIdenticalPathPage, isNotCreatable]);
   }, [isForbidden, isIdenticalPathPage, isNotCreatable]);
 
 
   const headerContents = (
   const headerContents = (
-    <PagePathNavSticky pageId={page?._id} pagePath={pagePath} />
+    <PagePathNavSticky pageId={page?._id} pagePath={pagePath} isWipPage={page?.wip} />
   );
   );
 
 
   const sideContents = !isNotFound && !isNotCreatable
   const sideContents = !isNotFound && !isNotCreatable

+ 2 - 0
apps/app/src/components/PageAlert/PageAlerts.tsx

@@ -8,6 +8,7 @@ import { OldRevisionAlert } from './OldRevisionAlert';
 import { PageGrantAlert } from './PageGrantAlert';
 import { PageGrantAlert } from './PageGrantAlert';
 import { PageRedirectedAlert } from './PageRedirectedAlert';
 import { PageRedirectedAlert } from './PageRedirectedAlert';
 import { PageStaleAlert } from './PageStaleAlert';
 import { PageStaleAlert } from './PageStaleAlert';
+import { WipPageAlert } from './WipPageAlert';
 
 
 const FixPageGrantAlert = dynamic(() => import('./FixPageGrantAlert').then(mod => mod.FixPageGrantAlert), { ssr: false });
 const FixPageGrantAlert = dynamic(() => import('./FixPageGrantAlert').then(mod => mod.FixPageGrantAlert), { ssr: false });
 // dynamic import because TrashPageAlert uses localStorageMiddleware
 // dynamic import because TrashPageAlert uses localStorageMiddleware
@@ -22,6 +23,7 @@ export const PageAlerts = (): JSX.Element => {
       <div className="col-sm-12">
       <div className="col-sm-12">
         {/* alerts */}
         {/* alerts */}
         { !isNotFound && <FixPageGrantAlert /> }
         { !isNotFound && <FixPageGrantAlert /> }
+        <WipPageAlert />
         <PageGrantAlert />
         <PageGrantAlert />
         <TrashPageAlert />
         <TrashPageAlert />
         <PageStaleAlert />
         <PageStaleAlert />

+ 53 - 0
apps/app/src/components/PageAlert/WipPageAlert.tsx

@@ -0,0 +1,53 @@
+import React, { useCallback } from 'react';
+
+import { useTranslation } from 'react-i18next';
+
+import { toastSuccess, toastError } from '~/client/util/toastr';
+import { useSWRMUTxCurrentPage, useSWRxCurrentPage } from '~/stores/page';
+import { mutatePageTree } from '~/stores/page-listing';
+
+import { publish } from '../../client/services/page-operation';
+
+
+export const WipPageAlert = (): JSX.Element => {
+  const { t } = useTranslation();
+  const { data: currentPage } = useSWRxCurrentPage();
+  const { trigger: mutateCurrentPage } = useSWRMUTxCurrentPage();
+
+  const clickPagePublishButton = useCallback(async() => {
+    const pageId = currentPage?._id;
+
+    if (pageId == null) {
+      return;
+    }
+
+    try {
+      await publish(pageId);
+      await mutateCurrentPage();
+      await mutatePageTree();
+      toastSuccess(t('wip_page.success_publish_page'));
+    }
+    catch {
+      toastError(t('wip_page.fail_publish_page'));
+    }
+  }, [currentPage?._id, mutateCurrentPage, t]);
+
+
+  if (!currentPage?.wip) {
+    return <></>;
+  }
+
+  return (
+    <p className="d-flex align-items-center alert alert-secondary py-3 px-4">
+      <span className="material-symbols-outlined me-1 fs-5">info</span>
+      <span>{t('wip_page.alert')}</span>
+      <button
+        type="button"
+        className="btn btn-outline-secondary ms-auto"
+        onClick={clickPagePublishButton}
+      >
+        {t('wip_page.publish_page') }
+      </button>
+    </p>
+  );
+};

+ 2 - 2
apps/app/src/components/PageAttachment/DeleteAttachmentModal.tsx

@@ -20,7 +20,7 @@ import styles from './DeleteAttachmentModal.module.scss';
 const logger = loggerFactory('growi:attachmentDelete');
 const logger = loggerFactory('growi:attachmentDelete');
 
 
 const iconByFormat = (format: string): string => {
 const iconByFormat = (format: string): string => {
-  return format.match(/image\/.+/i) ? 'icon-picture' : 'icon-doc';
+  return format.match(/image\/.+/i) ? 'image' : 'description';
 };
 };
 
 
 export const DeleteAttachmentModal: React.FC = () => {
 export const DeleteAttachmentModal: React.FC = () => {
@@ -73,7 +73,7 @@ export const DeleteAttachmentModal: React.FC = () => {
     return (
     return (
       <div className="attachment-delete-image">
       <div className="attachment-delete-image">
         <p>
         <p>
-          <i className={iconByFormat(attachment.fileFormat)}></i> {attachment.originalName}
+          <span className="material-symbols-outlined">{iconByFormat(attachment.fileFormat)}</span> {attachment.originalName}
         </p>
         </p>
         <p>
         <p>
           uploaded by <UserPicture user={attachment.creator} size="sm"></UserPicture> <Username user={attachment.creator as IUser}></Username>
           uploaded by <UserPicture user={attachment.creator} size="sm"></UserPicture> <Username user={attachment.creator as IUser}></Username>

+ 3 - 3
apps/app/src/components/PageComment/CommentEditor.tsx

@@ -44,11 +44,11 @@ const SlackNotification = dynamic(() => import('../SlackNotification').then(mod
 
 
 const navTabMapping = {
 const navTabMapping = {
   comment_editor: {
   comment_editor: {
-    Icon: () => <i className="icon-settings" />,
+    Icon: () => <span className="material-symbols-outlined">edit_square</span>,
     i18n: 'Write',
     i18n: 'Write',
   },
   },
   comment_preview: {
   comment_preview: {
-    Icon: () => <i className="icon-settings" />,
+    Icon: () => <span className="material-symbols-outlined">play_arrow</span>,
     i18n: 'Preview',
     i18n: 'Preview',
   },
   },
 };
 };
@@ -263,7 +263,7 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
               onClick={() => setIsReadyToUse(true)}
               onClick={() => setIsReadyToUse(true)}
               data-testid="open-comment-editor-button"
               data-testid="open-comment-editor-button"
             >
             >
-              <i className="icon-bubble"></i> Add Comment
+              <span className="material-symbols-outlined">comment</span> Add Comment
             </button>
             </button>
           </NotAvailableForReadOnlyUser>
           </NotAvailableForReadOnlyUser>
         </NotAvailableForGuest>
         </NotAvailableForGuest>

+ 2 - 2
apps/app/src/components/PageComment/ReplyComments.tsx

@@ -68,8 +68,8 @@ export const ReplyComments = (props: ReplycommentsProps): JSX.Element => {
   }
   }
 
 
   const areThereHiddenReplies = (replyList.length > 2);
   const areThereHiddenReplies = (replyList.length > 2);
-  const toggleButtonIconName = isOlderRepliesShown ? 'icon-arrow-up' : 'icon-options-vertical';
-  const toggleButtonIcon = <i className={`icon-fw ${toggleButtonIconName}`}></i>;
+  const toggleButtonIconName = isOlderRepliesShown ? 'expand_less' : 'more_vert';
+  const toggleButtonIcon = <span className="material-icons-outlined me-1">{toggleButtonIconName}</span>;
   const toggleButtonLabel = isOlderRepliesShown ? '' : 'more';
   const toggleButtonLabel = isOlderRepliesShown ? '' : 'more';
   const shownReplies = replyList.slice(replyList.length - 2, replyList.length);
   const shownReplies = replyList.slice(replyList.length - 2, replyList.length);
   const hiddenReplies = replyList.slice(0, replyList.length - 2);
   const hiddenReplies = replyList.slice(0, replyList.length - 2);

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

@@ -52,7 +52,7 @@ const Tags = (props: TagsProps): JSX.Element => {
         className="btn btn-link btn-edit-tags text-muted border border-secondary p-1 d-flex align-items-center"
         className="btn btn-link btn-edit-tags text-muted border border-secondary p-1 d-flex align-items-center"
         onClick={onClickEditTagsButton}
         onClick={onClickEditTagsButton}
       >
       >
-        <i className="icon-tag me-2" />
+        <span className="material-symbols-outlined me-2">local_offer</span>
         Tags
         Tags
       </button>
       </button>
     </div>
     </div>

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

@@ -343,7 +343,7 @@ const Editor: ForwardRefRenderFunction<IEditorMethods, EditorPropsType> = (props
               className="btn btn-outline-secondary btn-open-dropzone"
               className="btn btn-outline-secondary btn-open-dropzone"
               onClick={addAttachmentHandler}
               onClick={addAttachmentHandler}
             >
             >
-              <i className="icon-paper-clip" aria-hidden="true"></i>&nbsp;
+              <span className="material-symbols-outlined" aria-hidden="true">attachment</span>&nbsp;
               Attach files
               Attach files
               <span className="d-none d-sm-inline">
               <span className="d-none d-sm-inline">
               &nbsp;by dragging &amp; dropping,&nbsp;
               &nbsp;by dragging &amp; dropping,&nbsp;

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

@@ -2,7 +2,7 @@ import React, { useState } from 'react';
 
 
 import { useHandsontableModalForEditor } from '@growi/editor/src/stores/use-handsontable';
 import { useHandsontableModalForEditor } from '@growi/editor/src/stores/use-handsontable';
 import { HotTable } from '@handsontable/react';
 import { HotTable } from '@handsontable/react';
-import Handsontable from 'handsontable';
+import type Handsontable from 'handsontable';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import {
 import {
   Collapse,
   Collapse,

+ 16 - 3
apps/app/src/components/PageEditor/PageEditor.tsx

@@ -200,9 +200,9 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
   }, [socket, checkIsConflict]);
   }, [socket, checkIsConflict]);
 
 
   const save = useCallback(async(opts?: {slackChannels: string, overwriteScopesOfDescendants?: boolean}): Promise<IPageHasId | null> => {
   const save = useCallback(async(opts?: {slackChannels: string, overwriteScopesOfDescendants?: boolean}): Promise<IPageHasId | null> => {
-    if (pageId == null || currentPagePath == null || currentRevisionId == null || grantData == null) {
+    if (pageId == null || currentRevisionId == null || grantData == null) {
       logger.error('Some materials to save are invalid', {
       logger.error('Some materials to save are invalid', {
-        pageId, currentPagePath, currentRevisionId, grantData,
+        pageId, currentRevisionId, grantData,
       });
       });
       throw new Error('Some materials to save are invalid');
       throw new Error('Some materials to save are invalid');
     }
     }
@@ -242,7 +242,7 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
     }
     }
 
 
   // eslint-disable-next-line max-len
   // eslint-disable-next-line max-len
-  }, [codeMirrorEditor, grantData, pageId, currentPagePath, currentRevisionId, mutateWaitingSaveProcessing, mutateRemotePageId, mutateRemoteRevisionId, mutateRemoteRevisionLastUpdatedAt, mutateRemoteRevisionLastUpdateUser]);
+  }, [codeMirrorEditor, grantData, pageId, currentRevisionId, mutateWaitingSaveProcessing, mutateRemotePageId, mutateRemoteRevisionId, mutateRemoteRevisionLastUpdatedAt, mutateRemoteRevisionLastUpdateUser]);
 
 
   const saveAndReturnToViewHandler = useCallback(async(opts: {slackChannels: string, overwriteScopesOfDescendants?: boolean}) => {
   const saveAndReturnToViewHandler = useCallback(async(opts: {slackChannels: string, overwriteScopesOfDescendants?: boolean}) => {
     const page = await save(opts);
     const page = await save(opts);
@@ -390,6 +390,19 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
     }
     }
   }, [initialValue, isIndentSizeForced, mutateCurrentIndentSize]);
   }, [initialValue, isIndentSizeForced, mutateCurrentIndentSize]);
 
 
+  // set handler to set caret line
+  useEffect(() => {
+    const handler = (lineNumber?: number) => {
+      codeMirrorEditor?.setCaretLine(lineNumber);
+
+      // TODO: scroll to the caret line
+    };
+    globalEmitter.on('setCaretLine', handler);
+
+    return function cleanup() {
+      globalEmitter.removeListener('setCaretLine', handler);
+    };
+  }, [codeMirrorEditor]);
 
 
   // TODO: Check the reproduction conditions that made this code necessary and confirm reproduction
   // TODO: Check the reproduction conditions that made this code necessary and confirm reproduction
   // // when transitioning to a different page, if the initialValue is the same,
   // // when transitioning to a different page, if the initialValue is the same,

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

@@ -3,6 +3,7 @@
 .page-editor-preview-body :global {
 .page-editor-preview-body :global {
   .wiki {
   .wiki {
     max-width: 980px;
     max-width: 980px;
+    padding: 0px 15px;
     margin: 0 auto;
     margin: 0 auto;
   }
   }
 }
 }

+ 5 - 1
apps/app/src/components/PageHeader/PageTitleHeader.tsx

@@ -92,7 +92,11 @@ export const PageTitleHeader: FC<Props> = (props) => {
         </h1>
         </h1>
       </div>
       </div>
 
 
-      <div className={`${isRenameInputShown ? 'invisible' : ''}`}>
+      <div className={`${isRenameInputShown ? 'invisible' : ''} d-flex align-items-center`}>
+        { currentPage.wip && (
+          <span className="badge rounded-pill text-bg-secondary ms-2">WIP</span>
+        )}
+
         <CopyDropdown
         <CopyDropdown
           pageId={currentPage._id}
           pageId={currentPage._id}
           pagePath={currentPage.path}
           pagePath={currentPage.path}

+ 2 - 1
apps/app/src/components/PagePresentationModal.tsx

@@ -71,11 +71,12 @@ const PagePresentationModal = (): JSX.Element => {
     >
     >
       <div className="grw-presentation-controls d-flex">
       <div className="grw-presentation-controls d-flex">
         <button
         <button
-          className={`btn ${fullscreen.active ? 'icon-size-actual' : 'icon-size-fullscreen'}`}
+          className="btn material-symbols-outlined"
           type="button"
           type="button"
           aria-label="fullscreen"
           aria-label="fullscreen"
           onClick={toggleFullscreenHandler}
           onClick={toggleFullscreenHandler}
         >
         >
+          {fullscreen.active ? 'close_fullscreen' : 'open_in_full'}
         </button>
         </button>
         <button className="btn-close" type="button" aria-label="Close" onClick={closeHandler}></button>
         <button className="btn-close" type="button" aria-label="Close" onClick={closeHandler}></button>
       </div>
       </div>

+ 7 - 19
apps/app/src/components/PageTags/TagsInput.tsx

@@ -1,19 +1,12 @@
-import React, {
-  FC, useRef, useState, useCallback,
-} from 'react';
+import type { FC, KeyboardEvent } from 'react';
+import React, { useRef, useState, useCallback } from 'react';
 
 
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
+import type { TypeaheadRef } from 'react-bootstrap-typeahead';
 import { AsyncTypeahead } from 'react-bootstrap-typeahead';
 import { AsyncTypeahead } from 'react-bootstrap-typeahead';
 
 
 import { useSWRxTagsSearch } from '~/stores/tag';
 import { useSWRxTagsSearch } from '~/stores/tag';
 
 
-type TypeaheadInstance = {
-  _handleMenuItemSelect: (activeItem: string, event: React.KeyboardEvent) => void,
-  state: {
-    initialItem: string,
-  },
-}
-
 type Props = {
 type Props = {
   tags: string[],
   tags: string[],
   autoFocus: boolean,
   autoFocus: boolean,
@@ -24,7 +17,7 @@ export const TagsInput: FC<Props> = (props: Props) => {
   const { t } = useTranslation();
   const { t } = useTranslation();
   const { tags, autoFocus, onTagsUpdated } = props;
   const { tags, autoFocus, onTagsUpdated } = props;
 
 
-  const tagsInputRef = useRef<TypeaheadInstance>(null);
+  const tagsInputRef = useRef<TypeaheadRef>(null);
   const [resultTags, setResultTags] = useState<string[]>([]);
   const [resultTags, setResultTags] = useState<string[]>([]);
   const [searchQuery, setSearchQuery] = useState('');
   const [searchQuery, setSearchQuery] = useState('');
 
 
@@ -33,20 +26,17 @@ export const TagsInput: FC<Props> = (props: Props) => {
   const isLoading = error == null && tagsSearch === undefined;
   const isLoading = error == null && tagsSearch === undefined;
 
 
   const changeHandler = useCallback((selected: string[]) => {
   const changeHandler = useCallback((selected: string[]) => {
-    if (onTagsUpdated != null) {
-      onTagsUpdated(selected);
-    }
+    onTagsUpdated(selected);
   }, [onTagsUpdated]);
   }, [onTagsUpdated]);
 
 
-  const searchHandler = useCallback(async(query: string) => {
+  const searchHandler = useCallback((query: string) => {
     const tagsSearchData = tagsSearch?.tags || [];
     const tagsSearchData = tagsSearch?.tags || [];
     setSearchQuery(query);
     setSearchQuery(query);
     tagsSearchData.unshift(query);
     tagsSearchData.unshift(query);
     setResultTags(Array.from(new Set(tagsSearchData)));
     setResultTags(Array.from(new Set(tagsSearchData)));
-
   }, [tagsSearch?.tags]);
   }, [tagsSearch?.tags]);
 
 
-  const keyDownHandler = useCallback((event: React.KeyboardEvent) => {
+  const keyDownHandler = useCallback((event: KeyboardEvent<HTMLElement>) => {
     if (event.key === ' ') {
     if (event.key === ' ') {
       event.preventDefault();
       event.preventDefault();
 
 
@@ -64,12 +54,10 @@ export const TagsInput: FC<Props> = (props: Props) => {
       <AsyncTypeahead
       <AsyncTypeahead
         id="tag-typeahead-asynctypeahead"
         id="tag-typeahead-asynctypeahead"
         ref={tagsInputRef}
         ref={tagsInputRef}
-        caseSensitive={false}
         defaultSelected={tags}
         defaultSelected={tags}
         isLoading={isLoading}
         isLoading={isLoading}
         minLength={1}
         minLength={1}
         multiple
         multiple
-        newSelectionPrefix=""
         onChange={changeHandler}
         onChange={changeHandler}
         onSearch={searchHandler}
         onSearch={searchHandler}
         onKeyDown={keyDownHandler}
         onKeyDown={keyDownHandler}

+ 29 - 1
apps/app/src/components/SavePageControls.tsx

@@ -9,15 +9,19 @@ import {
   DropdownToggle, DropdownMenu, DropdownItem,
   DropdownToggle, DropdownMenu, DropdownItem,
 } from 'reactstrap';
 } from 'reactstrap';
 
 
+import { toastSuccess, toastError } from '~/client/util/toastr';
 import type { IPageGrantData } from '~/interfaces/page';
 import type { IPageGrantData } from '~/interfaces/page';
 import {
 import {
   useIsEditable, useIsAclEnabled,
   useIsEditable, useIsAclEnabled,
 } from '~/stores/context';
 } from '~/stores/context';
 import { useWaitingSaveProcessing } from '~/stores/editor';
 import { useWaitingSaveProcessing } from '~/stores/editor';
-import { useSWRxCurrentPage } from '~/stores/page';
+import { useSWRMUTxCurrentPage, useSWRxCurrentPage } from '~/stores/page';
+import { mutatePageTree } from '~/stores/page-listing';
 import { useSelectedGrant } from '~/stores/ui';
 import { useSelectedGrant } from '~/stores/ui';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
+import { unpublish } from '../client/services/page-operation';
+
 import { GrantSelector } from './SavePageControls/GrantSelector';
 import { GrantSelector } from './SavePageControls/GrantSelector';
 
 
 
 
@@ -41,6 +45,7 @@ export const SavePageControls = (props: SavePageControlsProps): JSX.Element | nu
   const { data: isAclEnabled } = useIsAclEnabled();
   const { data: isAclEnabled } = useIsAclEnabled();
   const { data: grantData, mutate: mutateGrant } = useSelectedGrant();
   const { data: grantData, mutate: mutateGrant } = useSelectedGrant();
   const { data: _isWaitingSaveProcessing } = useWaitingSaveProcessing();
   const { data: _isWaitingSaveProcessing } = useWaitingSaveProcessing();
+  const { trigger: mutateCurrentPage } = useSWRMUTxCurrentPage();
 
 
   const isWaitingSaveProcessing = _isWaitingSaveProcessing === true; // ignore undefined
   const isWaitingSaveProcessing = _isWaitingSaveProcessing === true; // ignore undefined
 
 
@@ -58,6 +63,25 @@ export const SavePageControls = (props: SavePageControlsProps): JSX.Element | nu
     globalEmitter.emit('saveAndReturnToView', { overwriteScopesOfDescendants: true, slackChannels });
     globalEmitter.emit('saveAndReturnToView', { overwriteScopesOfDescendants: true, slackChannels });
   }, [slackChannels]);
   }, [slackChannels]);
 
 
+  const clickUnpublishButtonHandler = useCallback(async() => {
+    const pageId = currentPage?._id;
+
+    if (pageId == null) {
+      return;
+    }
+
+    try {
+      await unpublish(pageId);
+      await mutateCurrentPage();
+      await mutatePageTree();
+      toastSuccess(t('wip_page.success_save_as_wip'));
+    }
+    catch (err) {
+      logger.error(err);
+      toastError(t('wip_page.fail_save_as_wip'));
+    }
+  }, [currentPage?._id, mutateCurrentPage, t]);
+
 
 
   if (isEditable == null || isAclEnabled == null || grantData == null) {
   if (isEditable == null || isAclEnabled == null || grantData == null) {
     return null;
     return null;
@@ -72,6 +96,7 @@ export const SavePageControls = (props: SavePageControlsProps): JSX.Element | nu
   const isGrantSelectorDisabledPage = isTopPage(currentPage?.path ?? '') || isUsersProtectedPages(currentPage?.path ?? '');
   const isGrantSelectorDisabledPage = isTopPage(currentPage?.path ?? '') || isUsersProtectedPages(currentPage?.path ?? '');
   const labelSubmitButton = t('Update');
   const labelSubmitButton = t('Update');
   const labelOverwriteScopes = t('page_edit.overwrite_scopes', { operation: labelSubmitButton });
   const labelOverwriteScopes = t('page_edit.overwrite_scopes', { operation: labelSubmitButton });
+  const labelUnpublishPage = t('wip_page.save_as_wip');
 
 
   return (
   return (
     <div className="d-flex align-items-center flex-nowrap">
     <div className="d-flex align-items-center flex-nowrap">
@@ -108,6 +133,9 @@ export const SavePageControls = (props: SavePageControlsProps): JSX.Element | nu
           <DropdownItem onClick={saveAndOverwriteScopesOfDescendants}>
           <DropdownItem onClick={saveAndOverwriteScopesOfDescendants}>
             {labelOverwriteScopes}
             {labelOverwriteScopes}
           </DropdownItem>
           </DropdownItem>
+          <DropdownItem onClick={clickUnpublishButtonHandler}>
+            {labelUnpublishPage}
+          </DropdownItem>
         </DropdownMenu>
         </DropdownMenu>
       </UncontrolledButtonDropdown>
       </UncontrolledButtonDropdown>
 
 

+ 5 - 5
apps/app/src/components/SavePageControls/GrantSelector/GrantSelector.tsx

@@ -18,18 +18,18 @@ import { useMyUserGroups } from './use-my-user-groups';
 
 
 const AVAILABLE_GRANTS = [
 const AVAILABLE_GRANTS = [
   {
   {
-    grant: PageGrant.GRANT_PUBLIC, iconClass: 'icon-people', btnStyleClass: 'outline-info', label: 'Public',
+    grant: PageGrant.GRANT_PUBLIC, iconName: 'group', btnStyleClass: 'outline-info', label: 'Public',
   },
   },
   {
   {
-    grant: PageGrant.GRANT_RESTRICTED, iconClass: 'icon-link', btnStyleClass: 'outline-teal', label: 'Anyone with the link',
+    grant: PageGrant.GRANT_RESTRICTED, iconName: 'link', btnStyleClass: 'outline-teal', label: 'Anyone with the link',
   },
   },
   // { grant: 3, iconClass: '', label: 'Specified users only' },
   // { grant: 3, iconClass: '', label: 'Specified users only' },
   {
   {
-    grant: PageGrant.GRANT_OWNER, iconClass: 'icon-lock', btnStyleClass: 'outline-danger', label: 'Only me',
+    grant: PageGrant.GRANT_OWNER, iconName: 'lock', btnStyleClass: 'outline-danger', label: 'Only me',
   },
   },
   {
   {
     grant: PageGrant.GRANT_USER_GROUP,
     grant: PageGrant.GRANT_USER_GROUP,
-    iconClass: 'icon-options',
+    iconName: 'more_horiz',
     btnStyleClass: 'outline-purple',
     btnStyleClass: 'outline-purple',
     label: 'Only inside the group',
     label: 'Only inside the group',
     reselectLabel: 'Reselect the group',
     reselectLabel: 'Reselect the group',
@@ -119,7 +119,7 @@ export const GrantSelector = (props: Props): JSX.Element => {
 
 
       const labelElm = (
       const labelElm = (
         <span>
         <span>
-          <i className={`icon icon-fw ${opt.iconClass}`}></i>
+          <span className="material-symbols-outlined me-2">{opt.iconName}</span>
           <span className="label">{t(label)}</span>
           <span className="label">{t(label)}</span>
         </span>
         </span>
       );
       );

+ 11 - 8
apps/app/src/components/SearchTypeahead.tsx

@@ -1,14 +1,17 @@
+import type {
+  FC, ForwardRefRenderFunction,
+  KeyboardEvent, MouseEvent,
+} from 'react';
 import React, {
 import React, {
-  FC, ForwardRefRenderFunction, forwardRef, useImperativeHandle,
-  KeyboardEvent, useCallback, useRef, useState, MouseEvent, useEffect,
+  forwardRef, useImperativeHandle, useCallback, useRef, useState, useEffect,
 } from 'react';
 } from 'react';
 
 
 import { UserPicture, PageListMeta, PagePathLabel } from '@growi/ui/dist/components';
 import { UserPicture, PageListMeta, PagePathLabel } from '@growi/ui/dist/components';
 import { AsyncTypeahead, Menu, MenuItem } from 'react-bootstrap-typeahead';
 import { AsyncTypeahead, Menu, MenuItem } from 'react-bootstrap-typeahead';
 
 
-import { IFocusable } from '~/client/interfaces/focusable';
-import { TypeaheadProps } from '~/client/interfaces/react-bootstrap-typeahead';
-import { IPageWithSearchMeta } from '~/interfaces/search';
+import type { IFocusable } from '~/client/interfaces/focusable';
+import type { TypeaheadProps } from '~/client/interfaces/react-bootstrap-typeahead';
+import type { IPageWithSearchMeta } from '~/interfaces/search';
 import { useSWRxSearch } from '~/stores/search';
 import { useSWRxSearch } from '~/stores/search';
 
 
 
 
@@ -225,14 +228,14 @@ const SearchTypeahead: ForwardRefRenderFunction<IFocusable, Props> = (props: Pro
       <AsyncTypeahead
       <AsyncTypeahead
         {...props}
         {...props}
         id="search-typeahead-asynctypeahead"
         id="search-typeahead-asynctypeahead"
-        ref={typeaheadRef}
+        // ref={typeaheadRef}
         delay={400}
         delay={400}
         // eslint-disable-next-line @typescript-eslint/no-explicit-any
         // eslint-disable-next-line @typescript-eslint/no-explicit-any
         inputProps={{ autoComplete: 'off', ...(inputProps as any ?? {}) }}
         inputProps={{ autoComplete: 'off', ...(inputProps as any ?? {}) }}
         isLoading={isLoading}
         isLoading={isLoading}
-        labelKey={labelKey}
+        // labelKey={labelKey}
         defaultInputValue={keywordOnInit}
         defaultInputValue={keywordOnInit}
-        options={searchResult?.data} // Search result (Some page names)
+        options={searchResult?.data ?? []} // Search result (Some page names)
         align="left"
         align="left"
         open={isOpenAlways || undefined}
         open={isOpenAlways || undefined}
         renderMenu={renderMenu}
         renderMenu={renderMenu}

+ 4 - 6
apps/app/src/components/Sidebar/Bookmarks.tsx

@@ -12,11 +12,9 @@ export const Bookmarks = () : JSX.Element => {
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isGuestUser } = useIsGuestUser();
 
 
   return (
   return (
-    <>
-      {/* TODO : #139425 Match the space specification method to others */}
-      {/* ref.  https://redmine.weseek.co.jp/issues/139425 */}
-      <div className="grw-sidebar-content-header p-3">
-        <h3 className="mb-0">{t('Bookmarks')}</h3>
+    <div className="px-3">
+      <div className="grw-sidebar-content-header">
+        <h4 className="mb-0 py-4">{t('Bookmarks')}</h4>
       </div>
       </div>
       {isGuestUser ? (
       {isGuestUser ? (
         <h4 className="ps-3">
         <h4 className="ps-3">
@@ -25,6 +23,6 @@ export const Bookmarks = () : JSX.Element => {
       ) : (
       ) : (
         <BookmarkContents />
         <BookmarkContents />
       )}
       )}
-    </>
+    </div>
   );
   );
 };
 };

+ 4 - 6
apps/app/src/components/Sidebar/Custom/CustomSidebar.tsx

@@ -18,14 +18,12 @@ export const CustomSidebar = (): JSX.Element => {
   const { mutate, isLoading } = useSWRxPageByPath('/Sidebar');
   const { mutate, isLoading } = useSWRxPageByPath('/Sidebar');
 
 
   return (
   return (
-    // TODO : #139425 Match the space specification method to others
-    // ref.  https://redmine.weseek.co.jp/issues/139425
-    <div className="px-3">
-      <div className="grw-sidebar-content-header py-3 d-flex">
-        <h3 className="mb-0">
+    <div className="pt-4 pb-3 px-3">
+      <div className="grw-sidebar-content-header d-flex">
+        <h4 className="mb-0">
           {t('CustomSidebar')}
           {t('CustomSidebar')}
           { !isLoading && <Link href="/Sidebar#edit" className="h6 ms-2"><span className="material-symbols-outlined">edit</span></Link> }
           { !isLoading && <Link href="/Sidebar#edit" className="h6 ms-2"><span className="material-symbols-outlined">edit</span></Link> }
-        </h3>
+        </h4>
         { !isLoading && <SidebarHeaderReloadButton onClick={() => mutate()} /> }
         { !isLoading && <SidebarHeaderReloadButton onClick={() => mutate()} /> }
       </div>
       </div>
 
 

+ 13 - 4
apps/app/src/components/Sidebar/Custom/CustomSidebarNotFound.tsx

@@ -1,16 +1,25 @@
-import Link from 'next/link';
+import { useCallback } from 'react';
+
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 
 
+import { useCreatePageAndTransit } from '~/client/services/create-page';
+
 export const SidebarNotFound = (): JSX.Element => {
 export const SidebarNotFound = (): JSX.Element => {
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
+  const { createAndTransit } = useCreatePageAndTransit();
+
+  const clickCreateButtonHandler = useCallback(async() => {
+    createAndTransit({ path: '/Sidebar', wip: false });
+  }, [createAndTransit]);
+
   return (
   return (
-    <div className="grw-sidebar-content-header h5 text-center py-3">
-      <Link href="/Sidebar#edit">
+    <div>
+      <button type="button" className="btn btn-lg btn-link" onClick={clickCreateButtonHandler}>
         <span className="material-symbols-outlined">edit_note</span>
         <span className="material-symbols-outlined">edit_note</span>
         {/* eslint-disable-next-line react/no-danger */}
         {/* eslint-disable-next-line react/no-danger */}
         <span dangerouslySetInnerHTML={{ __html: t('Create Sidebar Page') }}></span>
         <span dangerouslySetInnerHTML={{ __html: t('Create Sidebar Page') }}></span>
-      </Link>
+      </button>
     </div>
     </div>
   );
   );
 };
 };

+ 1 - 1
apps/app/src/components/Sidebar/Custom/CustomSidebarSubstance.tsx

@@ -22,7 +22,7 @@ export const CustomSidebarSubstance = (): JSX.Element => {
   const markdown = page?.revision?.body;
   const markdown = page?.revision?.body;
 
 
   return (
   return (
-    <div className={`py-3 grw-custom-sidebar-content ${styles['grw-custom-sidebar-content']}`}>
+    <div className={`py-4 grw-custom-sidebar-content ${styles['grw-custom-sidebar-content']}`}>
       { markdown == null
       { markdown == null
         ? <SidebarNotFound />
         ? <SidebarNotFound />
         : (
         : (

+ 3 - 5
apps/app/src/components/Sidebar/InAppNotification/InAppNotification.tsx

@@ -15,13 +15,11 @@ export const InAppNotification = (): JSX.Element => {
   const [isUnopendNotificationsVisible, setUnopendNotificationsVisible] = useState(false);
   const [isUnopendNotificationsVisible, setUnopendNotificationsVisible] = useState(false);
 
 
   return (
   return (
-    // TODO : #139425 Match the space specification method to others
-    // ref.  https://redmine.weseek.co.jp/issues/139425
     <div className="px-3">
     <div className="px-3">
-      <div className="grw-sidebar-content-header py-3 d-flex">
-        <h3 className="mb-0">
+      <div className="grw-sidebar-content-header py-4 d-flex">
+        <h4 className="mb-0">
           {t('In-App Notification')}
           {t('In-App Notification')}
-        </h3>
+        </h4>
       </div>
       </div>
 
 
       <InAppNotificationForms
       <InAppNotificationForms

+ 1 - 1
apps/app/src/components/Sidebar/PageCreateButton/hooks/use-create-new-page.ts

@@ -18,7 +18,7 @@ export const useCreateNewPage: UseCreateNewPage = () => {
     if (isLoadingPagePath) return;
     if (isLoadingPagePath) return;
 
 
     return createAndTransit(
     return createAndTransit(
-      { parentPath: currentPagePath, optionalParentPath: '/' },
+      { parentPath: currentPagePath, optionalParentPath: '/', wip: true },
     );
     );
   }, [createAndTransit, currentPagePath, isLoadingPagePath]);
   }, [createAndTransit, currentPagePath, isLoadingPagePath]);
 
 

+ 1 - 1
apps/app/src/components/Sidebar/PageCreateButton/hooks/use-create-todays-memo.tsx

@@ -32,7 +32,7 @@ export const useCreateTodaysMemo: UseCreateTodaysMemo = () => {
     if (!isCreatable || todaysPath == null) return;
     if (!isCreatable || todaysPath == null) return;
 
 
     return createAndTransit(
     return createAndTransit(
-      { path: todaysPath },
+      { path: todaysPath, wip: true },
       { shouldCheckPageExists: true },
       { shouldCheckPageExists: true },
     );
     );
   }, [createAndTransit, isCreatable, todaysPath]);
   }, [createAndTransit, isCreatable, todaysPath]);

+ 10 - 6
apps/app/src/components/Sidebar/PageTree/PageTree.tsx

@@ -1,9 +1,10 @@
-import { Suspense } from 'react';
+import { Suspense, useState } from 'react';
 
 
 import dynamic from 'next/dynamic';
 import dynamic from 'next/dynamic';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 
 
 import ItemsTreeContentSkeleton from '../../ItemsTree/ItemsTreeContentSkeleton';
 import ItemsTreeContentSkeleton from '../../ItemsTree/ItemsTreeContentSkeleton';
+
 import { PageTreeHeader } from './PageTreeSubstance';
 import { PageTreeHeader } from './PageTreeSubstance';
 
 
 const PageTreeContent = dynamic(
 const PageTreeContent = dynamic(
@@ -15,19 +16,22 @@ const PageTreeContent = dynamic(
 export const PageTree = (): JSX.Element => {
 export const PageTree = (): JSX.Element => {
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
+  const [isWipPageShown, setIsWipPageShown] = useState(true);
+
   return (
   return (
-    // TODO : #139425 Match the space specification method to others
-    // ref.  https://redmine.weseek.co.jp/issues/139425
     <div className="pt-4 pb-3 px-3">
     <div className="pt-4 pb-3 px-3">
       <div className="grw-sidebar-content-header d-flex">
       <div className="grw-sidebar-content-header d-flex">
-        <h3 className="mb-0">{t('Page Tree')}</h3>
+        <h4 className="mb-0">{t('Page Tree')}</h4>
         <Suspense>
         <Suspense>
-          <PageTreeHeader />
+          <PageTreeHeader
+            isWipPageShown={isWipPageShown}
+            onWipPageShownChange={() => { setIsWipPageShown(!isWipPageShown) }}
+          />
         </Suspense>
         </Suspense>
       </div>
       </div>
 
 
       <Suspense fallback={<ItemsTreeContentSkeleton />}>
       <Suspense fallback={<ItemsTreeContentSkeleton />}>
-        <PageTreeContent />
+        <PageTreeContent isWipPageShown={isWipPageShown} />
       </Suspense>
       </Suspense>
     </div>
     </div>
   );
   );

+ 40 - 2
apps/app/src/components/Sidebar/PageTree/PageTreeSubstance.tsx

@@ -1,6 +1,9 @@
 import React, { memo, useCallback } from 'react';
 import React, { memo, useCallback } from 'react';
 
 
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
+import {
+  UncontrolledButtonDropdown, DropdownMenu, DropdownToggle, DropdownItem,
+} from 'reactstrap';
 
 
 import { useTargetAndAncestors, useIsGuestUser, useIsReadOnlyUser } from '~/stores/context';
 import { useTargetAndAncestors, useIsGuestUser, useIsReadOnlyUser } from '~/stores/context';
 import { useCurrentPagePath, useCurrentPageId } from '~/stores/page';
 import { useCurrentPagePath, useCurrentPageId } from '~/stores/page';
@@ -12,8 +15,14 @@ import { SidebarHeaderReloadButton } from '../SidebarHeaderReloadButton';
 
 
 import { PrivateLegacyPagesLink } from './PrivateLegacyPagesLink';
 import { PrivateLegacyPagesLink } from './PrivateLegacyPagesLink';
 
 
+type HeaderProps = {
+  isWipPageShown: boolean,
+  onWipPageShownChange?: () => void
+}
+
+export const PageTreeHeader = memo(({ isWipPageShown, onWipPageShownChange }: HeaderProps) => {
+  const { t } = useTranslation();
 
 
-export const PageTreeHeader = memo(() => {
   const { mutate: mutateRootPage } = useSWRxRootPage({ suspense: true });
   const { mutate: mutateRootPage } = useSWRxRootPage({ suspense: true });
   useSWRxV5MigrationStatus({ suspense: true });
   useSWRxV5MigrationStatus({ suspense: true });
 
 
@@ -25,6 +34,29 @@ export const PageTreeHeader = memo(() => {
   return (
   return (
     <>
     <>
       <SidebarHeaderReloadButton onClick={() => mutate()} />
       <SidebarHeaderReloadButton onClick={() => mutate()} />
+
+      <UncontrolledButtonDropdown className="me-1">
+        <DropdownToggle color="transparent" className="p-0 border-0">
+          <span className="material-symbols-outlined">more_horiz</span>
+        </DropdownToggle>
+
+        <DropdownMenu container="body">
+          <DropdownItem onClick={onWipPageShownChange} className="">
+            <div className="form-check form-switch">
+              <input
+                id="wipPageVisibility"
+                className="form-check-input"
+                type="checkbox"
+                checked={isWipPageShown}
+                onChange={() => {}}
+              />
+              <label className="form-label form-check-label text-muted" htmlFor="wipPageVisibility">
+                {t('sidebar_header.show_wip_page')}
+              </label>
+            </div>
+          </DropdownItem>
+        </DropdownMenu>
+      </UncontrolledButtonDropdown>
     </>
     </>
   );
   );
 });
 });
@@ -44,7 +76,12 @@ const PageTreeUnavailable = () => {
   );
   );
 };
 };
 
 
-export const PageTreeContent = memo(() => {
+type PageTreeContentProps = {
+  isWipPageShown: boolean,
+}
+
+export const PageTreeContent = memo(({ isWipPageShown }: PageTreeContentProps) => {
+
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isReadOnlyUser } = useIsReadOnlyUser();
   const { data: isReadOnlyUser } = useIsReadOnlyUser();
   const { data: currentPath } = useCurrentPagePath();
   const { data: currentPath } = useCurrentPagePath();
@@ -73,6 +110,7 @@ export const PageTreeContent = memo(() => {
       <ItemsTree
       <ItemsTree
         isEnableActions={!isGuestUser}
         isEnableActions={!isGuestUser}
         isReadOnlyUser={!!isReadOnlyUser}
         isReadOnlyUser={!!isReadOnlyUser}
+        isWipPageShown={isWipPageShown}
         targetPath={path}
         targetPath={path}
         targetPathOrId={targetPathOrId}
         targetPathOrId={targetPathOrId}
         targetAndAncestorsData={targetAndAncestorsData}
         targetAndAncestorsData={targetAndAncestorsData}

+ 3 - 2
apps/app/src/components/Sidebar/PageTree/PrivateLegacyPagesLink.tsx

@@ -1,4 +1,5 @@
-import React, { FC, memo } from 'react';
+import type { FC } from 'react';
+import React, { memo } from 'react';
 
 
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import Link from 'next/link';
 import Link from 'next/link';
@@ -13,7 +14,7 @@ export const PrivateLegacyPagesLink: FC = memo(() => {
       className="h5 grw-private-legacy-pages-anchor text-decoration-none"
       className="h5 grw-private-legacy-pages-anchor text-decoration-none"
       prefetch={false}
       prefetch={false}
     >
     >
-      <i className="icon-drawer me-2"></i> {t('private_legacy_pages.title')}
+      <span className="material-symbols-outlined me-2">bottom_drawer</span> {t('private_legacy_pages.title')}
     </Link>
     </Link>
   );
   );
 });
 });

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

@@ -162,7 +162,7 @@ export const Ellipsis: FC<TreeItemToolProps> = (props) => {
           >
           >
             {/* pass the color property to reactstrap dropdownToggle props. https://6-4-0--reactstrap.netlify.app/components/dropdowns/  */}
             {/* pass the color property to reactstrap dropdownToggle props. https://6-4-0--reactstrap.netlify.app/components/dropdowns/  */}
             <DropdownToggle color="transparent" className="border-0 rounded btn-page-item-control p-0 grw-visible-on-hover mr-1">
             <DropdownToggle color="transparent" className="border-0 rounded btn-page-item-control p-0 grw-visible-on-hover mr-1">
-              <i id="option-button-in-page-tree" className="icon-options fa fa-rotate-90 p-1"></i>
+              <span id="option-button-in-page-tree" className="material-symbols-outlined p-1">more_vert</span>
             </DropdownToggle>
             </DropdownToggle>
           </PageItemControl>
           </PageItemControl>
         </div>
         </div>

+ 2 - 2
apps/app/src/components/ItemsTree/Item.module.scss → apps/app/src/components/Sidebar/PageTreeItem/PageTreeItem.module.scss

@@ -1,7 +1,6 @@
 @use '@growi/core/scss/bootstrap/init' as bs;
 @use '@growi/core/scss/bootstrap/init' as bs;
 
 
-// TODO: relocate following styles into PageTreeItem.mdoule.scss after refactoring
-// https://redmine.weseek.co.jp/issues/127544
+// == Colors
 @include bs.color-mode(light) {
 @include bs.color-mode(light) {
   .pagetree-item :global {
   .pagetree-item :global {
     .list-group-item-action {
     .list-group-item-action {
@@ -13,6 +12,7 @@
     }
     }
   }
   }
 }
 }
+
 @include bs.color-mode(dark) {
 @include bs.color-mode(dark) {
   .pagetree-item :global {
   .pagetree-item :global {
     .list-group-item-action {
     .list-group-item-action {

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

@@ -24,6 +24,7 @@ import {
 
 
 import { Ellipsis } from './Ellipsis';
 import { Ellipsis } from './Ellipsis';
 
 
+import styles from './PageTreeItem.module.scss';
 
 
 const logger = loggerFactory('growi:cli:Item');
 const logger = loggerFactory('growi:cli:Item');
 
 
@@ -177,6 +178,7 @@ export const PageTreeItem: FC<TreeItemProps> = (props) => {
       isOpen={isOpen}
       isOpen={isOpen}
       isEnableActions={props.isEnableActions}
       isEnableActions={props.isEnableActions}
       isReadOnlyUser={props.isReadOnlyUser}
       isReadOnlyUser={props.isReadOnlyUser}
+      isWipPageShown={props.isWipPageShown}
       onClick={itemSelectedHandler}
       onClick={itemSelectedHandler}
       onClickDuplicateMenuItem={props.onClickDuplicateMenuItem}
       onClickDuplicateMenuItem={props.onClickDuplicateMenuItem}
       onClickDeleteMenuItem={props.onClickDeleteMenuItem}
       onClickDeleteMenuItem={props.onClickDeleteMenuItem}

+ 10 - 6
apps/app/src/components/Sidebar/RecentChanges/RecentChanges.tsx

@@ -16,20 +16,24 @@ export const RecentChanges = (): JSX.Element => {
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
   const [isSmall, setIsSmall] = useState(false);
   const [isSmall, setIsSmall] = useState(false);
+  const [isWipPageShown, setIsWipPageShown] = useState(true);
 
 
   return (
   return (
-    // TODO : #139425 Match the space specification method to others
-    // ref.  https://redmine.weseek.co.jp/issues/139425
     <div className="px-3" data-testid="grw-recent-changes">
     <div className="px-3" data-testid="grw-recent-changes">
-      <div className="grw-sidebar-content-header py-3 d-flex">
-        <h3 className="mb-0 text-nowrap">{t('Recent Changes')}</h3>
+      <div className="grw-sidebar-content-header py-4 d-flex">
+        <h4 className="mb-0 text-nowrap">{t('Recent Changes')}</h4>
         <Suspense>
         <Suspense>
-          <RecentChangesHeader isSmall={isSmall} onSizeChange={setIsSmall} />
+          <RecentChangesHeader
+            isSmall={isSmall}
+            onSizeChange={setIsSmall}
+            isWipPageShown={isWipPageShown}
+            onWipPageShownChange={() => { setIsWipPageShown(!isWipPageShown) }}
+          />
         </Suspense>
         </Suspense>
       </div>
       </div>
 
 
       <Suspense fallback={<RecentChangesContentSkeleton />}>
       <Suspense fallback={<RecentChangesContentSkeleton />}>
-        <RecentChangesContent isSmall={isSmall} />
+        <RecentChangesContent isWipPageShown={isWipPageShown} isSmall={isSmall} />
       </Suspense>
       </Suspense>
     </div>
     </div>
   );
   );

+ 0 - 1
apps/app/src/components/Sidebar/RecentChanges/RecentChangesSubstance.module.scss

@@ -1,7 +1,6 @@
 @use '~/styles/mixins' as *;
 @use '~/styles/mixins' as *;
 
 
 .grw-recent-changes-resize-button :global {
 .grw-recent-changes-resize-button :global {
-  font-size: 12px;
   line-height: normal;
   line-height: normal;
   transform: translateY(-2px);
   transform: translateY(-2px);
 
 

+ 55 - 18
apps/app/src/components/Sidebar/RecentChanges/RecentChangesSubstance.tsx

@@ -7,6 +7,10 @@ import {
 } from '@growi/core';
 } from '@growi/core';
 import { DevidedPagePath } from '@growi/core/dist/models';
 import { DevidedPagePath } from '@growi/core/dist/models';
 import { UserPicture } from '@growi/ui/dist/components';
 import { UserPicture } from '@growi/ui/dist/components';
+import { useTranslation } from 'react-i18next';
+import {
+  DropdownItem, DropdownMenu, DropdownToggle, UncontrolledButtonDropdown,
+} from 'reactstrap';
 
 
 import { useKeywordManager } from '~/client/services/search-operation';
 import { useKeywordManager } from '~/client/services/search-operation';
 import { PagePathHierarchicalLink } from '~/components/Common/PagePathHierarchicalLink';
 import { PagePathHierarchicalLink } from '~/components/Common/PagePathHierarchicalLink';
@@ -116,8 +120,11 @@ const PageItem = memo(({ page, isSmall, onClickTag }: PageItemProps): JSX.Elemen
               { !dPagePath.isRoot && <FormerLink /> }
               { !dPagePath.isRoot && <FormerLink /> }
             </div>
             </div>
 
 
-            <h6 className={`col-12 ${isSmall ? 'mb-0 text-truncate' : 'mb-0'}`}>
+            <h6 className={`col-12 d-flex align-items-center ${isSmall ? 'mb-0 text-truncate' : 'mb-0'}`}>
               <PagePathHierarchicalLink linkedPagePath={linkedPagePathLatter} basePath={dPagePath.isRoot ? undefined : dPagePath.former} />
               <PagePathHierarchicalLink linkedPagePath={linkedPagePathLatter} basePath={dPagePath.isRoot ? undefined : dPagePath.former} />
+              { page.wip && (
+                <span className="wip-page-badge badge rounded-pill text-bg-secondary ms-2">WIP</span>
+              ) }
               {locked}
               {locked}
             </h6>
             </h6>
 
 
@@ -143,12 +150,17 @@ PageItem.displayName = 'PageItem';
 type HeaderProps = {
 type HeaderProps = {
   isSmall: boolean,
   isSmall: boolean,
   onSizeChange: (isSmall: boolean) => void,
   onSizeChange: (isSmall: boolean) => void,
+  isWipPageShown: boolean,
+  onWipPageShownChange: () => void,
 }
 }
 
 
 const PER_PAGE = 20;
 const PER_PAGE = 20;
-export const RecentChangesHeader = ({ isSmall, onSizeChange }: HeaderProps): JSX.Element => {
+export const RecentChangesHeader = ({
+  isSmall, onSizeChange, isWipPageShown, onWipPageShownChange,
+}: HeaderProps): JSX.Element => {
+  const { t } = useTranslation();
 
 
-  const { mutate } = useSWRINFxRecentlyUpdated(PER_PAGE, { suspense: true });
+  const { mutate } = useSWRINFxRecentlyUpdated(PER_PAGE, isWipPageShown, { suspense: true });
 
 
   const retrieveSizePreferenceFromLocalStorage = useCallback(() => {
   const retrieveSizePreferenceFromLocalStorage = useCallback(() => {
     if (window.localStorage.isRecentChangesSidebarSmall === 'true') {
     if (window.localStorage.isRecentChangesSidebarSmall === 'true') {
@@ -169,28 +181,53 @@ export const RecentChangesHeader = ({ isSmall, onSizeChange }: HeaderProps): JSX
   return (
   return (
     <>
     <>
       <SidebarHeaderReloadButton onClick={() => mutate()} />
       <SidebarHeaderReloadButton onClick={() => mutate()} />
-      <div className="d-flex align-items-center">
-        <div className={`grw-recent-changes-resize-button ${styles['grw-recent-changes-resize-button']} form-check form-switch ms-1`}>
-          <input
-            id="recentChangesResize"
-            className="form-check-input"
-            type="checkbox"
-            checked={isSmall}
-            onChange={changeSizeHandler}
-          />
-          <label className="form-label form-check-label" htmlFor="recentChangesResize" />
-        </div>
-      </div>
+
+      <UncontrolledButtonDropdown className="me-1">
+        <DropdownToggle color="transparent" className="p-0 border-0">
+          <span className="material-symbols-outlined">more_horiz</span>
+        </DropdownToggle>
+
+        <DropdownMenu container="body">
+          <DropdownItem onClick={changeSizeHandler}>
+            <div className={`${styles['grw-recent-changes-resize-button']} form-check form-switch`}>
+              <input
+                id="recentChangesResize"
+                className="form-check-input"
+                type="checkbox"
+                checked={isSmall}
+                onChange={() => {}}
+              />
+              <label className="form-label form-check-label text-muted" htmlFor="recentChangesResize" />
+            </div>
+          </DropdownItem>
+
+          <DropdownItem onClick={onWipPageShownChange}>
+            <div className="form-check form-switch">
+              <input
+                id="wipPageVisibility"
+                className="form-check-input"
+                type="checkbox"
+                checked={isWipPageShown}
+                onChange={() => {}}
+              />
+              <label className="form-label form-check-label text-muted" htmlFor="wipPageVisibility">
+                {t('sidebar_header.show_wip_page')}
+              </label>
+            </div>
+          </DropdownItem>
+        </DropdownMenu>
+      </UncontrolledButtonDropdown>
     </>
     </>
   );
   );
 };
 };
 
 
 type ContentProps = {
 type ContentProps = {
   isSmall: boolean,
   isSmall: boolean,
+  isWipPageShown: boolean,
 }
 }
 
 
-export const RecentChangesContent = ({ isSmall }: ContentProps): JSX.Element => {
-  const swrInifinitexRecentlyUpdated = useSWRINFxRecentlyUpdated(PER_PAGE, { suspense: true });
+export const RecentChangesContent = ({ isSmall, isWipPageShown }: ContentProps): JSX.Element => {
+  const swrInifinitexRecentlyUpdated = useSWRINFxRecentlyUpdated(PER_PAGE, isWipPageShown, { suspense: true });
   const { data } = swrInifinitexRecentlyUpdated;
   const { data } = swrInifinitexRecentlyUpdated;
 
 
   const { pushState } = useKeywordManager();
   const { pushState } = useKeywordManager();
@@ -199,7 +236,7 @@ export const RecentChangesContent = ({ isSmall }: ContentProps): JSX.Element =>
   const isReachingEnd = isEmpty || (data != null && data[data.length - 1]?.pages.length < PER_PAGE);
   const isReachingEnd = isEmpty || (data != null && data[data.length - 1]?.pages.length < PER_PAGE);
 
 
   return (
   return (
-    <div className="grw-recent-changes py-3">
+    <div className="grw-recent-changes">
       <ul className="list-group list-group-flush">
       <ul className="list-group list-group-flush">
         <InfiniteScroll
         <InfiniteScroll
           swrInifiniteResponse={swrInifinitexRecentlyUpdated}
           swrInifiniteResponse={swrInifinitexRecentlyUpdated}

+ 3 - 0
apps/app/src/components/Sidebar/SidebarContents.module.scss

@@ -11,4 +11,7 @@
     --bs-list-group-bg: transparent;
     --bs-list-group-bg: transparent;
   }
   }
 
 
+  .wip-page-badge {
+    --bs-badge-font-size: 0.5rem;
+  }
 }
 }

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

@@ -8,7 +8,7 @@ export const SidebarHeaderReloadButton = ({ onClick }: Props) => {
 
 
   return (
   return (
     <button type="button" className="btn btn-sm ms-auto py-0 grw-btn-reload" onClick={onClick}>
     <button type="button" className="btn btn-sm ms-auto py-0 grw-btn-reload" onClick={onClick}>
-      <i className="icon icon-reload"></i>
+      <span className="material-symbols-outlined">refresh</span>
     </button>
     </button>
   );
   );
 };
 };

+ 7 - 8
apps/app/src/components/Sidebar/Tag.tsx

@@ -1,9 +1,10 @@
-import React, { FC, useState, useCallback } from 'react';
+import type { FC } from 'react';
+import React, { useState, useCallback } from 'react';
 
 
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import { useRouter } from 'next/router';
 import { useRouter } from 'next/router';
 
 
-import { IDataTagCount } from '~/interfaces/tag';
+import type { IDataTagCount } from '~/interfaces/tag';
 import { useSWRxTagsList } from '~/stores/tag';
 import { useSWRxTagsList } from '~/stores/tag';
 
 
 import TagCloudBox from '../TagCloudBox';
 import TagCloudBox from '../TagCloudBox';
@@ -44,15 +45,13 @@ const Tag: FC = () => {
 
 
   // todo: adjust design by XD
   // todo: adjust design by XD
   return (
   return (
-    // TODO : #139425 Match the space specification method to others
-    // ref.  https://redmine.weseek.co.jp/issues/139425
-    <div className="container-lg px-4 mb-5 pb-5" data-testid="grw-sidebar-content-tags">
+    <div className="container-lg px-3 mb-5 pb-5" data-testid="grw-sidebar-content-tags">
       <div className="grw-sidebar-content-header py-3 d-flex">
       <div className="grw-sidebar-content-header py-3 d-flex">
-        <h3 className="mb-0">{t('Tags')}</h3>
+        <h4 className="mb-0">{t('Tags')}</h4>
         <SidebarHeaderReloadButton onClick={() => onReload()} />
         <SidebarHeaderReloadButton onClick={() => onReload()} />
       </div>
       </div>
 
 
-      <h3 className="my-3">{t('tag_list')}</h3>
+      <h6 className="my-3 pb-1 border-bottom">{t('tag_list')}</h6>
 
 
       { isLoading
       { isLoading
         ? (
         ? (
@@ -81,7 +80,7 @@ const Tag: FC = () => {
         </button>
         </button>
       </div>
       </div>
 
 
-      <h3 className="my-3">{t('popular_tags')}</h3>
+      <h6 className="my-3 pb-1 border-bottom">{t('popular_tags')}</h6>
 
 
       <TagCloudBox tags={tagCloudData} />
       <TagCloudBox tags={tagCloudData} />
     </div>
     </div>

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

@@ -53,7 +53,7 @@ const TagList: FC<TagListProps> = (props:(TagListProps & typeof defaultProps)) =
   }, [pushState]);
   }, [pushState]);
 
 
   if (!isTagExist) {
   if (!isTagExist) {
-    return <h3>{ t('You have no tag, You can set tags on pages') }</h3>;
+    return <h6>{ t('You have no tag, You can set tags on pages') }</h6>;
   }
   }
 
 
   return (
   return (

+ 1 - 1
apps/app/src/components/TreeItem/NewPageInput/NewPageCreateButton.tsx

@@ -27,7 +27,7 @@ export const NewPageCreateButton: FC<NewPageCreateButtonProps> = (props) => {
               className="border-0 rounded btn btn-page-item-control p-0 grw-visible-on-hover"
               className="border-0 rounded btn btn-page-item-control p-0 grw-visible-on-hover"
               onClick={onClick}
               onClick={onClick}
             >
             >
-              <i className="icon-plus d-block p-0" />
+              <span className="material-symbols-outlined d-block p-0">add_circle</span>
             </button>
             </button>
           </NotAvailableForReadOnlyUser>
           </NotAvailableForReadOnlyUser>
         </NotAvailableForGuest>
         </NotAvailableForGuest>

+ 2 - 0
apps/app/src/components/TreeItem/NewPageInput/use-new-page-input.tsx

@@ -4,6 +4,7 @@ import { createPage } from '~/client/services/page-operation';
 import { useSWRxPageChildren } from '~/stores/page-listing';
 import { useSWRxPageChildren } from '~/stores/page-listing';
 import { usePageTreeDescCountMap } from '~/stores/ui';
 import { usePageTreeDescCountMap } from '~/stores/ui';
 
 
+import { shouldCreateWipPage } from '../../../utils/should-create-wip-page';
 import type { TreeItemToolProps } from '../interfaces';
 import type { TreeItemToolProps } from '../interfaces';
 
 
 import { NewPageCreateButton } from './NewPageCreateButton';
 import { NewPageCreateButton } from './NewPageCreateButton';
@@ -73,6 +74,7 @@ export const useNewPageInput = (): UseNewPageInput => {
         // keep grant info undefined to inherit from parent
         // keep grant info undefined to inherit from parent
         grant: undefined,
         grant: undefined,
         grantUserGroupIds: undefined,
         grantUserGroupIds: undefined,
+        wip: shouldCreateWipPage(newPagePath),
       });
       });
 
 
       mutateChildren();
       mutateChildren();

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

@@ -58,7 +58,12 @@ const SimpleItemContent = ({ page }: { page: IPageForItem }) => {
       )}
       )}
       {page != null && page.path != null && page._id != null && (
       {page != null && page.path != null && page._id != null && (
         <div className="grw-pagetree-title-anchor flex-grow-1">
         <div className="grw-pagetree-title-anchor flex-grow-1">
-          <p className={`text-truncate m-auto ${page.isEmpty && 'grw-sidebar-text-muted'}`}>{pageName}</p>
+          <div className="d-flex align-items-center">
+            <span className={`text-truncate ${page.isEmpty && 'grw-sidebar-text-muted'}`}>{pageName}</span>
+            { page.wip && (
+              <span className="wip-page-badge badge rounded-pill text-bg-secondary ms-2">WIP</span>
+            )}
+          </div>
         </div>
         </div>
       )}
       )}
     </div>
     </div>
@@ -90,7 +95,7 @@ type SimpleItemProps = TreeItemProps & {
 export const SimpleItem: FC<SimpleItemProps> = (props) => {
 export const SimpleItem: FC<SimpleItemProps> = (props) => {
   const {
   const {
     itemNode, targetPathOrId, isOpen: _isOpen = false,
     itemNode, targetPathOrId, isOpen: _isOpen = false,
-    onRenamed, onClick, onClickDuplicateMenuItem, onClickDeleteMenuItem, isEnableActions, isReadOnlyUser,
+    onRenamed, onClick, onClickDuplicateMenuItem, onClickDeleteMenuItem, isEnableActions, isReadOnlyUser, isWipPageShown = true,
     itemRef, itemClass, mainClassName,
     itemRef, itemClass, mainClassName,
   } = props;
   } = props;
 
 
@@ -165,6 +170,7 @@ export const SimpleItem: FC<SimpleItemProps> = (props) => {
     isEnableActions,
     isEnableActions,
     isReadOnlyUser,
     isReadOnlyUser,
     isOpen: false,
     isOpen: false,
+    isWipPageShown,
     targetPathOrId,
     targetPathOrId,
     onRenamed,
     onRenamed,
     onClickDuplicateMenuItem,
     onClickDuplicateMenuItem,
@@ -178,6 +184,9 @@ export const SimpleItem: FC<SimpleItemProps> = (props) => {
 
 
   const CustomNextComponents = props.customNextComponents;
   const CustomNextComponents = props.customNextComponents;
 
 
+  if (!isWipPageShown && page.wip) {
+    return <></>;
+  }
 
 
   return (
   return (
     <div
     <div
@@ -188,8 +197,7 @@ export const SimpleItem: FC<SimpleItemProps> = (props) => {
       <li
       <li
         ref={itemRef}
         ref={itemRef}
         role="button"
         role="button"
-        className={`list-group-item list-group-item-action rounded border-0 py-0 pr-3 d-flex align-items-center
-        ${page.isTarget ? 'grw-pagetree-current-page-item' : ''}`}
+        className={`list-group-item border-0 py-0 pr-3 d-flex align-items-center text-muted ${page.isTarget ? 'active' : 'list-group-item-action'}`}
         id={page.isTarget ? 'grw-pagetree-current-page-item' : `grw-pagetree-list-${page._id}`}
         id={page.isTarget ? 'grw-pagetree-current-page-item' : `grw-pagetree-list-${page._id}`}
         onClick={itemClickHandler}
         onClick={itemClickHandler}
       >
       >

+ 1 - 0
apps/app/src/components/TreeItem/interfaces/index.ts

@@ -24,6 +24,7 @@ export type TreeItemToolProps = TreeItemBaseProps;
 export type TreeItemProps = TreeItemBaseProps & {
 export type TreeItemProps = TreeItemBaseProps & {
   targetPathOrId?: Nullable<string>,
   targetPathOrId?: Nullable<string>,
   isOpen?: boolean,
   isOpen?: boolean,
+  isWipPageShown?: boolean,
   itemClass?: React.FunctionComponent<TreeItemProps>,
   itemClass?: React.FunctionComponent<TreeItemProps>,
   mainClassName?: string,
   mainClassName?: string,
   customEndComponents?: Array<React.FunctionComponent<TreeItemToolProps>>,
   customEndComponents?: Array<React.FunctionComponent<TreeItemToolProps>>,

+ 1 - 2
apps/app/src/components/User/UserInfo.tsx

@@ -27,8 +27,7 @@ export const UserInfo = (props: UserInfoProps): JSX.Element => {
         </h1>
         </h1>
         <div className="user-page-meta mt-3 mb-0">
         <div className="user-page-meta mt-3 mb-0">
           <span className="user-page-username me-4">
           <span className="user-page-username me-4">
-            {/* TODO:Replace with Material Symbols Outlined */}
-            <span className="user-page-username me-4"><i className="icon-user me-1"></i>{author.username}</span>
+            <span className="user-page-username me-4"><span className="material-symbols-outlined">person</span>{author.username}</span>
           </span>
           </span>
           <span className="user-page-email me-2">
           <span className="user-page-email me-2">
             <span className="material-symbols-outlined me-1">mail</span>
             <span className="material-symbols-outlined me-1">mail</span>

+ 1 - 1
apps/app/src/features/growi-plugin/client/components/Admin/PluginsExtensionPageContents/PluginsExtensionPageContents.tsx

@@ -38,7 +38,7 @@ export const PluginsExtensionPageContents = (): JSX.Element => {
           <h2 className="admin-setting-header">
           <h2 className="admin-setting-header">
             {t('plugins.plugin_card')}
             {t('plugins.plugin_card')}
             <button type="button" className="btn btn-sm ms-auto grw-btn-reload" onClick={() => mutate()}>
             <button type="button" className="btn btn-sm ms-auto grw-btn-reload" onClick={() => mutate()}>
-              <i className="icon icon-reload"></i>
+              <span className="material-symbols-outlined">refresh</span>
             </button>
             </button>
           </h2>
           </h2>
           {data?.plugins == null
           {data?.plugins == null

+ 10 - 3
apps/app/src/features/search/client/components/SearchForm.tsx

@@ -2,7 +2,7 @@ import React, {
   useCallback, useRef, useEffect, useMemo,
   useCallback, useRef, useEffect, useMemo,
 } from 'react';
 } from 'react';
 
 
-import { GetInputProps } from '../interfaces/downshift';
+import type { GetInputProps } from '../interfaces/downshift';
 
 
 type Props = {
 type Props = {
   searchKeyword: string,
   searchKeyword: string,
@@ -35,7 +35,7 @@ export const SearchForm = (props: Props): JSX.Element => {
 
 
   const inputOptions = useMemo(() => {
   const inputOptions = useMemo(() => {
     return getInputProps({
     return getInputProps({
-      type: 'search',
+      type: 'text',
       placeholder: 'Search...',
       placeholder: 'Search...',
       className: 'form-control',
       className: 'form-control',
       ref: inputRef,
       ref: inputRef,
@@ -52,11 +52,18 @@ export const SearchForm = (props: Props): JSX.Element => {
 
 
   return (
   return (
     <form
     <form
-      className="w-100"
+      className="w-100 position-relative"
       onSubmit={submitHandler}
       onSubmit={submitHandler}
       data-testid="search-form"
       data-testid="search-form"
     >
     >
       <input {...inputOptions} />
       <input {...inputOptions} />
+      <button
+        type="button"
+        className="btn btn-neutral-secondary text-muted position-absolute bottom-0 end-0 w-auto h-100 border-0"
+        onClick={() => { onChange?.('') }}
+      >
+        <span className="material-symbols-outlined p-0">cancel</span>
+      </button>
     </form>
     </form>
   );
   );
 };
 };

+ 10 - 10
apps/app/src/features/search/client/components/SearchHelp.tsx

@@ -11,40 +11,40 @@ export const SearchHelp = (): JSX.Element => {
   return (
   return (
     <>
     <>
       <button type="button" className="btn border-0 text-muted d-flex justify-content-center align-items-center ms-1 p-0" onClick={() => setIsOpen(!isOpen)}>
       <button type="button" className="btn border-0 text-muted d-flex justify-content-center align-items-center ms-1 p-0" onClick={() => setIsOpen(!isOpen)}>
-        <span className="material-symbols-outlined me-2">help</span>
-        { t('search_help.title') }
-        <span className="material-symbols-outlined ms-2">{isOpen ? 'expand_less' : 'expand_more'}</span>
+        <span className="material-symbols-outlined me-2 p-0">help</span>
+        <span>{t('search_help.title')}</span>
+        <span className="material-symbols-outlined ms-2 p-0">{isOpen ? 'expand_less' : 'expand_more'}</span>
       </button>
       </button>
       <Collapse isOpen={isOpen}>
       <Collapse isOpen={isOpen}>
-        <table className="table m-0">
+        <table className="table table-borderless m-0">
           <tbody>
           <tbody>
-            <tr>
+            <tr className="border-bottom">
               <th className="py-2">
               <th className="py-2">
                 <code>word1</code> <code>word2</code><br />
                 <code>word1</code> <code>word2</code><br />
                 <small className="text-muted">({ t('search_help.and.syntax help') })</small>
                 <small className="text-muted">({ t('search_help.and.syntax help') })</small>
               </th>
               </th>
               <td><h6 className="m-0 text-muted">{ t('search_help.and.desc', { word1: 'word1', word2: 'word2' }) }</h6></td>
               <td><h6 className="m-0 text-muted">{ t('search_help.and.desc', { word1: 'word1', word2: 'word2' }) }</h6></td>
             </tr>
             </tr>
-            <tr>
+            <tr className="border-bottom">
               <th className="py-2">
               <th className="py-2">
                 <code>&quot;This is GROWI&quot;</code><br />
                 <code>&quot;This is GROWI&quot;</code><br />
                 <small className="text-muted">({ t('search_help.phrase.syntax help') })</small>
                 <small className="text-muted">({ t('search_help.phrase.syntax help') })</small>
               </th>
               </th>
               <td><h6 className="m-0 text-muted">{ t('search_help.phrase.desc', { phrase: 'This is GROWI' }) }</h6></td>
               <td><h6 className="m-0 text-muted">{ t('search_help.phrase.desc', { phrase: 'This is GROWI' }) }</h6></td>
             </tr>
             </tr>
-            <tr>
+            <tr className="border-bottom">
               <th className="py-2"><code>-keyword</code></th>
               <th className="py-2"><code>-keyword</code></th>
               <td><h6 className="m-0 text-muted">{ t('search_help.exclude.desc', { word: 'keyword' }) }</h6></td>
               <td><h6 className="m-0 text-muted">{ t('search_help.exclude.desc', { word: 'keyword' }) }</h6></td>
             </tr>
             </tr>
-            <tr>
+            <tr className="border-bottom">
               <th className="py-2"><code>prefix:/user/</code></th>
               <th className="py-2"><code>prefix:/user/</code></th>
               <td><h6 className="m-0 text-muted">{ t('search_help.prefix.desc', { path: '/user/' }) }</h6></td>
               <td><h6 className="m-0 text-muted">{ t('search_help.prefix.desc', { path: '/user/' }) }</h6></td>
             </tr>
             </tr>
-            <tr>
+            <tr className="border-bottom">
               <th className="py-2"><code>-prefix:/user/</code></th>
               <th className="py-2"><code>-prefix:/user/</code></th>
               <td><h6 className="m-0 text-muted">{ t('search_help.exclude_prefix.desc', { path: '/user/' }) }</h6></td>
               <td><h6 className="m-0 text-muted">{ t('search_help.exclude_prefix.desc', { path: '/user/' }) }</h6></td>
             </tr>
             </tr>
-            <tr>
+            <tr className="border-bottom">
               <th className="py-2"><code>tag:wiki</code></th>
               <th className="py-2"><code>tag:wiki</code></th>
               <td><h6 className="m-0 text-muted">{ t('search_help.tag.desc', { tag: 'wiki' }) }</h6></td>
               <td><h6 className="m-0 text-muted">{ t('search_help.tag.desc', { tag: 'wiki' }) }</h6></td>
             </tr>
             </tr>

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

@@ -21,7 +21,7 @@ export const SearchMenuItem = (props: Props): JSX.Element => {
     getItemProps({
     getItemProps({
       index,
       index,
       item: { url },
       item: { url },
-      className: `d-flex p-1 text-muted ${isActive ? 'active' : ''}`,
+      className: `d-flex align-items-center px-2 py-1 text-muted ${isActive ? 'active' : ''}`,
     })
     })
   );
   );
 
 

+ 22 - 16
apps/app/src/features/search/client/components/SearchMethodMenuItem.tsx

@@ -1,5 +1,6 @@
 import React from 'react';
 import React from 'react';
 
 
+import { DevidedPagePath } from '@growi/core/dist/models';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 
 
 import { useCurrentPagePath } from '~/stores/page';
 import { useCurrentPagePath } from '~/stores/page';
@@ -23,10 +24,15 @@ export const SearchMethodMenuItem = (props: Props): JSX.Element => {
 
 
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: currentPagePath } = useCurrentPagePath();
 
 
+  const dPagePath = (new DevidedPagePath(currentPagePath ?? '', true, true));
+  const currentPageName = `
+  ${(!(dPagePath.isRoot || dPagePath.isFormerRoot) ? '...' : '')}/${(dPagePath.isRoot ? '' : `${dPagePath.latter}/`)}
+  `;
+
   const shouldShowMenuItem = searchKeyword.trim().length > 0;
   const shouldShowMenuItem = searchKeyword.trim().length > 0;
 
 
   return (
   return (
-    <>
+    <div>
       { shouldShowMenuItem && (
       { shouldShowMenuItem && (
         <div data-testid="search-all-menu-item">
         <div data-testid="search-all-menu-item">
           <SearchMenuItem
           <SearchMenuItem
@@ -35,15 +41,14 @@ export const SearchMethodMenuItem = (props: Props): JSX.Element => {
             getItemProps={getItemProps}
             getItemProps={getItemProps}
             url={`/_search?q=${searchKeyword}`}
             url={`/_search?q=${searchKeyword}`}
           >
           >
-            <span className="material-symbols-outlined fs-4 me-3">search</span>
-            <span>{searchKeyword}</span>
-            <div className="ms-auto">
-              <span>{t('search_method_menu_item.search_in_all')}</span>
+            <span className="material-symbols-outlined fs-4 me-3 p-0">search</span>
+            <div className="w-100 d-flex align-items-md-center flex-md-row align-items-start flex-column">
+              <span className="text-break me-auto">{searchKeyword}</span>
+              <span className="small text-body-tertiary">{t('search_method_menu_item.search_in_all')}</span>
             </div>
             </div>
           </SearchMenuItem>
           </SearchMenuItem>
         </div>
         </div>
       )}
       )}
-
       <div data-testid="search-prefix-menu-item">
       <div data-testid="search-prefix-menu-item">
         <SearchMenuItem
         <SearchMenuItem
           index={shouldShowMenuItem ? 1 : 0}
           index={shouldShowMenuItem ? 1 : 0}
@@ -51,11 +56,11 @@ export const SearchMethodMenuItem = (props: Props): JSX.Element => {
           getItemProps={getItemProps}
           getItemProps={getItemProps}
           url={`/_search?q=prefix:${currentPagePath} ${searchKeyword}`}
           url={`/_search?q=prefix:${currentPagePath} ${searchKeyword}`}
         >
         >
-          <span className="material-symbols-outlined fs-4 me-3">search</span>
-          <code>prefix: {currentPagePath}</code>
-          <span className="ms-2">{searchKeyword}</span>
-          <div className="ms-auto">
-            <span>{t('search_method_menu_item.only_children_of_this_tree')}</span>
+          <span className="material-symbols-outlined fs-4 me-3 p-0">search</span>
+          <div className="w-100 d-flex align-items-md-center flex-md-row align-items-start flex-column">
+            <code className="text-break">{currentPageName}</code>
+            <span className="ms-md-2 text-break me-auto">{searchKeyword}</span>
+            <span className="small text-body-tertiary">{t('search_method_menu_item.only_children_of_this_tree')}</span>
           </div>
           </div>
         </SearchMenuItem>
         </SearchMenuItem>
       </div>
       </div>
@@ -67,13 +72,14 @@ export const SearchMethodMenuItem = (props: Props): JSX.Element => {
           getItemProps={getItemProps}
           getItemProps={getItemProps}
           url={`/_search?q="${searchKeyword}"`}
           url={`/_search?q="${searchKeyword}"`}
         >
         >
-          <span className="material-symbols-outlined fs-4 me-3">search</span>
-          <span>{`"${searchKeyword}"`}</span>
-          <div className="ms-auto">
-            <span>{t('search_method_menu_item.exact_mutch')}</span>
+          <span className="material-symbols-outlined fs-4 me-3 p-0">search</span>
+          <div className="w-100 d-flex align-items-md-center flex-md-row align-items-start flex-column">
+            <span className="text-break me-auto">{`"${searchKeyword}"`}</span>
+            <span className="small text-body-tertiary">{t('search_method_menu_item.exact_mutch')}</span>
           </div>
           </div>
         </SearchMenuItem>
         </SearchMenuItem>
       ) }
       ) }
-    </>
+    </div>
+
   );
   );
 };
 };

+ 4 - 3
apps/app/src/features/search/client/components/SearchModal.tsx

@@ -56,7 +56,7 @@ const SearchModal = (): JSX.Element => {
 
 
   return (
   return (
     <Modal size="lg" isOpen={searchModalData?.isOpened ?? false} toggle={closeSearchModal} data-testid="search-modal">
     <Modal size="lg" isOpen={searchModalData?.isOpened ?? false} toggle={closeSearchModal} data-testid="search-modal">
-      <ModalBody>
+      <ModalBody className="pb-2">
         <Downshift
         <Downshift
           onSelect={selectSearchMenuItemHandler}
           onSelect={selectSearchMenuItemHandler}
           stateReducer={stateReducer}
           stateReducer={stateReducer}
@@ -83,11 +83,11 @@ const SearchModal = (): JSX.Element => {
                   className="btn border-0 d-flex justify-content-center p-0"
                   className="btn border-0 d-flex justify-content-center p-0"
                   onClick={closeSearchModal}
                   onClick={closeSearchModal}
                 >
                 >
-                  <span className="material-symbols-outlined fs-4 ms-3">close</span>
+                  <span className="material-symbols-outlined fs-4 ms-3 py-0">close</span>
                 </button>
                 </button>
               </div>
               </div>
 
 
-              <ul {...getMenuProps()} className="list-unstyled">
+              <ul {...getMenuProps()} className="list-unstyled m-0">
                 <div className="border-top mt-3 mb-2" />
                 <div className="border-top mt-3 mb-2" />
                 <SearchMethodMenuItem
                 <SearchMethodMenuItem
                   activeIndex={highlightedIndex}
                   activeIndex={highlightedIndex}
@@ -100,6 +100,7 @@ const SearchModal = (): JSX.Element => {
                   searchKeyword={searchKeyword}
                   searchKeyword={searchKeyword}
                   getItemProps={getItemProps}
                   getItemProps={getItemProps}
                 />
                 />
+                <div className="border-top mt-2 mb-2" />
               </ul>
               </ul>
             </div>
             </div>
           )}
           )}

+ 5 - 6
apps/app/src/features/search/client/components/SearchResultMenuItem.tsx

@@ -46,7 +46,7 @@ export const SearchResultMenuItem = (props: Props): JSX.Element => {
   }
   }
 
 
   return (
   return (
-    <>
+    <div>
       {searchResult?.data
       {searchResult?.data
         .map((item, index) => (
         .map((item, index) => (
           <SearchMenuItem
           <SearchMenuItem
@@ -62,14 +62,13 @@ export const SearchResultMenuItem = (props: Props): JSX.Element => {
               <PagePathLabel path={item.data.path} />
               <PagePathLabel path={item.data.path} />
             </span>
             </span>
 
 
-            <span className="ms-2 d-flex justify-content-center align-items-center">
-              <span className="material-symbols-outlined fs-5">footprint</span>
-              <span>{item.data.seenUsers.length}</span>
+            <span className="text-body-tertiary ms-2 d-flex justify-content-center align-items-center">
+              <span className="material-symbols-outlined fs-6 p-0">footprint</span>
+              <span className="fs-6">{item.data.seenUsers.length}</span>
             </span>
             </span>
           </SearchMenuItem>
           </SearchMenuItem>
         ))
         ))
       }
       }
-      <div className="border-top mt-2 mb-2" />
-    </>
+    </div>
   );
   );
 };
 };

+ 2 - 0
apps/app/src/interfaces/page.ts

@@ -43,4 +43,6 @@ export type IOptionsForCreate = {
   grant?: PageGrant,
   grant?: PageGrant,
   grantUserGroupIds?: IGrantedGroup[],
   grantUserGroupIds?: IGrantedGroup[],
   overwriteScopesOfDescendants?: boolean,
   overwriteScopesOfDescendants?: boolean,
+
+  wip?: boolean,
 };
 };

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

@@ -42,7 +42,7 @@ const InstallerPage: NextPage<Props> = (props: Props) => {
   const navTabMapping = useMemo(() => {
   const navTabMapping = useMemo(() => {
     return {
     return {
       user_infomation: {
       user_infomation: {
-        Icon: () => <i className="icon-fw icon-user"></i>,
+        Icon: () => <span className="material-symbols-outlined me-1">person</span>,
         Content: InstallerForm,
         Content: InstallerForm,
         i18n: t('installer.tab'),
         i18n: t('installer.tab'),
       },
       },

+ 1 - 0
apps/app/src/server/crowi/index.js

@@ -725,6 +725,7 @@ Crowi.prototype.setupPageService = async function() {
   // initialize after pageGrantService since pageService uses pageGrantService in constructor
   // initialize after pageGrantService since pageService uses pageGrantService in constructor
   if (this.pageService == null) {
   if (this.pageService == null) {
     this.pageService = new PageService(this);
     this.pageService = new PageService(this);
+    await this.pageService.createTtlIndex();
   }
   }
   if (this.pageOperationService == null) {
   if (this.pageOperationService == null) {
     this.pageOperationService = new PageOperationService(this);
     this.pageOperationService = new PageOperationService(this);

+ 39 - 1
apps/app/src/server/models/page.ts

@@ -8,7 +8,7 @@ import {
   GroupType, type HasObjectId,
   GroupType, type HasObjectId,
 } from '@growi/core';
 } from '@growi/core';
 import { getIdForRef, isPopulated } from '@growi/core/dist/interfaces';
 import { getIdForRef, isPopulated } from '@growi/core/dist/interfaces';
-import { isTopPage, hasSlash, collectAncestorPaths } from '@growi/core/dist/utils/page-path-utils';
+import { isTopPage, hasSlash } from '@growi/core/dist/utils/page-path-utils';
 import { addTrailingSlash, normalizePath } from '@growi/core/dist/utils/path-utils';
 import { addTrailingSlash, normalizePath } from '@growi/core/dist/utils/path-utils';
 import escapeStringRegexp from 'escape-string-regexp';
 import escapeStringRegexp from 'escape-string-regexp';
 import type { Model, Document, AnyObject } from 'mongoose';
 import type { Model, Document, AnyObject } from 'mongoose';
@@ -24,6 +24,7 @@ import type { IOptionsForCreate } from '~/interfaces/page';
 import type { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
 import type { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
 
 
 import loggerFactory from '../../utils/logger';
 import loggerFactory from '../../utils/logger';
+import { collectAncestorPaths } from '../util/collect-ancestor-paths';
 import { getOrCreateModel } from '../util/mongoose-utils';
 import { getOrCreateModel } from '../util/mongoose-utils';
 
 
 import { getPageSchema, extractToAncestorsPaths, populateDataToShowRevision } from './obsolete-page';
 import { getPageSchema, extractToAncestorsPaths, populateDataToShowRevision } from './obsolete-page';
@@ -142,6 +143,8 @@ const schema = new Schema<PageDocument, PageModel>({
   seenUsers: [{ type: ObjectId, ref: 'User' }],
   seenUsers: [{ type: ObjectId, ref: 'User' }],
   commentCount: { type: Number, default: 0 },
   commentCount: { type: Number, default: 0 },
   expandContentWidth: { type: Boolean },
   expandContentWidth: { type: Boolean },
+  wip: { type: Boolean },
+  ttlTimestamp: { type: Date, index: true },
   updatedAt: { type: Date, default: Date.now }, // Do not use timetamps for updatedAt because it breaks 'updateMetadata: false' option
   updatedAt: { type: Date, default: Date.now }, // Do not use timetamps for updatedAt because it breaks 'updateMetadata: false' option
   deleteUser: { type: ObjectId, ref: 'User' },
   deleteUser: { type: ObjectId, ref: 'User' },
   deletedAt: { type: Date },
   deletedAt: { type: Date },
@@ -205,6 +208,18 @@ export class PageQueryBuilder {
     return this;
     return this;
   }
   }
 
 
+  addConditionToExcludeWipPage(): PageQueryBuilder {
+    this.query = this.query
+      .and({
+        $or: [
+          { wip: undefined },
+          { wip: false },
+        ],
+      });
+
+    return this;
+  }
+
   /**
   /**
    * generate the query to find the pages '{path}/*' and '{path}' self.
    * generate the query to find the pages '{path}/*' and '{path}' self.
    * If top page, return without doing anything.
    * If top page, return without doing anything.
@@ -656,10 +671,15 @@ schema.statics.findRecentUpdatedPages = async function(
 
 
   const baseQuery = this.find({});
   const baseQuery = this.find({});
   const queryBuilder = new PageQueryBuilder(baseQuery, includeEmpty);
   const queryBuilder = new PageQueryBuilder(baseQuery, includeEmpty);
+
   if (!options.includeTrashed) {
   if (!options.includeTrashed) {
     queryBuilder.addConditionToExcludeTrashed();
     queryBuilder.addConditionToExcludeTrashed();
   }
   }
 
 
+  if (!options.includeWipPage) {
+    queryBuilder.addConditionToExcludeWipPage();
+  }
+
   queryBuilder.addConditionToListWithDescendants(path, options);
   queryBuilder.addConditionToListWithDescendants(path, options);
   queryBuilder.populateDataToList(User.USER_FIELDS_EXCEPT_CONFIDENTIAL);
   queryBuilder.populateDataToList(User.USER_FIELDS_EXCEPT_CONFIDENTIAL);
   await queryBuilder.addViewerCondition(user);
   await queryBuilder.addViewerCondition(user);
@@ -1089,6 +1109,24 @@ schema.methods.calculateAndUpdateLatestRevisionBodyLength = async function(this:
   await this.save();
   await this.save();
 };
 };
 
 
+schema.methods.publish = function() {
+  this.wip = undefined;
+  this.ttlTimestamp = undefined;
+};
+
+schema.methods.unpublish = function() {
+  this.wip = true;
+  this.ttlTimestamp = undefined;
+};
+
+schema.methods.makeWip = function(disableTtl: boolean) {
+  this.wip = true;
+
+  if (!disableTtl) {
+    this.ttlTimestamp = new Date();
+  }
+};
+
 /*
 /*
  * Merge obsolete page model methods and define new methods which depend on crowi instance
  * Merge obsolete page model methods and define new methods which depend on crowi instance
  */
  */

+ 12 - 3
apps/app/src/server/routes/apiv3/page/create-page.ts

@@ -2,7 +2,7 @@ import type {
   IPage, IUser, IUserHasId,
   IPage, IUser, IUserHasId,
 } from '@growi/core';
 } from '@growi/core';
 import { ErrorV3 } from '@growi/core/dist/models';
 import { ErrorV3 } from '@growi/core/dist/models';
-import { isCreatablePage, isUserPage } from '@growi/core/dist/utils/page-path-utils';
+import { isCreatablePage, isUserPage, isUsersHomepage } from '@growi/core/dist/utils/page-path-utils';
 import { attachTitleHeader, normalizePath } from '@growi/core/dist/utils/path-utils';
 import { attachTitleHeader, normalizePath } from '@growi/core/dist/utils/path-utils';
 import type { Request, RequestHandler } from 'express';
 import type { Request, RequestHandler } from 'express';
 import type { ValidationChain } from 'express-validator';
 import type { ValidationChain } from 'express-validator';
@@ -63,6 +63,11 @@ async function determinePath(_parentPath?: string, _path?: string, optionalParen
   if (_parentPath != null) {
   if (_parentPath != null) {
     const parentPath = normalizePath(_parentPath);
     const parentPath = normalizePath(_parentPath);
 
 
+    // when parentPath is user's homepage
+    if (isUsersHomepage(parentPath)) {
+      return generateUntitledPath(parentPath, basePathname);
+    }
+
     // when parentPath is valid
     // when parentPath is valid
     if (isCreatablePage(parentPath)) {
     if (isCreatablePage(parentPath)) {
       return generateUntitledPath(parentPath, basePathname);
       return generateUntitledPath(parentPath, basePathname);
@@ -111,6 +116,7 @@ export const createPageHandlersFactory: CreatePageHandlersFactory = (crowi) => {
     body('pageTags').optional().isArray().withMessage('pageTags must be array'),
     body('pageTags').optional().isArray().withMessage('pageTags must be array'),
     body('isSlackEnabled').optional().isBoolean().withMessage('isSlackEnabled must be boolean'),
     body('isSlackEnabled').optional().isBoolean().withMessage('isSlackEnabled must be boolean'),
     body('slackChannels').optional().isString().withMessage('slackChannels must be string'),
     body('slackChannels').optional().isString().withMessage('slackChannels must be string'),
+    body('wip').optional().isBoolean().withMessage('wip must be boolean'),
   ];
   ];
 
 
 
 
@@ -220,8 +226,11 @@ export const createPageHandlersFactory: CreatePageHandlersFactory = (crowi) => {
 
 
       let createdPage;
       let createdPage;
       try {
       try {
-        const { grant, grantUserGroupIds, overwriteScopesOfDescendants } = req.body;
-        const options: IOptionsForCreate = { overwriteScopesOfDescendants };
+        const {
+          grant, grantUserGroupIds, overwriteScopesOfDescendants, wip,
+        } = req.body;
+
+        const options: IOptionsForCreate = { overwriteScopesOfDescendants, wip };
         if (grant != null) {
         if (grant != null) {
           options.grant = grant;
           options.grant = grant;
           options.grantUserGroupIds = grantUserGroupIds;
           options.grantUserGroupIds = grantUserGroupIds;

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

@@ -22,6 +22,8 @@ import loggerFactory from '~/utils/logger';
 
 
 import { checkPageExistenceHandlersFactory } from './check-page-existence';
 import { checkPageExistenceHandlersFactory } from './check-page-existence';
 import { createPageHandlersFactory } from './create-page';
 import { createPageHandlersFactory } from './create-page';
+import { publishPageHandlersFactory } from './publish-page';
+import { unpublishPageHandlersFactory } from './unpublish-page';
 import { updatePageHandlersFactory } from './update-page';
 import { updatePageHandlersFactory } from './update-page';
 
 
 
 
@@ -920,5 +922,10 @@ module.exports = (crowi) => {
       }
       }
     });
     });
 
 
+
+  router.put('/:pageId/publish', publishPageHandlersFactory(crowi));
+
+  router.put('/:pageId/unpublish', unpublishPageHandlersFactory(crowi));
+
   return router;
   return router;
 };
 };

+ 62 - 0
apps/app/src/server/routes/apiv3/page/publish-page.ts

@@ -0,0 +1,62 @@
+import type { IPage, IUserHasId } from '@growi/core';
+import { ErrorV3 } from '@growi/core/dist/models';
+import type { Request, RequestHandler } from 'express';
+import type { ValidationChain } from 'express-validator';
+import { param } from 'express-validator';
+import mongoose from 'mongoose';
+
+import type Crowi from '~/server/crowi';
+import type { PageModel } from '~/server/models/page';
+import loggerFactory from '~/utils/logger';
+
+import { apiV3FormValidator } from '../../../middlewares/apiv3-form-validator';
+import type { ApiV3Response } from '../interfaces/apiv3-response';
+
+
+const logger = loggerFactory('growi:routes:apiv3:page:unpublish-page');
+
+
+type ReqParams = {
+  pageId: string,
+}
+
+interface Req extends Request<ReqParams, ApiV3Response> {
+  user: IUserHasId,
+}
+
+type PublishPageHandlersFactory = (crowi: Crowi) => RequestHandler[];
+
+export const publishPageHandlersFactory: PublishPageHandlersFactory = (crowi) => {
+  const Page = mongoose.model<IPage, PageModel>('Page');
+
+  const accessTokenParser = require('../../../middlewares/access-token-parser')(crowi);
+  const loginRequiredStrictly = require('../../../middlewares/login-required')(crowi);
+
+  // define validators for req.body
+  const validator: ValidationChain[] = [
+    param('pageId').isMongoId().withMessage('The param "pageId" must be specified'),
+  ];
+
+  return [
+    accessTokenParser, loginRequiredStrictly,
+    validator, apiV3FormValidator,
+    async(req: Req, res: ApiV3Response) => {
+      const { pageId } = req.params;
+
+      try {
+        const page = await Page.findById(pageId);
+        if (page == null) {
+          return res.apiv3Err(new ErrorV3(`Page ${pageId} is not exist.`), 404);
+        }
+
+        page.publish();
+        const updatedPage = await page.save();
+        return res.apiv3(updatedPage);
+      }
+      catch (err) {
+        logger.error(err);
+        return res.apiv3Err(err);
+      }
+    },
+  ];
+};

+ 63 - 0
apps/app/src/server/routes/apiv3/page/unpublish-page.ts

@@ -0,0 +1,63 @@
+import type { IPage, IUserHasId } from '@growi/core';
+import { ErrorV3 } from '@growi/core/dist/models';
+import type { Request, RequestHandler } from 'express';
+import type { ValidationChain } from 'express-validator';
+import { param } from 'express-validator';
+import mongoose from 'mongoose';
+
+import type Crowi from '~/server/crowi';
+import type { PageModel } from '~/server/models/page';
+import loggerFactory from '~/utils/logger';
+
+import { apiV3FormValidator } from '../../../middlewares/apiv3-form-validator';
+import type { ApiV3Response } from '../interfaces/apiv3-response';
+
+
+const logger = loggerFactory('growi:routes:apiv3:page:unpublish-page');
+
+
+type ReqParams = {
+  pageId: string,
+}
+
+interface Req extends Request<ReqParams, ApiV3Response> {
+  user: IUserHasId,
+}
+
+type UnpublishPageHandlersFactory = (crowi: Crowi) => RequestHandler[];
+
+export const unpublishPageHandlersFactory: UnpublishPageHandlersFactory = (crowi) => {
+  const Page = mongoose.model<IPage, PageModel>('Page');
+
+  const accessTokenParser = require('../../../middlewares/access-token-parser')(crowi);
+  const loginRequiredStrictly = require('../../../middlewares/login-required')(crowi);
+
+  // define validators for req.body
+  const validator: ValidationChain[] = [
+    param('pageId').isMongoId().withMessage('The param "pageId" must be specified'),
+  ];
+
+  return [
+    accessTokenParser, loginRequiredStrictly,
+    validator, apiV3FormValidator,
+    async(req: Req, res: ApiV3Response) => {
+      const { pageId } = req.params;
+
+      try {
+        const page = await Page.findById(pageId);
+        if (page == null) {
+          return res.apiv3Err(new ErrorV3(`Page ${pageId} is not exist.`), 404);
+        }
+
+        page.unpublish();
+        const updatedPage = await page.save();
+
+        return res.apiv3(updatedPage);
+      }
+      catch (err) {
+        logger.error(err);
+        return res.apiv3Err(err);
+      }
+    },
+  ];
+};

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

@@ -160,6 +160,11 @@ module.exports = (crowi) => {
   const addActivity = generateAddActivityMiddleware(crowi);
   const addActivity = generateAddActivityMiddleware(crowi);
 
 
   const validator = {
   const validator = {
+    recent: [
+      query('limit').optional().isInt().withMessage('limit must be integer'),
+      query('offset').optional().isInt().withMessage('offset must be integer'),
+      query('includeWipPage').optional().isBoolean().withMessage('includeWipPage must be boolean'),
+    ],
     renamePage: [
     renamePage: [
       body('pageId').isMongoId().withMessage('pageId is required'),
       body('pageId').isMongoId().withMessage('pageId is required'),
       body('revisionId').optional({ nullable: true }).isMongoId().withMessage('revisionId is required'), // required when v4
       body('revisionId').optional({ nullable: true }).isMongoId().withMessage('revisionId is required'), // required when v4
@@ -216,12 +221,15 @@ module.exports = (crowi) => {
    *            description: Return pages recently updated
    *            description: Return pages recently updated
    *
    *
    */
    */
-  router.get('/recent', accessTokenParser, loginRequired, async(req, res) => {
+  router.get('/recent', accessTokenParser, loginRequired, validator.recent, apiV3FormValidator, async(req, res) => {
     const limit = parseInt(req.query.limit) || 20;
     const limit = parseInt(req.query.limit) || 20;
     const offset = parseInt(req.query.offset) || 0;
     const offset = parseInt(req.query.offset) || 0;
+    const includeWipPage = req.query.includeWipPage === 'true'; // Need validation using express-validator
+
     const queryOptions = {
     const queryOptions = {
       offset,
       offset,
       limit,
       limit,
+      includeWipPage,
       includeTrashed: false,
       includeTrashed: false,
       isRegExpEscapedFromPath: true,
       isRegExpEscapedFromPath: true,
       sort: 'updatedAt',
       sort: 'updatedAt',

+ 8 - 3
apps/app/src/server/service/config-loader.ts

@@ -4,9 +4,8 @@ import { parseISO } from 'date-fns';
 import { GrowiServiceType } from '~/features/questionnaire/interfaces/growi-info';
 import { GrowiServiceType } from '~/features/questionnaire/interfaces/growi-info';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
-import ConfigModel, {
-  Config, defaultCrowiConfigs, defaultMarkdownConfigs, defaultNotificationConfigs,
-} from '../models/config';
+import type { Config } from '../models/config';
+import ConfigModel, { defaultCrowiConfigs, defaultMarkdownConfigs, defaultNotificationConfigs } from '../models/config';
 
 
 
 
 const logger = loggerFactory('growi:service:ConfigLoader');
 const logger = loggerFactory('growi:service:ConfigLoader');
@@ -712,6 +711,12 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type: ValueType.NUMBER,
     type: ValueType.NUMBER,
     default: 30000,
     default: 30000,
   },
   },
+  WIP_PAGE_EXPIRATION_SECONDS: {
+    ns: 'crowi',
+    key: 'app:wipPageExpirationSeconds',
+    type: ValueType.NUMBER,
+    default: 172800, // 2 days
+  },
 };
 };
 
 
 
 

+ 6 - 6
apps/app/src/server/service/page-operation.ts

@@ -1,13 +1,13 @@
 import { pagePathUtils } from '@growi/core/dist/utils';
 import { pagePathUtils } from '@growi/core/dist/utils';
-import { collectAncestorPaths } from '@growi/core/dist/utils/page-path-utils';
 
 
-import {
-  IPageOperationProcessInfo, IPageOperationProcessData, PageActionType, PageActionStage,
-} from '~/interfaces/page-operation';
-import PageOperation, { PageOperationDocument } from '~/server/models/page-operation';
+import type { IPageOperationProcessInfo, IPageOperationProcessData } from '~/interfaces/page-operation';
+import { PageActionType, PageActionStage } from '~/interfaces/page-operation';
+import type { PageOperationDocument } from '~/server/models/page-operation';
+import PageOperation from '~/server/models/page-operation';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
-import { ObjectIdLike } from '../interfaces/mongoose-utils';
+import type { ObjectIdLike } from '../interfaces/mongoose-utils';
+import { collectAncestorPaths } from '../util/collect-ancestor-paths';
 
 
 const logger = loggerFactory('growi:services:page-operation');
 const logger = loggerFactory('growi:services:page-operation');
 
 

+ 57 - 1
apps/app/src/server/service/page/index.ts

@@ -41,6 +41,7 @@ import type { PageTagRelationDocument } from '~/server/models/page-tag-relation'
 import PageTagRelation from '~/server/models/page-tag-relation';
 import PageTagRelation from '~/server/models/page-tag-relation';
 import type { UserGroupDocument } from '~/server/models/user-group';
 import type { UserGroupDocument } from '~/server/models/user-group';
 import { createBatchStream } from '~/server/util/batch-stream';
 import { createBatchStream } from '~/server/util/batch-stream';
+import { collectAncestorPaths } from '~/server/util/collect-ancestor-paths';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 import { prepareDeleteConfigValuesForCalc } from '~/utils/page-delete-config';
 import { prepareDeleteConfigValuesForCalc } from '~/utils/page-delete-config';
 
 
@@ -71,7 +72,7 @@ const debug = require('debug')('growi:services:page');
 
 
 const logger = loggerFactory('growi:services:page');
 const logger = loggerFactory('growi:services:page');
 const {
 const {
-  isTrashPage, isTopPage, omitDuplicateAreaPageFromPages, getUsernameByPath, collectAncestorPaths,
+  isTrashPage, isTopPage, omitDuplicateAreaPageFromPages, getUsernameByPath,
   canMoveByPath, isUsersTopPage, isMovablePage, isUsersHomepage, hasSlash, generateChildrenRegExp,
   canMoveByPath, isUsersTopPage, isMovablePage, isUsersHomepage, hasSlash, generateChildrenRegExp,
 } = pagePathUtils;
 } = pagePathUtils;
 
 
@@ -587,6 +588,8 @@ class PageService implements IPageService {
 
 
       this.activityEvent.emit('updated', activity, page, preNotify);
       this.activityEvent.emit('updated', activity, page, preNotify);
     }
     }
+
+    this.disableAncestorPagesTtl(newPagePath);
     return renamedPage;
     return renamedPage;
   }
   }
 
 
@@ -3810,6 +3813,13 @@ class PageService implements IPageService {
       const parent = await this.getParentAndFillAncestorsByUser(user, path);
       const parent = await this.getParentAndFillAncestorsByUser(user, path);
       page.parent = parent._id;
       page.parent = parent._id;
     }
     }
+
+    // Make WIP
+    if (options.wip) {
+      const hasChildren = await Page.exists({ parent: page._id });
+      page.makeWip(hasChildren != null); // disableTtl = hasChildren != null
+    }
+
     // Save
     // Save
     let savedPage = await page.save();
     let savedPage = await page.save();
 
 
@@ -3848,6 +3858,8 @@ class PageService implements IPageService {
    * Used to run sub operation in create method
    * Used to run sub operation in create method
    */
    */
   async createSubOperation(page, user, options: IOptionsForCreate, pageOpId: ObjectIdLike): Promise<void> {
   async createSubOperation(page, user, options: IOptionsForCreate, pageOpId: ObjectIdLike): Promise<void> {
+    await this.disableAncestorPagesTtl(page.path);
+
     // Update descendantCount
     // Update descendantCount
     await this.updateDescendantCountOfAncestors(page._id, 1, false);
     await this.updateDescendantCountOfAncestors(page._id, 1, false);
 
 
@@ -3932,6 +3944,18 @@ class PageService implements IPageService {
     return this.canProcessCreate(path, grantData, false);
     return this.canProcessCreate(path, grantData, false);
   }
   }
 
 
+  private async disableAncestorPagesTtl(path: string): Promise<void> {
+    const Page = mongoose.model<PageDocument, PageModel>('Page');
+
+    const ancestorPaths = collectAncestorPaths(path);
+    const ancestorPageIds = await Page.aggregate([
+      { $match: { path: { $in: ancestorPaths, $nin: ['/'] }, isEmpty: false } },
+      { $project: { _id: 1 } },
+    ]);
+
+    await Page.updateMany({ _id: { $in: ancestorPageIds } }, { $unset: { ttlTimestamp: true } });
+  }
+
   /**
   /**
    * @private
    * @private
    * This method receives the same arguments as the PageService.create method does except for the added type '{ grantUserIds?: ObjectIdLike[] }'.
    * This method receives the same arguments as the PageService.create method does except for the added type '{ grantUserIds?: ObjectIdLike[] }'.
@@ -4122,6 +4146,10 @@ class PageService implements IPageService {
     const clonedPageData = Page.hydrate(pageData.toObject());
     const clonedPageData = Page.hydrate(pageData.toObject());
     const newPageData = pageData;
     const newPageData = pageData;
 
 
+    // If updated at least once, publish
+    pageData.publish();
+
+
     // use the previous data if absent
     // use the previous data if absent
     const grant = options.grant ?? clonedPageData.grant;
     const grant = options.grant ?? clonedPageData.grant;
     const grantUserGroupIds = options.userRelatedGrantUserGroupIds != null
     const grantUserGroupIds = options.userRelatedGrantUserGroupIds != null
@@ -4402,6 +4430,34 @@ class PageService implements IPageService {
     });
     });
   }
   }
 
 
+  async createTtlIndex(): Promise<void> {
+    const wipPageExpirationSeconds = configManager.getConfig('crowi', 'app:wipPageExpirationSeconds') ?? 172800;
+    const collection = mongoose.connection.collection('pages');
+
+    try {
+      const targetField = 'ttlTimestamp_1';
+
+      const indexes = await collection.indexes();
+      const foundTargetField = indexes.find(i => i.name === targetField);
+
+      const isNotSpec = foundTargetField?.expireAfterSeconds == null || foundTargetField?.expireAfterSeconds !== wipPageExpirationSeconds;
+      const shoudDropIndex = foundTargetField != null && isNotSpec;
+      const shoudCreateIndex = foundTargetField == null || shoudDropIndex;
+
+      if (shoudDropIndex) {
+        await collection.dropIndex(targetField);
+      }
+
+      if (shoudCreateIndex) {
+        await collection.createIndex({ ttlTimestamp: 1 }, { expireAfterSeconds: wipPageExpirationSeconds });
+      }
+    }
+    catch (err) {
+      logger.error('Failed to create TTL Index', err);
+      throw err;
+    }
+  }
+
 }
 }
 
 
 export default PageService;
 export default PageService;

+ 3 - 3
packages/core/src/utils/page-path-utils/collect-ancestor-paths.ts → apps/app/src/server/util/collect-ancestor-paths.ts

@@ -1,6 +1,6 @@
-import platformPath from 'path';
+import { dirname } from 'node:path';
 
 
-import { isTopPage } from './is-top-page';
+import { isTopPage } from '@growi/core/dist/utils/page-path-utils';
 
 
 /**
 /**
  * returns ancestors paths
  * returns ancestors paths
@@ -11,7 +11,7 @@ import { isTopPage } from './is-top-page';
 export const collectAncestorPaths = (path: string, ancestorPaths: string[] = []): string[] => {
 export const collectAncestorPaths = (path: string, ancestorPaths: string[] = []): string[] => {
   if (isTopPage(path)) return ancestorPaths;
   if (isTopPage(path)) return ancestorPaths;
 
 
-  const parentPath = platformPath.dirname(path);
+  const parentPath = dirname(path);
   ancestorPaths.push(parentPath);
   ancestorPaths.push(parentPath);
   return collectAncestorPaths(parentPath, ancestorPaths);
   return collectAncestorPaths(parentPath, ancestorPaths);
 };
 };

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است