Преглед изворни кода

Merge pull request #5849 from weseek/master

Release v5.0.5 (retry)
Yuki Takei пре 3 година
родитељ
комит
3225a21299
69 измењених фајлова са 818 додато и 503 уклоњено
  1. 0 5
      .github/workflows/release-rc.yml
  2. 0 5
      .github/workflows/release.yml
  3. 32 24
      CHANGELOG.md
  4. 1 1
      lerna.json
  5. 1 1
      package.json
  6. 1 0
      packages/app/docker/Dockerfile
  7. 7 7
      packages/app/package.json
  8. 9 3
      packages/app/resource/locales/en_US/translation.json
  9. 9 2
      packages/app/resource/locales/ja_JP/translation.json
  10. 10 3
      packages/app/resource/locales/zh_CN/translation.json
  11. 9 8
      packages/app/src/client/base.jsx
  12. 1 1
      packages/app/src/components/Admin/Users/UserInviteModal.jsx
  13. 5 2
      packages/app/src/components/CustomNavigation/CustomNav.jsx
  14. 4 1
      packages/app/src/components/CustomNavigation/CustomNavAndContents.jsx
  15. 61 0
      packages/app/src/components/EmptyTrashButton.tsx
  16. 0 71
      packages/app/src/components/EmptyTrashModal.jsx
  17. 92 0
      packages/app/src/components/EmptyTrashModal.tsx
  18. 7 4
      packages/app/src/components/MaintenanceModeContent.tsx
  19. 2 2
      packages/app/src/components/Page/TagsInput.tsx
  20. 8 22
      packages/app/src/components/PageCreateModal.jsx
  21. 12 10
      packages/app/src/components/PageDeleteModal.tsx
  22. 5 7
      packages/app/src/components/PageDuplicateModal.tsx
  23. 48 21
      packages/app/src/components/PageRenameModal.tsx
  24. 40 15
      packages/app/src/components/PrivateLegacyPages.tsx
  25. 1 8
      packages/app/src/components/Sidebar/PageTree/Item.tsx
  26. 2 2
      packages/app/src/components/Sidebar/Tag.tsx
  27. 5 3
      packages/app/src/components/TagCloudBox.tsx
  28. 3 3
      packages/app/src/components/TagList.tsx
  29. 2 2
      packages/app/src/components/TagPage.tsx
  30. 9 2
      packages/app/src/components/TrashPageList.jsx
  31. 3 3
      packages/app/src/interfaces/page.ts
  32. 6 9
      packages/app/src/interfaces/tag.ts
  33. 6 0
      packages/app/src/interfaces/websocket.ts
  34. 13 12
      packages/app/src/server/crowi/index.js
  35. 3 5
      packages/app/src/server/models/page-tag-relation.js
  36. 0 69
      packages/app/src/server/models/tag.js
  37. 63 0
      packages/app/src/server/models/tag.ts
  38. 5 1
      packages/app/src/server/routes/apiv3/page.js
  39. 37 9
      packages/app/src/server/routes/apiv3/pages.js
  40. 2 1
      packages/app/src/server/routes/tag.js
  41. 41 20
      packages/app/src/server/service/page.ts
  42. 1 0
      packages/app/src/server/views/layout/layout.html
  43. 48 2
      packages/app/src/stores/modal.tsx
  44. 3 3
      packages/app/src/stores/tag.tsx
  45. 10 0
      packages/app/src/styles/_mixins.scss
  46. 0 6
      packages/app/src/styles/_page-tree.scss
  47. 5 9
      packages/app/src/styles/theme/_apply-colors-dark.scss
  48. 8 12
      packages/app/src/styles/theme/_apply-colors-light.scss
  49. 0 17
      packages/app/src/styles/theme/christmas.scss
  50. 1 1
      packages/app/src/styles/theme/default.scss
  51. 0 17
      packages/app/src/styles/theme/future.scss
  52. 0 8
      packages/app/src/styles/theme/island.scss
  53. 0 17
      packages/app/src/styles/theme/kibela.scss
  54. 24 8
      packages/app/src/styles/theme/mixins/_list-group.scss
  55. 1 1
      packages/app/src/styles/theme/nature.scss
  56. 1 1
      packages/app/src/styles/theme/wood.scss
  57. 1 2
      packages/app/test/integration/models/v5.page.test.js
  58. 3 2
      packages/app/test/integration/service/page.test.js
  59. 125 7
      packages/app/test/integration/service/user-groups.test.ts
  60. 5 7
      packages/app/test/integration/service/v5.non-public-page.test.ts
  61. 8 10
      packages/app/test/integration/service/v5.public-page.test.ts
  62. 1 1
      packages/codemirror-textlint/package.json
  63. 1 1
      packages/core/package.json
  64. 1 1
      packages/plugin-attachment-refs/package.json
  65. 1 1
      packages/plugin-lsx/package.json
  66. 1 1
      packages/plugin-pukiwiki-like-linker/package.json
  67. 1 1
      packages/slack/package.json
  68. 2 2
      packages/slackbot-proxy/package.json
  69. 1 1
      packages/ui/package.json

+ 0 - 5
.github/workflows/release-rc.yml

@@ -54,8 +54,3 @@ jobs:
         cache-from: type=gha
         cache-to: type=gha,mode=max
         tags: ${{ steps.meta.outputs.tags }}
-
-    - name: Move cache
-      run: |
-        rm -rf /tmp/.buildx-cache
-        mv /tmp/.buildx-cache-new /tmp/.buildx-cache

+ 0 - 5
.github/workflows/release.yml

@@ -183,11 +183,6 @@ jobs:
         cache-to: type=gha,mode=max
         tags: ${{ steps.meta.outputs.tags }}
 
-    - name: Move cache
-      run: |
-        rm -rf /tmp/.buildx-cache
-        mv /tmp/.buildx-cache-new /tmp/.buildx-cache
-
     - name: Update Docker Hub Description
       uses: peter-evans/dockerhub-description@v3
       with:

+ 32 - 24
CHANGELOG.md

@@ -30,6 +30,13 @@
 - fix: Too many footstamps icons are shown by lsx output 2 (#5763) @yuki-takei
 - fix:  footstamp-icon size (#5759) @kaoritokashiki
 
+## [v4.5.19](https://github.com/weseek/growi/compare/v4.5.18...v4.5.19) - 2022-04-28
+
+### 🐛 Bug Fixes
+
+- fix: Swiping to previous/next page for Mac users (4.5.x) (#5758) @hirokei-camel
+- fix: Get attachment list api without "page" parameter returns 500 response (#5726) @miya
+
 ## [v5.0.3](https://github.com/weseek/growi/compare/v5.0.2...v5.0.3) - 2022-04-21
 
 ### 💎 Features
@@ -95,6 +102,31 @@
 - support: Migration for setting sparce option to slack member id (#5694) @kaoritokashiki
 - support: Update eslint-config-weseek (#5673) @yuki-takei
 
+## [v4.5.18](https://github.com/weseek/growi/compare/v4.5.17...v4.5.18) - 2022-04-15
+
+### 🐛 Bug Fixes
+
+- fix: One Time Token is not available for v4.5.x (#5713) @miya
+- fix: Prevent auto completing email with username stored by browser in /me page for v4.5.x (#5703) @Yohei-Shiina
+- fix: Page view count stops at 15 (#5705) @miya
+
+## [v4.5.17](https://github.com/weseek/growi/compare/v4.5.16...v4.5.17) - 2022-04-07
+
+### 🐛 Bug Fixes
+
+- fix: Elasticsearch doesn't work properly on production (#5676) @Yohei-Shiina
+
+## [v4.5.16](https://github.com/weseek/growi/compare/v4.5.15...v4.5.16) - 2022-04-06
+
+### 💎 Features
+
+- feat: Support Elasticsearch 7 (#5613) @Yohei-Shiina
+
+### 🐛 Bug Fixes
+
+- fix: Domain whitelist is not respected (fix #5408) (#5488) @yuto-oweseek
+- fix: Add tags to pages restricted by specified groups on View mode (for v4.5.x) (#5487) @yuto-oweseek
+
 ## [v5.0.0](https://github.com/weseek/growi/compare/v4.5.15...v5.0.0) - 2022-04-01
 
 ### 💎 Features
@@ -145,30 +177,6 @@
 - support: update nanoid yarn.lock v3.1.30 to v3.2.0 (#5216) @LuqmanHakim-Grune
 - support: update validator version (#5562) @LuqmanHakim-Grune
 
-## [v4.5.18](https://github.com/weseek/growi/compare/v4.5.17...v4.5.18) - 2022-04-15
-
-### 🐛 Bug Fixes
-
-- fix: One Time Token is not available for v4.5.x (#5713) @miya
-- fix: Prevent auto completing email with username stored by browser in /me page for v4.5.x (#5703) @Yohei-Shiina
-- fix: Page view count stops at 15 (#5705) @miya
-
-## [v4.5.17](https://github.com/weseek/growi/compare/v4.5.16...v4.5.17) - 2022-04-07
-
-### 🐛 Bug Fixes
-
-- fix: Elasticsearch doesn't work properly on production (#5676) @Yohei-Shiina
-
-## [v4.5.16](https://github.com/weseek/growi/compare/v4.5.15...v4.5.16) - 2022-04-06
-
-### 💎 Features
-
-- feat: Support Elasticsearch 7 (#5613) @Yohei-Shiina
-
-### 🐛 Bug Fixes
-
-- fix: Domain whitelist is not respected (fix #5408) (#5488) @yuto-oweseek
-- fix: Add tags to pages restricted by specified groups on View mode (for v4.5.x) (#5487) @yuto-oweseek
 
 ## [v4.5.15](https://github.com/weseek/growi/compare/v4.5.14...v4.5.15) - 2022-02-17
 

+ 1 - 1
lerna.json

@@ -1,7 +1,7 @@
 {
   "npmClient": "yarn",
   "useWorkspaces": true,
-  "version": "5.0.4",
+  "version": "5.0.5-RC.0",
   "packages": [
     "packages/*"
   ]

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "5.0.4",
+  "version": "5.0.5-RC.0",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",

+ 1 - 0
packages/app/docker/Dockerfile

@@ -159,6 +159,7 @@ RUN rm node_modules.tar packages.tar
 
 COPY --chown=node:node --chmod=700 packages/app/docker/docker-entrypoint.sh /
 
+USER root
 WORKDIR ${appDir}/packages/app
 
 VOLUME /data

+ 7 - 7
packages/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "5.0.4",
+  "version": "5.0.5-RC.0",
   "license": "MIT",
   "scripts": {
     "//// for production": "",
@@ -62,11 +62,11 @@
     "@elastic/elasticsearch7": "npm:@elastic/elasticsearch@^7.17.0",
     "@godaddy/terminus": "^4.9.0",
     "@google-cloud/storage": "^5.8.5",
-    "@growi/codemirror-textlint": "^5.0.4",
-    "@growi/plugin-attachment-refs": "^5.0.4",
-    "@growi/plugin-lsx": "^5.0.4",
-    "@growi/plugin-pukiwiki-like-linker": "^5.0.4",
-    "@growi/slack": "^5.0.4",
+    "@growi/codemirror-textlint": "^5.0.5-RC.0",
+    "@growi/plugin-attachment-refs": "^5.0.5-RC.0",
+    "@growi/plugin-lsx": "^5.0.5-RC.0",
+    "@growi/plugin-pukiwiki-like-linker": "^5.0.5-RC.0",
+    "@growi/slack": "^5.0.5-RC.0",
     "@promster/express": "^7.0.2",
     "@promster/server": "^7.0.4",
     "@slack/events-api": "^3.0.0",
@@ -167,7 +167,7 @@
   },
   "devDependencies": {
     "@alienfast/i18next-loader": "^1.1.4",
-    "@growi/ui": "^5.0.4",
+    "@growi/ui": "^5.0.5-RC.0",
     "@handsontable/react": "=2.1.0",
     "@types/compression": "^1.7.0",
     "@types/express": "^4.17.11",

+ 9 - 3
packages/app/resource/locales/en_US/translation.json

@@ -186,9 +186,7 @@
     "error_message": "Some values ​​are incorrect",
     "required": "%s is required",
     "invalid_syntax": "The syntax of %s is invalid.",
-    "title_required": "Title is required.",
-    "slashed_are_not_yet_supported": "Titles containing slashes are not yet supported"
-
+    "title_required": "Title is required."
   },
   "not_found_page": {
     "Create Page": "Create Page",
@@ -443,8 +441,11 @@
   "deleted_pages": "{{path}} has been deleted",
   "deleted_pages_completely": "{{path}} has been deleted completely",
   "renamed_pages": "{{path}} has been renamed",
+  "empty_trash": "The trash has been emptied",
   "modal_empty":{
     "empty_the_trash": "Empty The Trash",
+    "empty_the_trash_button": "Empty The Trash",
+    "not_deletable_notice": "Some pages cannot be removed due to lack of permission.",
     "notice": "The pages deleted completely are unrecoverable."
   },
   "modal_duplicate": {
@@ -655,6 +656,11 @@
       "convert_recursively_desc": "Convert pages under this path recursively.",
       "button_label": "Convert"
     },
+    "toaster": {
+      "page_migration_succeeded": "Conversion of selected page to v5 has been successfully completed.",
+      "page_migration_failed_with_paths": "Conversion of {{paths}} to v5 has been failed.",
+      "page_migration_failed": "Conversion of page to v5 has been failed."
+    },
     "by_path_modal": {
       "title": "Convert to new v5 compatible format",
       "description": "Enter a path and all pages under that path will be converted to v5 compatible format.",

+ 9 - 2
packages/app/resource/locales/ja_JP/translation.json

@@ -188,8 +188,7 @@
     "error_message": "いくつかの値が設定されていません",
     "required": "%sに値を入力してください",
     "invalid_syntax": "%sの構文が不正です",
-    "title_required": "タイトルを入力してください",
-    "slashed_are_not_yet_supported": "スラッシュを含むタイトルにはまだ対応していません"
+    "title_required": "タイトルを入力してください"
   },
   "not_found_page": {
     "Create Page": "ページを作成する",
@@ -442,8 +441,11 @@
   "deleted_pages": "{{path}} をゴミ箱に入れました",
   "deleted_pages_completely": "{{path}} を完全に削除しました",
   "renamed_pages": "{{path}} を移動/名前変更しました",
+  "empty_trash": "ゴミ箱を空にしました",
   "modal_empty":{
     "empty_the_trash": "ゴミ箱を空にする",
+    "empty_the_trash_button": "空にする",
+    "not_deletable_notice": "権限がないため、いくつかのページは削除できません",
     "notice": "完全削除したページは元に戻すことができません"
   },
   "modal_duplicate": {
@@ -654,6 +656,11 @@
       "convert_recursively_desc": "このページの配下のページを再起的に変換します",
       "button_label": "変換"
     },
+    "toaster": {
+      "page_migration_succeeded": "選択されたページの v5 互換形式への変換が正常に終了しました。",
+      "page_migration_failed_with_paths": "{{paths}} の v5 互換形式への変換中にエラーが発生しました。",
+      "page_migration_failed": "ページの v5 互換形式への変換中にエラーが発生しました。"
+    },
     "by_path_modal": {
       "title": "新しい v5 互換形式への変換",
       "description": "パスを入力することで、そのパスの配下のページを全て v5 互換形式に変換します",

+ 10 - 3
packages/app/resource/locales/zh_CN/translation.json

@@ -186,8 +186,7 @@
 		"error_message": "有些值不正确",
 		"required": "%s 是必需的",
 		"invalid_syntax": "%s的语法无效。",
-    "title_required": "标题是必需的。",
-    "slashed_are_not_yet_supported": "目前还不支持包含斜线的标题"
+    "title_required": "标题是必需的。"
   },
   "not_found_page": {
     "Create Page": "创建页面",
@@ -421,8 +420,11 @@
   "deleted_pages": "将 {{path}} 放入垃圾箱",
   "deleted_pages_completely": "{{path}} 已被完全删除",
   "renamed_pages": "移动/重命名 {{path}}",
+  "empty_trash": "清空垃圾",
 	"modal_empty": {
-		"empty_the_trash": "Empty The Trash",
+		"empty_the_trash": "清空垃圾",
+    "empty_the_trash_button": "清空垃圾",
+    "not_deletable_notice": "由于缺乏权限,一些页面不能被删除",
 		"notice": "完全删除的页面是不可恢复的。"
 	},
 	"modal_duplicate": {
@@ -941,6 +943,11 @@
       "convert_recursively_desc": "递归地转换该路径下的页面。",
       "button_label": "转换"
     },
+    "toaster": {
+      "page_migration_succeeded": "已成功将所选页面转换为 v5 兼容格式。",
+      "page_migration_failed_with_paths": "将 {{paths}} 转换为 v5 兼容格式时出错",
+      "page_migration_failed": "将页面转换为 v5 兼容格式时出错。"
+    },
     "by_path_modal": {
       "title": "转换为新的v5兼容格式",
       "description": "输入一个路径,该路径下的所有页面将被转换为v5兼容格式。",

+ 9 - 8
packages/app/src/client/base.jsx

@@ -1,22 +1,22 @@
 import React from 'react';
 
+import AppContainer from '~/client/services/AppContainer';
+import SocketIoContainer from '~/client/services/SocketIoContainer';
+import { DescendantsPageListModal } from '~/components/DescendantsPageListModal';
+import PutbackPageModal from '~/components/PutbackPageModal';
 import Xss from '~/services/xss';
 import loggerFactory from '~/utils/logger';
 
+import EmptyTrashModal from '../components/EmptyTrashModal';
+import HotkeysManager from '../components/Hotkeys/HotkeysManager';
 import GrowiNavbar from '../components/Navbar/GrowiNavbar';
 import GrowiNavbarBottom from '../components/Navbar/GrowiNavbarBottom';
-import HotkeysManager from '../components/Hotkeys/HotkeysManager';
+import PageAccessoriesModal from '../components/PageAccessoriesModal';
 import PageCreateModal from '../components/PageCreateModal';
 import PageDeleteModal from '../components/PageDeleteModal';
 import PageDuplicateModal from '../components/PageDuplicateModal';
-import PageRenameModal from '../components/PageRenameModal';
 import PagePresentationModal from '../components/PagePresentationModal';
-import PageAccessoriesModal from '../components/PageAccessoriesModal';
-import PutbackPageModal from '~/components/PutbackPageModal';
-
-import AppContainer from '~/client/services/AppContainer';
-import SocketIoContainer from '~/client/services/SocketIoContainer';
-import { DescendantsPageListModal } from '~/components/DescendantsPageListModal';
+import PageRenameModal from '../components/PageRenameModal';
 
 const logger = loggerFactory('growi:cli:app');
 
@@ -48,6 +48,7 @@ const componentMappings = {
 
   'page-create-modal': <PageCreateModal />,
   'page-delete-modal': <PageDeleteModal />,
+  'empty-trash-modal': <EmptyTrashModal />,
   'page-duplicate-modal': <PageDuplicateModal />,
   'page-rename-modal': <PageRenameModal />,
   'page-presentation-modal': <PagePresentationModal />,

+ 1 - 1
packages/app/src/components/Admin/Users/UserInviteModal.jsx

@@ -171,7 +171,7 @@ class UserInviteModal extends React.Component {
     return (
       <ul>
         {userList.map((user) => {
-          const copyText = `Email:${user.email} Password:${user.password} `;
+          const copyText = `Email:${user.email} Password:${user.password}`;
           return (
             <div className="my-1" key={user.email}>
               <CopyToClipboard text={copyText} onCopy={this.showToaster}>

+ 5 - 2
packages/app/src/components/CustomNavigation/CustomNav.jsx

@@ -1,6 +1,7 @@
 import React, {
   useEffect, useState, useRef, useMemo, useCallback,
 } from 'react';
+
 import PropTypes from 'prop-types';
 import {
   Nav, NavItem, NavLink,
@@ -87,7 +88,7 @@ export const CustomNavTab = (props) => {
   const [sliderMarginLeft, setSliderMarginLeft] = useState(0);
 
   const {
-    activeTab, navTabMapping, onNavSelected, hideBorderBottom, breakpointToHideInactiveTabsDown,
+    activeTab, navTabMapping, onNavSelected, hideBorderBottom, breakpointToHideInactiveTabsDown, navRightElement,
   } = props;
 
   const navTabRefs = useMemo(() => {
@@ -149,7 +150,7 @@ export const CustomNavTab = (props) => {
 
   return (
     <div className="grw-custom-nav-tab">
-      <div ref={navContainer}>
+      <div ref={navContainer} className="d-flex justify-content-between">
         <Nav className="nav-title">
           {Object.entries(navTabMapping).map(([key, value]) => {
 
@@ -169,6 +170,7 @@ export const CustomNavTab = (props) => {
             );
           })}
         </Nav>
+        {navRightElement}
       </div>
       <hr className="my-0 grw-nav-slide-hr border-none" style={{ width: `${sliderWidth}%`, marginLeft: `${sliderMarginLeft}%` }} />
       { !hideBorderBottom && <hr className="my-0 border-top-0 border-bottom" /> }
@@ -183,6 +185,7 @@ CustomNavTab.propTypes = {
   onNavSelected: PropTypes.func,
   hideBorderBottom: PropTypes.bool,
   breakpointToHideInactiveTabsDown: PropTypes.oneOf(['xs', 'sm', 'md', 'lg', 'xl']),
+  navRightElement: PropTypes.node,
 };
 
 CustomNavTab.defaultProps = {

+ 4 - 1
packages/app/src/components/CustomNavigation/CustomNavAndContents.jsx

@@ -1,4 +1,5 @@
 import React, { useState } from 'react';
+
 import PropTypes from 'prop-types';
 
 import CustomNav, { CustomNavTab, CustomNavDropdown } from './CustomNav';
@@ -7,7 +8,7 @@ import CustomTabContent from './CustomTabContent';
 
 const CustomNavAndContents = (props) => {
   const {
-    navTabMapping, defaultTabIndex, navigationMode, tabContentClasses, breakpointToHideInactiveTabsDown,
+    navTabMapping, defaultTabIndex, navigationMode, tabContentClasses, breakpointToHideInactiveTabsDown, navRightElement,
   } = props;
   const [activeTab, setActiveTab] = useState(Object.keys(props.navTabMapping)[defaultTabIndex || 0]);
 
@@ -31,6 +32,7 @@ const CustomNavAndContents = (props) => {
         navTabMapping={navTabMapping}
         onNavSelected={setActiveTab}
         breakpointToHideInactiveTabsDown={breakpointToHideInactiveTabsDown}
+        navRightElement={navRightElement}
       />
       <CustomTabContent activeTab={activeTab} navTabMapping={navTabMapping} additionalClassNames={tabContentClasses} />
     </>
@@ -43,6 +45,7 @@ CustomNavAndContents.propTypes = {
   navigationMode: PropTypes.oneOf(['both', 'tab', 'dropdown']),
   tabContentClasses: PropTypes.arrayOf(PropTypes.string),
   breakpointToHideInactiveTabsDown: PropTypes.oneOf(['xs', 'sm', 'md', 'lg', 'xl']),
+  navRightElement: PropTypes.node,
 };
 CustomNavAndContents.defaultProps = {
   navigationMode: 'tab',

+ 61 - 0
packages/app/src/components/EmptyTrashButton.tsx

@@ -0,0 +1,61 @@
+import React, { useCallback } from 'react';
+
+import { useTranslation } from 'react-i18next';
+
+import { toastSuccess } from '~/client/util/apiNotification';
+import {
+  IDataWithMeta,
+  IPageHasId,
+  IPageInfo,
+} from '~/interfaces/page';
+import { useEmptyTrashModal } from '~/stores/modal';
+import { useSWRxDescendantsPageListForCurrrentPath, useSWRxPageInfoForList } from '~/stores/page';
+
+
+const EmptyTrashButton = () => {
+  const { t } = useTranslation();
+  const { open: openEmptyTrashModal } = useEmptyTrashModal();
+  const { data: pagingResult, mutate } = useSWRxDescendantsPageListForCurrrentPath();
+
+  const pageIds = pagingResult?.items?.map(page => page._id);
+  const { injectTo } = useSWRxPageInfoForList(pageIds, true, true);
+
+  let pageWithMetas: IDataWithMeta<IPageHasId, IPageInfo>[] = [];
+
+  const convertToIDataWithMeta = (page) => {
+    return { data: page };
+  };
+
+  if (pagingResult != null) {
+    const dataWithMetas = pagingResult.items.map(page => convertToIDataWithMeta(page));
+    pageWithMetas = injectTo(dataWithMetas);
+  }
+
+  const deletablePages = pageWithMetas.filter(page => page.meta?.isAbleToDeleteCompletely);
+
+  const onEmptiedTrashHandler = useCallback(() => {
+    toastSuccess(t('empty_trash'));
+
+    mutate();
+  }, [t, mutate]);
+
+  const emptyTrashClickHandler = () => {
+    if (deletablePages.length === 0) { return }
+    openEmptyTrashModal(deletablePages, { onEmptiedTrash: onEmptiedTrashHandler, canDelepeAllPages: pagingResult?.totalCount === deletablePages.length });
+  };
+
+  return (
+    <div className="d-flex align-items-center">
+      <button
+        type="button"
+        className="btn btn-outline-secondary rounded-pill text-danger d-flex align-items-center"
+        onClick={() => emptyTrashClickHandler()}
+      >
+        <i className="icon-fw icon-trash"></i>
+        <div>{t('modal_empty.empty_the_trash')}</div>
+      </button>
+    </div>
+  );
+};
+
+export default EmptyTrashButton;

+ 0 - 71
packages/app/src/components/EmptyTrashModal.jsx

@@ -1,71 +0,0 @@
-import React, { useState } from 'react';
-import PropTypes from 'prop-types';
-
-import {
-  Modal, ModalHeader, ModalBody, ModalFooter,
-} from 'reactstrap';
-
-import { withTranslation } from 'react-i18next';
-import { withUnstatedContainers } from './UnstatedUtils';
-
-import SocketIoContainer from '~/client/services/SocketIoContainer';
-import AppContainer from '~/client/services/AppContainer';
-import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
-
-const EmptyTrashModal = (props) => {
-  const {
-    t, isOpen, onClose, appContainer, socketIoContainer,
-  } = props;
-
-  const [errs, setErrs] = useState(null);
-
-  async function emptyTrash() {
-    setErrs(null);
-
-    try {
-      await appContainer.apiv3Delete('/pages/empty-trash');
-      window.location.reload();
-    }
-    catch (err) {
-      setErrs(err);
-    }
-  }
-
-  function emptyButtonHandler() {
-    emptyTrash();
-  }
-
-  return (
-    <Modal isOpen={isOpen} toggle={onClose} className="grw-create-page">
-      <ModalHeader tag="h4" toggle={onClose} className="bg-danger text-light">
-        { t('modal_empty.empty_the_trash')}
-      </ModalHeader>
-      <ModalBody>
-        { t('modal_empty.notice')}
-      </ModalBody>
-      <ModalFooter>
-        <ApiErrorMessageList errs={errs} />
-        <button type="button" className="btn btn-danger" onClick={emptyButtonHandler}>
-          <i className="icon-trash mr-2" aria-hidden="true"></i> Empty
-        </button>
-      </ModalFooter>
-    </Modal>
-  );
-};
-
-/**
- * Wrapper component for using unstated
- */
-const EmptyTrashModalWrapper = withUnstatedContainers(EmptyTrashModal, [AppContainer, SocketIoContainer]);
-
-
-EmptyTrashModal.propTypes = {
-  t: PropTypes.func.isRequired, //  i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  socketIoContainer: PropTypes.instanceOf(SocketIoContainer),
-
-  isOpen: PropTypes.bool.isRequired,
-  onClose: PropTypes.func.isRequired,
-};
-
-export default withTranslation()(EmptyTrashModalWrapper);

+ 92 - 0
packages/app/src/components/EmptyTrashModal.tsx

@@ -0,0 +1,92 @@
+import React, {
+  useState, FC,
+} from 'react';
+
+import { useTranslation } from 'react-i18next';
+import {
+  Modal, ModalHeader, ModalBody, ModalFooter,
+} from 'reactstrap';
+
+import { apiv3Delete } from '~/client/util/apiv3-client';
+import { useEmptyTrashModal } from '~/stores/modal';
+
+import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
+
+const EmptyTrashModal: FC = () => {
+  const { t } = useTranslation();
+
+  const { data: emptyTrashModalData, close: closeEmptyTrashModal } = useEmptyTrashModal();
+
+  const isOpened = emptyTrashModalData?.isOpened ?? false;
+
+  const canDeleteAllpages = emptyTrashModalData?.opts?.canDelepeAllPages ?? false;
+
+  const [errs, setErrs] = useState<Error[] | null>(null);
+
+  async function emptyTrash() {
+    if (emptyTrashModalData == null || emptyTrashModalData.pages == null) {
+      return;
+    }
+
+    try {
+      await apiv3Delete('/pages/empty-trash');
+      const onEmptiedTrash = emptyTrashModalData.opts?.onEmptiedTrash;
+      if (onEmptiedTrash != null) {
+        onEmptiedTrash();
+      }
+      closeEmptyTrashModal();
+    }
+    catch (err) {
+      setErrs([err]);
+    }
+  }
+
+  async function emptyTrashButtonHandler() {
+    await emptyTrash();
+  }
+
+  const renderPagePaths = () => {
+    const pages = emptyTrashModalData?.pages;
+
+    if (pages != null) {
+      return pages.map(page => (
+        <p key={page.data._id} className="mb-1">
+          <code>{ page.data.path }</code>
+        </p>
+      ));
+    }
+    return <></>;
+  };
+
+  return (
+    <Modal size="lg" isOpen={isOpened} toggle={closeEmptyTrashModal} data-testid="page-delete-modal" className="grw-create-page">
+      <ModalHeader tag="h4" toggle={closeEmptyTrashModal} className="bg-danger text-light">
+        <i className="icon-fw icon-fire"></i>
+        {t('modal_empty.empty_the_trash')}
+      </ModalHeader>
+      <ModalBody>
+        <div className="form-group grw-scrollable-modal-body pb-1">
+          <label>{ t('modal_delete.deleting_page') }:</label><br />
+          {/* Todo: change the way to show path on modal when too many pages are selected */}
+          {renderPagePaths()}
+        </div>
+        {!canDeleteAllpages && t('modal_empty.not_deletable_notice')}<br />
+        {t('modal_empty.notice')}
+      </ModalBody>
+      <ModalFooter>
+        <ApiErrorMessageList errs={errs} />
+        <button
+          type="button"
+          className="btn btn-danger"
+          onClick={emptyTrashButtonHandler}
+        >
+          <i className="mr-1 icon-fire" aria-hidden="true"></i>
+          {t('modal_empty.empty_the_trash_button')}
+        </button>
+      </ModalFooter>
+    </Modal>
+
+  );
+};
+
+export default EmptyTrashModal;

+ 7 - 4
packages/app/src/components/MaintenanceModeContent.tsx

@@ -25,10 +25,13 @@ const MaintenanceModeContent = () => {
 
   return (
     <div className="text-left">
-      <p>
-        <i className="icon-arrow-right"></i>
-        <a className="btn btn-link" href="/admin">{ t('maintenance_mode.admin_page') }</a>
-      </p>
+      {currentUser?.admin
+      && (
+        <p>
+          <i className="icon-arrow-right"></i>
+          <a className="btn btn-link" href="/admin">{ t('maintenance_mode.admin_page') }</a>
+        </p>
+      )}
       {currentUser != null
         ? (
           <p>

+ 2 - 2
packages/app/src/components/Page/TagsInput.tsx

@@ -5,7 +5,7 @@ import { AsyncTypeahead } from 'react-bootstrap-typeahead';
 
 import { apiGet } from '~/client/util/apiv1-client';
 import { toastError } from '~/client/util/apiNotification';
-import { ITagsSearchApiv1Result } from '~/interfaces/tag';
+import { IResTagsSearchApiv1 } from '~/interfaces/tag';
 
 type TypeaheadInstance = {
   _handleMenuItemSelect: (activeItem: string, event: React.KeyboardEvent) => void,
@@ -36,7 +36,7 @@ const TagsInput: FC<Props> = (props: Props) => {
     setLoading(true);
     try {
       // TODO: 91698 SWRize
-      const res = await apiGet('/tags.search', { q: query }) as ITagsSearchApiv1Result;
+      const res = await apiGet('/tags.search', { q: query }) as IResTagsSearchApiv1;
       res.tags.unshift(query);
       setResultTags(Array.from(new Set(res.tags)));
     }

+ 8 - 22
packages/app/src/components/PageCreateModal.jsx

@@ -1,24 +1,22 @@
 import React, {
   useEffect, useState, useMemo, useCallback,
 } from 'react';
-import PropTypes from 'prop-types';
 
+import { pagePathUtils, pathUtils } from '@growi/core';
+import { format } from 'date-fns';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
 import { Modal, ModalHeader, ModalBody } from 'reactstrap';
 import { debounce } from 'throttle-debounce';
 
-import { withTranslation } from 'react-i18next';
-import { format } from 'date-fns';
-
-import { pagePathUtils, pathUtils } from '@growi/core';
-
 
 import AppContainer from '~/client/services/AppContainer';
-import { withUnstatedContainers } from './UnstatedUtils';
 import { toastError } from '~/client/util/apiNotification';
-
 import { usePageCreateModal } from '~/stores/modal';
 
 import PagePathAutoComplete from './PagePathAutoComplete';
+import { withUnstatedContainers } from './UnstatedUtils';
+
 
 const {
   userPageRoot, isCreatablePage, generateEditorPath, isUsersHomePage,
@@ -83,14 +81,6 @@ const PageCreateModal = (props) => {
     setTodayInput2(value);
   }
 
-  /**
-   * change pageNameInput
-   * @param {string} value
-   */
-  function onChangePageNameInputHandler(value) {
-    setPageNameInput(value);
-  }
-
   /**
    * change template
    * @param {string} value
@@ -131,10 +121,6 @@ const PageCreateModal = (props) => {
     redirectToEditor(pageNameInput);
   }
 
-  function ppacInputChangeHandler(value) {
-    setPageNameInput(value);
-  }
-
   function ppacSubmitHandler(input) {
     redirectToEditor(input);
   }
@@ -212,7 +198,7 @@ const PageCreateModal = (props) => {
                     initializedPath={pageNameInput}
                     addTrailingSlash
                     onSubmit={ppacSubmitHandler}
-                    onInputChange={ppacInputChangeHandler}
+                    onInputChange={value => setPageNameInput(value)}
                     autoFocus
                   />
                 )
@@ -223,7 +209,7 @@ const PageCreateModal = (props) => {
                       value={pageNameInput}
                       className="form-control flex-fill"
                       placeholder={t('Input page name')}
-                      onChange={e => onChangePageNameInputHandler(e.target.value)}
+                      onChange={e => setPageNameInput(e.target.value)}
                       required
                     />
                   </form>

+ 12 - 10
packages/app/src/components/PageDeleteModal.tsx

@@ -1,22 +1,26 @@
-import React, { useState, FC, useMemo } from 'react';
+import React, {
+  useState, FC, useMemo,
+} from 'react';
+
+import { useTranslation } from 'react-i18next';
 import {
   Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
-import { useTranslation } from 'react-i18next';
 
 import { apiPost } from '~/client/util/apiv1-client';
 import { apiv3Post } from '~/client/util/apiv3-client';
-import { usePageDeleteModal } from '~/stores/modal';
-import loggerFactory from '~/utils/logger';
-
+import { HasObjectId } from '~/interfaces/has-object-id';
 import {
   IDeleteSinglePageApiv1Result, IDeleteManyPageApiv3Result, IPageToDeleteWithMeta, IDataWithMeta, isIPageInfoForEntity, IPageInfoForEntity,
 } from '~/interfaces/page';
-import { HasObjectId } from '~/interfaces/has-object-id';
+import { usePageDeleteModal } from '~/stores/modal';
+import { useSWRxPageInfoForList } from '~/stores/page';
+import loggerFactory from '~/utils/logger';
+
 
 import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
+
 import { isTrashPage } from '^/../core/src/utils/page-path-utils';
-import { useSWRxPageInfoForList } from '~/stores/page';
 
 
 const logger = loggerFactory('growi:cli:PageDeleteModal');
@@ -121,7 +125,6 @@ const PageDeleteModal: FC = () => {
         if (onDeleted != null) {
           onDeleted(data.paths, data.isRecursively, data.isCompletely);
         }
-
         closeDeleteModal();
       }
       catch (err) {
@@ -231,7 +234,6 @@ const PageDeleteModal: FC = () => {
         <div className="form-group grw-scrollable-modal-body pb-1">
           <label>{ t('modal_delete.deleting_page') }:</label><br />
           {/* Todo: change the way to show path on modal when too many pages are selected */}
-          {/* https://redmine.weseek.co.jp/issues/82787 */}
           {renderPagePathsToDelete()}
         </div>
         { isDeletable && renderDeleteRecursivelyForm()}
@@ -245,7 +247,7 @@ const PageDeleteModal: FC = () => {
           disabled={!isDeletable}
           onClick={deleteButtonHandler}
         >
-          <i className={`icon-${deleteIconAndKey[deleteMode].icon}`} aria-hidden="true"></i>
+          <i className={`mr-1 icon-${deleteIconAndKey[deleteMode].icon}`} aria-hidden="true"></i>
           { t(`modal_delete.delete_${deleteIconAndKey[deleteMode].translationKey}`) }
         </button>
       </ModalFooter>

+ 5 - 7
packages/app/src/components/PageDuplicateModal.tsx

@@ -2,22 +2,20 @@ import React, {
   useState, useEffect, useCallback, useMemo,
 } from 'react';
 
+import { useTranslation } from 'react-i18next';
 import {
   Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
-
-import { useTranslation } from 'react-i18next';
 import { debounce } from 'throttle-debounce';
 
-import { apiv3Get, apiv3Post } from '~/client/util/apiv3-client';
 import { toastError } from '~/client/util/apiNotification';
-
-import { usePageDuplicateModal } from '~/stores/modal';
+import { apiv3Get, apiv3Post } from '~/client/util/apiv3-client';
 import { useIsSearchServiceReachable, useSiteUrl } from '~/stores/context';
+import { usePageDuplicateModal } from '~/stores/modal';
 
-import PagePathAutoComplete from './PagePathAutoComplete';
-import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
 import DuplicatePathsTable from './DuplicatedPathsTable';
+import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
+import PagePathAutoComplete from './PagePathAutoComplete';
 
 
 const PageDuplicateModal = (): JSX.Element => {

+ 48 - 21
packages/app/src/components/PageRenameModal.tsx

@@ -2,25 +2,24 @@ import React, {
   useState, useEffect, useCallback, useMemo,
 } from 'react';
 
+import { pagePathUtils } from '@growi/core';
+import { useTranslation } from 'react-i18next';
 import {
   Collapse, Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
-
-import { useTranslation } from 'react-i18next';
-
 import { debounce } from 'throttle-debounce';
-import { pagePathUtils } from '@growi/core';
-import { usePageRenameModal } from '~/stores/modal';
-import { toastError } from '~/client/util/apiNotification';
 
+import { toastError } from '~/client/util/apiNotification';
 import { apiv3Get, apiv3Put } from '~/client/util/apiv3-client';
-
-import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
-import DuplicatedPathsTable from './DuplicatedPathsTable';
-import { useSiteUrl } from '~/stores/context';
 import { isIPageInfoForEntity } from '~/interfaces/page';
+import { useSiteUrl, useIsSearchServiceReachable } from '~/stores/context';
+import { usePageRenameModal } from '~/stores/modal';
 import { useSWRxPageInfo } from '~/stores/page';
 
+import DuplicatedPathsTable from './DuplicatedPathsTable';
+import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
+import PagePathAutoComplete from './PagePathAutoComplete';
+
 
 const isV5Compatible = (meta: unknown): boolean => {
   return isIPageInfoForEntity(meta) ? meta.isV5Compatible : true;
@@ -33,6 +32,7 @@ const PageRenameModal = (): JSX.Element => {
   const { isUsersHomePage } = pagePathUtils;
   const { data: siteUrl } = useSiteUrl();
   const { data: renameModalData, close: closeRenameModal } = usePageRenameModal();
+  const { data: isReachable } = useIsSearchServiceReachable();
 
   const isOpened = renameModalData?.isOpened ?? false;
   const page = renameModalData?.page;
@@ -50,6 +50,7 @@ const PageRenameModal = (): JSX.Element => {
 
   const [subordinatedPages, setSubordinatedPages] = useState([]);
   const [existingPaths, setExistingPaths] = useState<string[]>([]);
+  const [canRename, setCanRename] = useState(false);
   const [isRenameRecursively, setIsRenameRecursively] = useState(true);
   const [isRenameRedirect, setIsRenameRedirect] = useState(false);
   const [isRemainMetadata, setIsRemainMetadata] = useState(false);
@@ -81,7 +82,7 @@ const PageRenameModal = (): JSX.Element => {
   }, [isOpened, page, updateSubordinatedList]);
 
   const rename = useCallback(async() => {
-    if (page == null) {
+    if (page == null || !canRename) {
       return;
     }
 
@@ -116,7 +117,7 @@ const PageRenameModal = (): JSX.Element => {
     catch (err) {
       setErrs(err);
     }
-  }, [closeRenameModal, isRemainMetadata, isRenameRecursively, isRenameRedirect, page, pageNameInput, renameModalData?.opts?.onRenamed]);
+  }, [closeRenameModal, canRename, isRemainMetadata, isRenameRecursively, isRenameRedirect, page, pageNameInput, renameModalData?.opts?.onRenamed]);
 
   const checkExistPaths = useCallback(async(fromPath, toPath) => {
     if (page == null) {
@@ -124,8 +125,11 @@ const PageRenameModal = (): JSX.Element => {
     }
 
     try {
-      const res = await apiv3Get<{ existPaths: string[] }>('/page/exist-paths', { fromPath, toPath });
+      const res = await apiv3Get<{ existPaths: string[]}>('/page/exist-paths', { fromPath, toPath });
       const { existPaths } = res.data;
+      if (existPaths.length === 0) {
+        setCanRename(true);
+      }
       setExistingPaths(existPaths);
     }
     catch (err) {
@@ -153,6 +157,15 @@ const PageRenameModal = (): JSX.Element => {
     }
   }, [pageNameInput, subordinatedPages, checkExistPathsDebounce, page, checkIsUsersHomePageDebounce]);
 
+  useEffect(() => {
+    setCanRename(false);
+  }, [pageNameInput]);
+
+
+  function ppacInputChangeHandler(value) {
+    setErrs(null);
+    setPageNameInput(value);
+  }
 
   /**
    * change pageNameInput
@@ -194,6 +207,9 @@ const PageRenameModal = (): JSX.Element => {
   if (isMatchedWithUserHomePagePath) {
     submitButtonDisabled = true;
   }
+  else if (!canRename) {
+    submitButtonDisabled = true;
+  }
   else if (isV5Compatible(page.meta)) {
     submitButtonDisabled = existingPaths.length !== 0; // v5 data
   }
@@ -219,14 +235,25 @@ const PageRenameModal = (): JSX.Element => {
               <span className="input-group-text">{siteUrl}</span>
             </div>
             <form className="flex-fill" onSubmit={(e) => { e.preventDefault(); rename() }}>
-              <input
-                type="text"
-                value={pageNameInput}
-                className="form-control"
-                onChange={e => inputChangeHandler(e.target.value)}
-                required
-                autoFocus
-              />
+              {isReachable
+                ? (
+                  <PagePathAutoComplete
+                    initializedPath={path}
+                    onSubmit={rename}
+                    onInputChange={ppacInputChangeHandler}
+                    autoFocus
+                  />
+                )
+                : (
+                  <input
+                    type="text"
+                    value={pageNameInput}
+                    className="form-control"
+                    onChange={e => inputChangeHandler(e.target.value)}
+                    required
+                    autoFocus
+                  />
+                )}
             </form>
           </div>
         </div>

+ 40 - 15
packages/app/src/components/PrivateLegacyPages.tsx

@@ -1,34 +1,35 @@
 import React, {
-  useCallback, useMemo, useRef, useState,
+  useCallback, useMemo, useRef, useState, useEffect,
 } from 'react';
-import { useTranslation } from 'react-i18next';
 
+import { useTranslation } from 'react-i18next';
 import {
   UncontrolledButtonDropdown, DropdownToggle, DropdownMenu, DropdownItem, Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
 
-import { IFormattedSearchResult } from '~/interfaces/search';
-import AppContainer from '~/client/services/AppContainer';
 import { ISelectableAll, ISelectableAndIndeterminatable } from '~/client/interfaces/selectable-all';
+import AppContainer from '~/client/services/AppContainer';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
-import {
-  useSWRxSearch,
-} from '~/stores/search';
+import { apiv3Post } from '~/client/util/apiv3-client';
+import { V5ConversionErrCode } from '~/interfaces/errors/v5-conversion-error';
+import { V5MigrationStatus } from '~/interfaces/page-listing-results';
+import { IFormattedSearchResult } from '~/interfaces/search';
+import { PageMigrationErrorData, SocketEventName } from '~/interfaces/websocket';
 import {
   ILegacyPrivatePage, usePrivateLegacyPagesMigrationModal,
 } from '~/stores/modal';
+import { useSWRxV5MigrationStatus } from '~/stores/page-listing';
+import {
+  useSWRxSearch,
+} from '~/stores/search';
+import { useGlobalSocket } from '~/stores/websocket';
 
-import PaginationWrapper from './PaginationWrapper';
-import { OperateAllControl } from './SearchPage/OperateAllControl';
-
-import { IReturnSelectedPageIds, SearchPageBase, usePageDeleteModalForBulkDeletion } from './SearchPage2/SearchPageBase';
 import { MenuItemType } from './Common/Dropdown/PageItemControl';
+import PaginationWrapper from './PaginationWrapper';
 import { PrivateLegacyPagesMigrationModal } from './PrivateLegacyPagesMigrationModal';
+import { OperateAllControl } from './SearchPage/OperateAllControl';
 import SearchControl from './SearchPage/SearchControl';
-import { useSWRxV5MigrationStatus } from '~/stores/page-listing';
-import { V5MigrationStatus } from '~/interfaces/page-listing-results';
-import { apiv3Post } from '~/client/util/apiv3-client';
-import { V5ConversionErrCode } from '~/interfaces/errors/v5-conversion-error';
+import { IReturnSelectedPageIds, SearchPageBase, usePageDeleteModalForBulkDeletion } from './SearchPage2/SearchPageBase';
 
 
 // TODO: replace with "customize:showPageLimitationS"
@@ -201,6 +202,30 @@ const PrivateLegacyPages = (props: Props): JSX.Element => {
   }, []);
 
   const { open: openModal, close: closeModal } = usePrivateLegacyPagesMigrationModal();
+  const { data: socket } = useGlobalSocket();
+
+  useEffect(() => {
+    socket?.on(SocketEventName.PageMigrationSuccess, () => {
+      toastSuccess(t('private_legacy_pages.toaster.page_migration_succeeded'));
+    });
+
+    socket?.on(SocketEventName.PageMigrationError, (data?: PageMigrationErrorData) => {
+      if (data == null || data.paths.length === 0) {
+        toastError(t('private_legacy_pages.toaster.page_migration_failed'));
+      }
+      else {
+        const errorPaths = data.paths.length > 3
+          ? `${data.paths.slice(0, 3).join(', ')}...`
+          : data.paths.join(', ');
+        toastError(t('private_legacy_pages.toaster.page_migration_failed_with_paths', { paths: errorPaths }));
+      }
+    });
+
+    return () => {
+      socket?.off(SocketEventName.PageMigrationSuccess);
+      socket?.off(SocketEventName.PageMigrationError);
+    };
+  }, [socket]);
 
   const selectAllCheckboxChangedHandler = useCallback((isChecked: boolean) => {
     const instance = searchPageBaseRef.current;

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

@@ -368,13 +368,6 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
       };
     }
 
-    if (title.includes('/')) {
-      return {
-        type: AlertType.WARNING,
-        message: t('form_validation.slashed_are_not_yet_supported'),
-      };
-    }
-
     return null;
   };
 
@@ -446,7 +439,7 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
                 <i className="fa fa-spinner fa-pulse mr-2 text-muted"></i>
               )}
               <a href={`/${page._id}`} className="grw-pagetree-title-anchor flex-grow-1">
-                <p className={`text-truncate m-auto ${page.isEmpty && 'text-muted'}`}>{nodePath.basename(page.path ?? '') || '/'}</p>
+                <p className={`text-truncate m-auto ${page.isEmpty && 'grw-sidebar-text-muted'}`}>{nodePath.basename(page.path ?? '') || '/'}</p>
               </a>
             </>
           )}

+ 2 - 2
packages/app/src/components/Sidebar/Tag.tsx

@@ -2,7 +2,7 @@ import React, { FC, useState, useCallback } from 'react';
 
 import { useTranslation } from 'react-i18next';
 
-import { ITagCountHasId } from '~/interfaces/tag';
+import { IDataTagCount } from '~/interfaces/tag';
 import { useSWRxTagsList } from '~/stores/tag';
 
 import TagCloudBox from '../TagCloudBox';
@@ -16,7 +16,7 @@ const Tag: FC = () => {
   const [offset, setOffset] = useState<number>(0);
 
   const { data: tagDataList, mutate: mutateTagDataList, error } = useSWRxTagsList(PAGING_LIMIT, offset);
-  const tagData: ITagCountHasId[] = tagDataList?.data || [];
+  const tagData: IDataTagCount[] = tagDataList?.data || [];
   const totalCount: number = tagDataList?.totalCount || 0;
   const isLoading = tagDataList === undefined && error == null;
 

+ 5 - 3
packages/app/src/components/TagCloudBox.tsx

@@ -1,9 +1,11 @@
 import React, { FC, memo } from 'react';
+
 import { TagCloud } from 'react-tagcloud';
-import { ITagCountHasId } from '~/interfaces/tag';
+
+import { IDataTagCount } from '~/interfaces/tag';
 
 type Props = {
-  tags:ITagCountHasId[],
+  tags:IDataTagCount[],
   minSize?: number,
   maxSize?: number,
   maxTagTextLength?: number,
@@ -29,7 +31,7 @@ const TagCloudBox: FC<Props> = memo((props:(Props & typeof defaultProps)) => {
       <TagCloud
         minSize={minSize ?? MIN_FONT_SIZE}
         maxSize={maxSize ?? MAX_FONT_SIZE}
-        tags={tags.map((tag:ITagCountHasId) => {
+        tags={tags.map((tag:IDataTagCount) => {
           return {
             // text truncation
             value: (tag.name).length > maxTagTextLength ? `${(tag.name).slice(0, maxTagTextLength)}...` : tag.name,

+ 3 - 3
packages/app/src/components/TagList.tsx

@@ -4,12 +4,12 @@ import React, {
 
 import { useTranslation } from 'react-i18next';
 
-import { ITagCountHasId } from '~/interfaces/tag';
+import { IDataTagCount } from '~/interfaces/tag';
 
 import PaginationWrapper from './PaginationWrapper';
 
 type TagListProps = {
-  tagData: ITagCountHasId[],
+  tagData: IDataTagCount[],
   totalTags: number,
   activePage: number,
   onChangePage?: (selectedPageNumber: number) => void,
@@ -29,7 +29,7 @@ const TagList: FC<TagListProps> = (props:(TagListProps & typeof defaultProps)) =
   const { t } = useTranslation('');
 
   const generateTagList = useCallback((tagData) => {
-    return tagData.map((tag:ITagCountHasId, index:number) => {
+    return tagData.map((tag:IDataTagCount, index:number) => {
       const tagListClasses: string = index === 0 ? 'list-group-item d-flex' : 'list-group-item d-flex border-top-0';
 
       return (

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

@@ -2,7 +2,7 @@ import React, { FC, useState, useCallback } from 'react';
 
 import { useTranslation } from 'react-i18next';
 
-import { ITagCountHasId } from '~/interfaces/tag';
+import { IDataTagCount } from '~/interfaces/tag';
 import { useSWRxTagsList } from '~/stores/tag';
 
 import TagCloudBox from './TagCloudBox';
@@ -15,7 +15,7 @@ const TagPage: FC = () => {
   const [offset, setOffset] = useState<number>(0);
 
   const { data: tagDataList, error } = useSWRxTagsList(PAGING_LIMIT, offset);
-  const tagData: ITagCountHasId[] = tagDataList?.data || [];
+  const tagData: IDataTagCount[] = tagDataList?.data || [];
   const totalCount: number = tagDataList?.totalCount || 0;
   const isLoading = tagDataList === undefined && error == null;
 

+ 9 - 2
packages/app/src/components/TrashPageList.jsx

@@ -1,9 +1,12 @@
 import React, { useMemo } from 'react';
+
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
-import PageListIcon from './Icons/PageListIcon';
+
 import CustomNavAndContents from './CustomNavigation/CustomNavAndContents';
 import { DescendantsPageListForCurrentPath } from './DescendantsPageList';
+import EmptyTrashButton from './EmptyTrashButton';
+import PageListIcon from './Icons/PageListIcon';
 
 
 const TrashPageList = (props) => {
@@ -20,9 +23,13 @@ const TrashPageList = (props) => {
     };
   }, [t]);
 
+  const emptyTrashButton = useMemo(() => {
+    return <EmptyTrashButton />;
+  }, [t]);
+
   return (
     <div data-testid="trash-page-list" className="mt-5 d-edit-none">
-      <CustomNavAndContents navTabMapping={navTabMapping} />
+      <CustomNavAndContents navTabMapping={navTabMapping} navRightElement={emptyTrashButton} />
     </div>
   );
 };

+ 3 - 3
packages/app/src/interfaces/page.ts

@@ -1,9 +1,9 @@
 import { Ref, Nullable } from './common';
-import { IUser } from './user';
-import { IRevision, HasRevisionShortbody } from './revision';
-import { ITag } from './tag';
 import { HasObjectId } from './has-object-id';
+import { IRevision, HasRevisionShortbody } from './revision';
 import { SubscriptionStatusType } from './subscription';
+import { ITag } from './tag';
+import { IUser } from './user';
 
 
 export interface IPage {

+ 6 - 9
packages/app/src/interfaces/tag.ts

@@ -1,21 +1,18 @@
-import { HasObjectId } from './has-object-id';
-
-export type ITag = {
+export type ITag<ID = string> = {
+  _id: ID
   name: string,
-  createdAt: Date;
 }
 
-export type ITagCount = Omit<ITag, 'createdAt'> & {count: number}
+export type IDataTagCount = ITag & {count: number}
 
-export type ITagCountHasId = ITagCount & HasObjectId
 
-export type ITagsSearchApiv1Result = {
+export type IResTagsSearchApiv1 = {
   ok: boolean,
   tags: string[]
 }
 
-export type ITagsListApiv1Result = {
+export type IResTagsListApiv1 = {
   ok: boolean,
-  data: ITagCountHasId[],
+  data: IDataTagCount[],
   totalCount: number,
 }

+ 6 - 0
packages/app/src/interfaces/websocket.ts

@@ -7,6 +7,10 @@ export const SocketEventName = {
   PMMigrating: 'PublicMigrationMigrating',
   PMErrorCount: 'PublicMigrationErrorCount',
   PMEnded: 'PublicMigrationEnded',
+
+  // Page migration
+  PageMigrationSuccess: 'PageMigrationSuccess',
+  PageMigrationError: 'PageMigrationError',
 } as const;
 export type SocketEventName = typeof SocketEventName[keyof typeof SocketEventName];
 
@@ -22,3 +26,5 @@ export type PMStartedData = { total: number };
 export type PMMigratingData = { count: number };
 export type PMErrorCountData = { skip: number };
 export type PMEndedData = { isSucceeded: boolean };
+
+export type PageMigrationErrorData = { paths: string[] }

+ 13 - 12
packages/app/src/server/crowi/index.js

@@ -1,12 +1,13 @@
 /* eslint-disable @typescript-eslint/no-this-alias */
 
-import path from 'path';
 import http from 'http';
-import mongoose from 'mongoose';
+import path from 'path';
 
 import { createTerminus } from '@godaddy/terminus';
-
 import { initMongooseGlobalSettings, getMongoUri, mongoOptions } from '@growi/core';
+import mongoose from 'mongoose';
+
+
 import pkg from '^/package.json';
 
 import CdnResourcesService from '~/services/cdn-resources-service';
@@ -15,26 +16,25 @@ import Xss from '~/services/xss';
 import loggerFactory from '~/utils/logger';
 import { projectRoot } from '~/utils/project-dir-utils';
 
-import ConfigManager from '../service/config-manager';
-import AppService from '../service/app';
+import Activity from '../models/activity';
+import PageRedirect from '../models/page-redirect';
+import Tag from '../models/tag';
+import UserGroup from '../models/user-group';
 import AclService from '../service/acl';
-import SearchService from '../service/search';
+import AppService from '../service/app';
 import AttachmentService from '../service/attachment';
+import ConfigManager from '../service/config-manager';
+import { InstallerService } from '../service/installer';
 import PageService from '../service/page';
 import PageGrantService from '../service/page-grant';
 import PageOperationService from '../service/page-operation';
+import SearchService from '../service/search';
 import { SlackIntegrationService } from '../service/slack-integration';
 import { UserNotificationService } from '../service/user-notification';
-import { InstallerService } from '../service/installer';
-import Activity from '../models/activity';
-import UserGroup from '../models/user-group';
-import PageRedirect from '../models/page-redirect';
 
 const logger = loggerFactory('growi:crowi');
 const httpErrorHandler = require('../middlewares/http-error-handler');
-
 const models = require('../models');
-
 const PluginService = require('../plugins/plugin.service');
 
 const sep = path.sep;
@@ -281,6 +281,7 @@ Crowi.prototype.setupModels = async function() {
 
   // include models that independent from crowi
   allModels.Activity = Activity;
+  allModels.Tag = Tag;
   allModels.UserGroup = UserGroup;
   allModels.PageRedirect = PageRedirect;
 

+ 3 - 5
packages/app/src/server/models/page-tag-relation.js

@@ -1,8 +1,9 @@
+import Tag from './tag';
+
 // disable no-return-await for model functions
 /* eslint-disable no-return-await */
 
 const flatMap = require('array.prototype.flatmap');
-
 const mongoose = require('mongoose');
 const mongoosePaginate = require('mongoose-paginate-v2');
 const uniqueValidator = require('mongoose-unique-validator');
@@ -110,8 +111,7 @@ class PageTagRelation {
       .flatMap(result => result.tagIds); // map + flatten
     const distinctTagIds = Array.from(new Set(allTagIds));
 
-    // retrieve tag documents
-    const Tag = mongoose.model('Tag');
+    // TODO: set IdToNameMap type by 93933
     const tagIdToNameMap = await Tag.getIdToNameMap(distinctTagIds);
 
     // convert to map
@@ -136,8 +136,6 @@ class PageTagRelation {
     // eslint-disable-next-line no-param-reassign
     tags = tags.filter((tag) => { return tag !== '' });
 
-    const Tag = mongoose.model('Tag');
-
     // get relations for this page
     const relations = await this.findByPageId(pageId, { nullable: true });
 

+ 0 - 69
packages/app/src/server/models/tag.js

@@ -1,69 +0,0 @@
-// disable no-return-await for model functions
-/* eslint-disable no-return-await */
-
-const mongoose = require('mongoose');
-const mongoosePaginate = require('mongoose-paginate-v2');
-const uniqueValidator = require('mongoose-unique-validator');
-
-/*
- * define schema
- */
-const schema = new mongoose.Schema({
-  name: {
-    type: String,
-    required: true,
-    unique: true,
-  },
-});
-schema.plugin(mongoosePaginate);
-schema.plugin(uniqueValidator);
-
-/**
- * Tag Class
- *
- * @class Tag
- */
-class Tag {
-
-  static async getIdToNameMap(tagIds) {
-    const tags = await this.find({ _id: { $in: tagIds } });
-
-    const idToNameMap = {};
-    tags.forEach((tag) => {
-      idToNameMap[tag._id.toString()] = tag.name;
-    });
-
-    return idToNameMap;
-  }
-
-  static async findOrCreate(tagName) {
-    const tag = await this.findOne({ name: tagName });
-    if (!tag) {
-      return this.create({ name: tagName });
-    }
-    return tag;
-  }
-
-  static async findOrCreateMany(tagNames) {
-    const existTags = await this.find({ name: { $in: tagNames } });
-    const existTagNames = existTags.map((tag) => { return tag.name });
-
-    // bulk insert
-    const tagsToCreate = tagNames.filter((tagName) => { return !existTagNames.includes(tagName) });
-    await this.insertMany(
-      tagsToCreate.map((tag) => {
-        return { name: tag };
-      }),
-    );
-
-    return this.find({ name: { $in: tagNames } });
-  }
-
-}
-
-module.exports = function(crowi) {
-  Tag.crowi = crowi;
-  schema.loadClass(Tag);
-  const model = mongoose.model('Tag', schema);
-  return model;
-};

+ 63 - 0
packages/app/src/server/models/tag.ts

@@ -0,0 +1,63 @@
+import { getOrCreateModel } from '@growi/core';
+import {
+  Types, Model, Schema,
+} from 'mongoose';
+
+import { ObjectIdLike } from '../interfaces/mongoose-utils';
+
+const mongoosePaginate = require('mongoose-paginate-v2');
+const uniqueValidator = require('mongoose-unique-validator');
+
+
+export interface TagDocument {
+  _id: Types.ObjectId;
+  name: string;
+}
+
+export type IdToNameMap = {[key: string] : string }
+
+export interface TagModel extends Model<TagDocument>{
+  getIdToNameMap(tagIds: ObjectIdLike[]): IdToNameMap
+  findOrCreateMany(tagNames: string[]): Promise<TagDocument[]>
+}
+
+
+const tagSchema = new Schema<TagDocument, TagModel>({
+  name: {
+    type: String,
+    require: true,
+    unique: true,
+  },
+});
+tagSchema.plugin(mongoosePaginate);
+tagSchema.plugin(uniqueValidator);
+
+
+tagSchema.statics.getIdToNameMap = async function(tagIds: ObjectIdLike[]): Promise<IdToNameMap> {
+  const tags = await this.find({ _id: { $in: tagIds } });
+
+  const idToNameMap = {};
+  tags.forEach((tag) => {
+    idToNameMap[tag._id.toString()] = tag.name;
+  });
+
+  return idToNameMap;
+};
+
+tagSchema.statics.findOrCreateMany = async function(tagNames: string[]): Promise<TagDocument[]> {
+  const existTags = await this.find({ name: { $in: tagNames } });
+  const existTagNames = existTags.map((tag) => { return tag.name });
+
+  // bulk insert
+  const tagsToCreate = tagNames.filter((tagName) => { return !existTagNames.includes(tagName) });
+  await this.insertMany(
+    tagsToCreate.map((tag) => {
+      return { name: tag };
+    }),
+  );
+
+  return this.find({ name: { $in: tagNames } });
+};
+
+
+export default getOrCreateModel<TagDocument, TagModel>('Tag', tagSchema);

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

@@ -1,8 +1,8 @@
 import { pagePathUtils } from '@growi/core';
-import loggerFactory from '~/utils/logger';
 
 import { AllSubscriptionStatusType } from '~/interfaces/subscription';
 import Subscription from '~/server/models/subscription';
+import loggerFactory from '~/utils/logger';
 
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 
@@ -474,6 +474,10 @@ module.exports = (crowi) => {
 
     try {
       const fromPage = await Page.findByPath(fromPath);
+      if (fromPage == null) {
+        return res.apiv3Err(new ErrorV3('fromPage is Null'), 400);
+      }
+
       const fromPageDescendants = await Page.findManageableListWithDescendants(fromPage, req.user);
 
       const toPathDescendantsArray = fromPageDescendants.map((subordinatedPage) => {

+ 37 - 9
packages/app/src/server/routes/apiv3/pages.js

@@ -350,9 +350,9 @@ module.exports = (crowi) => {
         user: req.user._id,
         targetModel: SUPPORTED_TARGET_MODEL_TYPE.MODEL_PAGE,
         target: createdPage,
-        action: SUPPORTED_ACTION_TYPE.PAGE_CREATE,
+        action: SUPPORTED_ACTION_TYPE.ACTION_PAGE_CREATE,
       };
-      await crowi.activityService.createByParameter(parameters);
+      await crowi.activityService.createByParameters(parameters);
     }
     catch (err) {
       logger.error('Failed to create activity', err);
@@ -562,15 +562,38 @@ module.exports = (crowi) => {
    *          200:
    *            description: Succeeded to remove all trash pages
    */
-  router.delete('/empty-trash', accessTokenParser, loginRequired, adminRequired, csrf, apiV3FormValidator, async(req, res) => {
+  router.delete('/empty-trash', accessTokenParser, loginRequired, csrf, apiV3FormValidator, async(req, res) => {
     const options = {};
 
-    try {
-      const pages = await crowi.pageService.emptyTrashPage(req.user, options);
-      return res.apiv3({ pages });
+    const pagesInTrash = await Page.findChildrenByParentPathOrIdAndViewer('/trash', req.user);
+
+    const deletablePages = crowi.pageService.filterPagesByCanDeleteCompletely(pagesInTrash, req.user, true);
+
+    if (deletablePages.length === 0) {
+      const msg = 'No pages can be deleted.';
+      return res.apiv3Err(new ErrorV3(msg), 500);
     }
-    catch (err) {
-      return res.apiv3Err(new ErrorV3('Failed to update page.', 'unknown'), 500);
+
+    // when some pages are not deletable
+    if (deletablePages.length < pagesInTrash.length) {
+      try {
+        const options = { isCompletely: true, isRecursively: true };
+        await crowi.pageService.deleteMultiplePages(deletablePages, req.user, options);
+        return res.apiv3({ deletablePages });
+      }
+      catch (err) {
+        return res.apiv3Err(new ErrorV3('Failed to update page.', 'unknown'), 500);
+      }
+    }
+    // when all pages are deletable
+    else {
+      try {
+        const pages = await crowi.pageService.emptyTrashPage(req.user, options);
+        return res.apiv3({ pages });
+      }
+      catch (err) {
+        return res.apiv3Err(new ErrorV3('Failed to update page.', 'unknown'), 500);
+      }
     }
   });
 
@@ -834,7 +857,12 @@ module.exports = (crowi) => {
     }
 
     try {
-      await crowi.pageService.normalizeParentByPageIds(pageIds, req.user, isRecursively);
+      if (isRecursively) {
+        await crowi.pageService.normalizeParentByPageIdsRecursively(pageIds, req.user);
+      }
+      else {
+        await crowi.pageService.normalizeParentByPageIds(pageIds, req.user);
+      }
     }
     catch (err) {
       return res.apiv3Err(new ErrorV3(`Failed to migrate pages: ${err.message}`), 500);

+ 2 - 1
packages/app/src/server/routes/tag.js

@@ -1,3 +1,5 @@
+import Tag from '~/server/models/tag';
+
 /**
  * @swagger
  *
@@ -29,7 +31,6 @@
  */
 module.exports = function(crowi, app) {
 
-  const Tag = crowi.model('Tag');
   const PageTagRelation = crowi.model('PageTagRelation');
   const ApiResponse = require('../util/apiResponse');
   const actions = {};

+ 41 - 20
packages/app/src/server/service/page.ts

@@ -8,6 +8,7 @@ import streamToPromise from 'stream-to-promise';
 
 import { SUPPORTED_TARGET_MODEL_TYPE, SUPPORTED_ACTION_TYPE } from '~/interfaces/activity';
 import { Ref } from '~/interfaces/common';
+import { V5ConversionErrCode } from '~/interfaces/errors/v5-conversion-error';
 import { HasObjectId } from '~/interfaces/has-object-id';
 import {
   IPage, IPageInfo, IPageInfoForEntity, IPageWithMeta,
@@ -16,7 +17,7 @@ import {
   PageDeleteConfigValue, IPageDeleteConfigValueToProcessValidation,
 } from '~/interfaces/page-delete-config';
 import { IUserHasId } from '~/interfaces/user';
-import { SocketEventName, UpdateDescCountRawData } from '~/interfaces/websocket';
+import { PageMigrationErrorData, SocketEventName, UpdateDescCountRawData } from '~/interfaces/websocket';
 import { stringifySnapshot } from '~/models/serializers/in-app-notification-snapshot/page';
 import {
   CreateMethod, PageCreateOptions, PageModel, PageDocument,
@@ -32,7 +33,6 @@ import { PageRedirectModel } from '../models/page-redirect';
 import { serializePageSecurely } from '../models/serializers/page-serializer';
 import Subscription from '../models/subscription';
 import { V5ConversionError } from '../models/vo/v5-conversion-error';
-import { V5ConversionErrCode } from '~/interfaces/errors/v5-conversion-error';
 
 const debug = require('debug')('growi:services:page');
 
@@ -2315,24 +2315,37 @@ class PageService {
     this.normalizeParentRecursivelyMainOperation(page, user, pageOp._id);
   }
 
-  async normalizeParentByPageIds(pageIds: ObjectIdLike[], user, isRecursively: boolean): Promise<void> {
+  async normalizeParentByPageIdsRecursively(pageIds: ObjectIdLike[], user): Promise<void> {
     const Page = mongoose.model('Page') as unknown as PageModel;
 
-    if (isRecursively) {
-      const pages = await Page.findByIdsAndViewer(pageIds, user, null);
+    const pages = await Page.findByIdsAndViewer(pageIds, user, null);
 
-      // DO NOT await !!
-      this.normalizeParentRecursivelyByPages(pages, user);
+    if (pages == null || pages.length === 0) {
+      throw Error('pageIds is null or 0 length.');
+    }
 
-      return;
+    if (pages.length > LIMIT_FOR_MULTIPLE_PAGE_OP) {
+      throw Error(`The maximum number of pageIds allowed is ${LIMIT_FOR_MULTIPLE_PAGE_OP}.`);
     }
 
+    this.normalizeParentRecursivelyByPages(pages, user);
+
+    return;
+  }
+
+  async normalizeParentByPageIds(pageIds: ObjectIdLike[], user): Promise<void> {
+    const Page = await mongoose.model('Page') as unknown as PageModel;
+
+    const socket = this.crowi.socketIoService.getDefaultSocket();
+
     for await (const pageId of pageIds) {
       const page = await Page.findById(pageId);
       if (page == null) {
         continue;
       }
 
+      const errorData: PageMigrationErrorData = { paths: [page.path] };
+
       try {
         const canOperate = await this.crowi.pageOperationService.canOperate(false, page.path, page.path);
         if (!canOperate) {
@@ -2342,14 +2355,16 @@ class PageService {
         const normalizedPage = await this.normalizeParentByPage(page, user);
 
         if (normalizedPage == null) {
+          socket.emit(SocketEventName.PageMigrationError, errorData);
           logger.error(`Failed to update descendantCount of page of id: "${pageId}"`);
         }
       }
       catch (err) {
+        socket.emit(SocketEventName.PageMigrationError, errorData);
         logger.error('Something went wrong while normalizing parent.', err);
-        // socket.emit('normalizeParentByPageIds', { error: err.message }); TODO: use socket to tell user
       }
     }
+    socket.emit(SocketEventName.PageMigrationSuccess);
   }
 
   private async normalizeParentByPage(page, user) {
@@ -2412,14 +2427,7 @@ class PageService {
     /*
      * Main Operation
      */
-    if (pages == null || pages.length === 0) {
-      logger.error('pageIds is null or 0 length.');
-      return;
-    }
-
-    if (pages.length > LIMIT_FOR_MULTIPLE_PAGE_OP) {
-      throw Error(`The maximum number of pageIds allowed is ${LIMIT_FOR_MULTIPLE_PAGE_OP}.`);
-    }
+    const socket = this.crowi.socketIoService.getDefaultSocket();
 
     const pagesToNormalize = omitDuplicateAreaPageFromPages(pages);
 
@@ -2429,25 +2437,29 @@ class PageService {
       [normalizablePages, nonNormalizablePages] = await this.crowi.pageGrantService.separateNormalizableAndNotNormalizablePages(user, pagesToNormalize);
     }
     catch (err) {
+      socket.emit(SocketEventName.PageMigrationError);
       throw err;
     }
 
     if (normalizablePages.length === 0) {
-      // socket.emit('normalizeParentRecursivelyByPages', { error: err.message }); TODO: use socket to tell user
+      socket.emit(SocketEventName.PageMigrationError);
       return;
     }
 
     if (nonNormalizablePages.length !== 0) {
-      // TODO: iterate nonNormalizablePages and send socket error to client so that the user can know which path failed to migrate
-      // socket.emit('normalizeParentRecursivelyByPages', { error: err.message }); TODO: use socket to tell user
+      const nonNormalizablePagePaths: string[] = nonNormalizablePages.map(p => p.path);
+      socket.emit(SocketEventName.PageMigrationError, { paths: nonNormalizablePagePaths });
+      logger.debug('Some pages could not be converted.', nonNormalizablePagePaths);
     }
 
     /*
      * Main Operation (s)
      */
+    const errorPagePaths: string[] = [];
     for await (const page of normalizablePages) {
       const canOperate = await this.crowi.pageOperationService.canOperate(true, page.path, page.path);
       if (!canOperate) {
+        errorPagePaths.push(page.path);
         throw Error(`Cannot operate normalizeParentRecursiively to path "${page.path}" right now.`);
       }
 
@@ -2459,6 +2471,7 @@ class PageService {
       const existingPage = await builder.query.exec();
 
       if (existingPage?.parent != null) {
+        errorPagePaths.push(page.path);
         throw Error('This page has already converted.');
       }
 
@@ -2474,6 +2487,7 @@ class PageService {
         });
       }
       catch (err) {
+        errorPagePaths.push(page.path);
         logger.error('Failed to create PageOperation document.', err);
         throw err;
       }
@@ -2482,10 +2496,17 @@ class PageService {
         await this.normalizeParentRecursivelyMainOperation(page, user, pageOp._id);
       }
       catch (err) {
+        errorPagePaths.push(page.path);
         logger.err('Failed to run normalizeParentRecursivelyMainOperation.', err);
         throw err;
       }
     }
+    if (errorPagePaths.length === 0) {
+      socket.emit(SocketEventName.PageMigrationSuccess);
+    }
+    else {
+      socket.emit(SocketEventName.PageMigrationError, { paths: errorPagePaths });
+    }
   }
 
   async normalizeParentRecursivelyMainOperation(page, user, pageOpId: ObjectIdLike): Promise<void> {

+ 1 - 0
packages/app/src/server/views/layout/layout.html

@@ -105,6 +105,7 @@
 
 <div id="page-create-modal"></div>
 <div id="page-delete-modal"></div>
+<div id="empty-trash-modal"></div>
 <div id="page-duplicate-modal"></div>
 <div id="page-rename-modal"></div>
 <div id="page-presentation-modal"></div>

+ 48 - 2
packages/app/src/stores/modal.tsx

@@ -1,11 +1,13 @@
 import { SWRResponse } from 'swr';
-import { useStaticSWR } from './use-static-swr';
+
+import { IPageToDeleteWithMeta, IPageToRenameWithMeta } from '~/interfaces/page';
 import {
   OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction, OnPutBackedFunction,
 } from '~/interfaces/ui';
-import { IPageToDeleteWithMeta, IPageToRenameWithMeta } from '~/interfaces/page';
 import { IUserGroupHasId } from '~/interfaces/user';
 
+import { useStaticSWR } from './use-static-swr';
+
 
 /*
 * PageCreateModal
@@ -31,6 +33,9 @@ export const usePageCreateModal = (status?: CreateModalStatus): SWRResponse<Crea
   };
 };
 
+/*
+* PageDeleteModal
+*/
 export type IDeleteModalOption = {
   onDeleted?: OnDeletedFunction,
 }
@@ -68,6 +73,47 @@ export const usePageDeleteModal = (status?: DeleteModalStatus): SWRResponse<Dele
   };
 };
 
+/*
+* EmptyTrashModal
+*/
+type IEmptyTrashModalOption = {
+  onEmptiedTrash?: () => void,
+  canDelepeAllPages: boolean,
+}
+
+type EmptyTrashModalStatus = {
+  isOpened: boolean,
+  pages?: IPageToDeleteWithMeta[],
+  opts?: IEmptyTrashModalOption,
+}
+
+type EmptyTrashModalStatusUtils = {
+  open(
+    pages?: IPageToDeleteWithMeta[],
+    opts?: IEmptyTrashModalOption,
+  ): Promise<EmptyTrashModalStatus | undefined>,
+  close(): Promise<EmptyTrashModalStatus | undefined>,
+}
+
+export const useEmptyTrashModal = (status?: EmptyTrashModalStatus): SWRResponse<EmptyTrashModalStatus, Error> & EmptyTrashModalStatusUtils => {
+  const initialData: EmptyTrashModalStatus = {
+    isOpened: false,
+    pages: [],
+  };
+  const swrResponse = useStaticSWR<EmptyTrashModalStatus, Error>('emptyTrashModalStatus', status, { fallbackData: initialData });
+
+  return {
+    ...swrResponse,
+    open: (
+        pages?: IPageToDeleteWithMeta[],
+        opts?: IEmptyTrashModalOption,
+    ) => swrResponse.mutate({
+      isOpened: true, pages, opts,
+    }),
+    close: () => swrResponse.mutate({ isOpened: false }),
+  };
+};
+
 /*
 * PageDuplicateModal
 */

+ 3 - 3
packages/app/src/stores/tag.tsx

@@ -2,11 +2,11 @@ import { SWRResponse } from 'swr';
 import useSWRImmutable from 'swr/immutable';
 
 import { apiGet } from '~/client/util/apiv1-client';
-import { ITagsListApiv1Result } from '~/interfaces/tag';
+import { IResTagsListApiv1 } from '~/interfaces/tag';
 
-export const useSWRxTagsList = (limit?: number, offset?: number): SWRResponse<ITagsListApiv1Result, Error> => {
+export const useSWRxTagsList = (limit?: number, offset?: number): SWRResponse<IResTagsListApiv1, Error> => {
   return useSWRImmutable(
     ['/tags.list', limit, offset],
-    (endpoint, limit, offset) => apiGet(endpoint, { limit, offset }).then((result: ITagsListApiv1Result) => result),
+    (endpoint, limit, offset) => apiGet(endpoint, { limit, offset }).then((result: IResTagsListApiv1) => result),
   );
 };

+ 10 - 0
packages/app/src/styles/_mixins.scss

@@ -223,3 +223,13 @@
     }
   }
 }
+
+@mixin count-badge($color, $bg-color, $min-width: initial) {
+  min-width: $min-width;
+  padding: 0.1rem 0.5rem;
+  font-family: $font-family-monospace;
+  font-size: 12px;
+  font-weight: 500;
+  color: $color;
+  background-color: $bg-color;
+}

+ 0 - 6
packages/app/src/styles/_page-tree.scss

@@ -48,12 +48,6 @@ $grw-pagetree-item-padding-left: 10px;
       &:hover {
         display: none;
       }
-
-      .grw-count-badge {
-        min-width: 28px;
-        padding: 0.1rem 0.5rem;
-        font-size: 12px;
-      }
     }
   }
 

+ 5 - 9
packages/app/src/styles/theme/_apply-colors-dark.scss

@@ -277,20 +277,17 @@ ul.pagination {
   // Pagetree
   .grw-pagetree {
     @include override-list-group-item-for-pagetree(
-      $gray-200,
-      $bgcolor-sidebar-list-group,
       $gray-200,
       lighten($bgcolor-sidebar-context, 8%),
+      lighten($bgcolor-sidebar-context, 15%),
+      $gray-500,
       $gray-200,
-      lighten($bgcolor-sidebar-context, 15%)
+      lighten($bgcolor-sidebar-context, 18%),
+      lighten($bgcolor-sidebar-context, 24%)
     );
     .grw-pagetree-triangle-btn {
       @include button-outline-svg-icon-variant($secondary, $gray-200);
     }
-    .grw-count-badge {
-      color: $gray-400;
-      background: lighten($bgcolor-sidebar-context, 15%);
-    }
     .btn-page-item-control {
       @include button-outline-variant($gray-500, $gray-500, $secondary, transparent);
       @include hover() {
@@ -482,8 +479,7 @@ ul.pagination {
 */
 .grw-side-contents-sticky-container {
   .grw-count-badge {
-    color: $gray-400;
-    background: $gray-700;
+    @include count-badge($gray-400, $gray-700);
   }
 
   .grw-border-vr {

+ 8 - 12
packages/app/src/styles/theme/_apply-colors-light.scss

@@ -182,20 +182,17 @@ $dropdown-link-active-bg: $bgcolor-dropdown-link-active;
   // Pagetree
   .grw-pagetree {
     @include override-list-group-item-for-pagetree(
-      $color-list,
-      $bgcolor-sidebar-list-group,
-      $color-list-hover,
-      $bgcolor-list-hover,
-      $color-list-active,
-      $bgcolor-list-active
+      $color-sidebar-context,
+      darken($bgcolor-sidebar-context, 5%),
+      darken($bgcolor-sidebar-context, 12%),
+      lighten($color-sidebar-context, 10%),
+      lighten($color-sidebar-context, 8%),
+      darken($bgcolor-sidebar-context, 15%),
+      darken($bgcolor-sidebar-context, 24%)
     );
     .grw-pagetree-triangle-btn {
       @include button-outline-svg-icon-variant($gray-400, $primary);
     }
-    .grw-count-badge {
-      color: $gray-500;
-      background: $gray-200;
-    }
   }
   .private-legacy-pages-link {
     &:hover {
@@ -358,8 +355,7 @@ $dropdown-link-active-bg: $bgcolor-dropdown-link-active;
 */
 .grw-side-contents-sticky-container {
   .grw-count-badge {
-    color: $primary;
-    background: $gray-200;
+    @include count-badge($gray-600, $gray-200);
   }
 
   .grw-border-vr {

+ 0 - 17
packages/app/src/styles/theme/christmas.scss

@@ -179,21 +179,4 @@ html[dark] {
       @include btn-page-editor-mode-manager(darken($subthemecolor, 15%), lighten($subthemecolor, 35%), lighten($subthemecolor, 45%));
     }
   }
-
-  /*
- * GROWI Sidebar
- */
-  .grw-sidebar {
-    // Pagetree
-    .grw-pagetree {
-      @include override-list-group-item-for-pagetree(
-        $color-list,
-        $bgcolor-sidebar-list-group,
-        $color-list-hover,
-        $bgcolor-list-hover,
-        $color-list-active,
-        $bgcolor-list-hover
-      );
-    }
-  }
 }

+ 1 - 1
packages/app/src/styles/theme/default.scss

@@ -170,7 +170,7 @@ html[dark] {
   $color-resize-button-hover: white;
   $bgcolor-resize-button-hover: darken($bgcolor-resize-button, 5%);
   // Sidebar contents
-  $bgcolor-sidebar-context: lighten($bgcolor-global, 10%);
+  $bgcolor-sidebar-context: lighten($bgcolor-global, 8%);
   $color-sidebar-context: $color-global;
   // Sidebar list group
   $bgcolor-sidebar-list-group: #1c2a3e; // optional

+ 0 - 17
packages/app/src/styles/theme/future.scss

@@ -104,21 +104,4 @@ html[dark] {
     color: #95abba;
     background-color: #1f1f22;
   }
-
-  /*
- * GROWI Sidebar
- */
-  .grw-sidebar {
-    // Pagetree
-    .grw-pagetree {
-      @include override-list-group-item-for-pagetree(
-        $color-list,
-        $bgcolor-sidebar-list-group,
-        $color-list-hover,
-        $bgcolor-list-hover,
-        $color-list-active,
-        lighten($bgcolor-list-hover, 5%)
-      );
-    }
-  }
 }

+ 0 - 8
packages/app/src/styles/theme/island.scss

@@ -123,14 +123,6 @@ html[dark] {
   .grw-sidebar {
     // Pagetree
     .grw-pagetree {
-      @include override-list-group-item-for-pagetree(
-        $color-list,
-        $bgcolor-sidebar-list-group,
-        $color-list-hover,
-        $bgcolor-list-hover,
-        $color-list-active,
-        lighten($bgcolor-list-hover, 5%)
-      );
       .grw-pagetree-triangle-btn {
         @include button-outline-svg-icon-variant($gray-400, $bgcolor-sidebar);
       }

+ 0 - 17
packages/app/src/styles/theme/kibela.scss

@@ -107,21 +107,4 @@ html[dark] {
       @include btn-page-editor-mode-manager(darken($primary, 15%), lighten($primary, 45%), lighten($primary, 50%));
     }
   }
-
-  /*
- * GROWI Sidebar
- */
-  .grw-sidebar {
-    // Pagetree
-    .grw-pagetree {
-      @include override-list-group-item-for-pagetree(
-        $color-list,
-        $bgcolor-sidebar-list-group,
-        $color-list-hover,
-        $bgcolor-list-hover,
-        $color-list-active,
-        lighten($bgcolor-list-active, 55%)
-      );
-    }
-  }
 }

+ 24 - 8
packages/app/src/styles/theme/mixins/_list-group.scss

@@ -18,14 +18,7 @@
   }
 }
 
-@mixin override-list-group-item-for-pagetree(
-  $color,
-  $bgcolor,
-  $color-hover: $color,
-  $bgcolor-hover: $bgcolor,
-  $color-active: $color,
-  $bgcolor-active: $bgcolor
-) {
+@mixin override-list-group-item-for-pagetree($color, $bgcolor-hover, $bgcolor-active, $btn-color, $btn-color-hover, $btn-bgcolor-hover, $btn-bgcolor-active) {
   .grw-pagetree-is-over {
     background: $bgcolor-hover;
   }
@@ -34,6 +27,24 @@
     background-color: transparent;
     border-color: $border-color-global;
 
+    .grw-count-badge {
+      @include count-badge($btn-color, $bgcolor-hover, 28px);
+    }
+
+    .btn.btn-page-item-control {
+      color: $btn-color;
+      background-color: transparent;
+      @include hover() {
+        color: $btn-color-hover;
+        background-color: $btn-bgcolor-hover;
+      }
+      &:not(:disabled):not(.disabled):active,
+      &:not(:disabled):not(.disabled).active {
+        color: $btn-color-hover;
+        background-color: $btn-bgcolor-active;
+      }
+    }
+
     &.grw-pagetree-current-page-item {
       background: $bgcolor-hover;
     }
@@ -46,5 +57,10 @@
         background-color: $bgcolor-active;
       }
     }
+    .grw-pagetree-title-anchor {
+      .grw-sidebar-text-muted {
+        color: rgba(desaturate($color, 50%), 0.6);
+      }
+    }
   }
 }

+ 1 - 1
packages/app/src/styles/theme/nature.scss

@@ -72,7 +72,7 @@ html[dark] {
   $bgcolor-sidebar: #188f64;
   // Sidebar contents
   $color-sidebar-context: #7e0044;
-  $bgcolor-sidebar-context: #fdffeb;
+  $bgcolor-sidebar-context: #f7f9e9;
   // Sidebar resize button
   $color-resize-button: white;
   $bgcolor-resize-button: $themecolor;

+ 1 - 1
packages/app/src/styles/theme/wood.scss

@@ -91,7 +91,7 @@ html[dark] {
   $bgcolor-sidebar: $themecolor;
   // Sidebar contents
   $color-sidebar-context: #9d7406;
-  $bgcolor-sidebar-context: transparent;
+  $bgcolor-sidebar-context: lighten($themecolor, 32%);
   // Sidebar list group
   $bgcolor-sidebar-list-group: rgba(#f7f5f1, 0.5);
   // Sidebar resize button

+ 1 - 2
packages/app/test/integration/models/v5.page.test.js

@@ -1,5 +1,6 @@
 import mongoose from 'mongoose';
 
+
 import { getInstance } from '../setup-crowi';
 
 describe('Page', () => {
@@ -7,7 +8,6 @@ describe('Page', () => {
   let Page;
   let Revision;
   let User;
-  let Tag;
   let PageTagRelation;
   let Bookmark;
   let Comment;
@@ -35,7 +35,6 @@ describe('Page', () => {
     User = mongoose.model('User');
     Page = mongoose.model('Page');
     Revision = mongoose.model('Revision');
-    Tag = mongoose.model('Tag');
     PageTagRelation = mongoose.model('PageTagRelation');
     Bookmark = mongoose.model('Bookmark');
     Comment = mongoose.model('Comment');

+ 3 - 2
packages/app/test/integration/service/page.test.js

@@ -1,8 +1,11 @@
 /* eslint-disable no-unused-vars */
 import { advanceTo } from 'jest-date-mock';
 
+import Tag from '~/server/models/tag';
+
 const mongoose = require('mongoose');
 
+
 const { getInstance } = require('../setup-crowi');
 
 let testUser1;
@@ -50,7 +53,6 @@ describe('PageService', () => {
   let Page;
   let Revision;
   let User;
-  let Tag;
   let PageTagRelation;
   let Bookmark;
   let Comment;
@@ -64,7 +66,6 @@ describe('PageService', () => {
     User = mongoose.model('User');
     Page = mongoose.model('Page');
     Revision = mongoose.model('Revision');
-    Tag = mongoose.model('Tag');
     PageTagRelation = mongoose.model('PageTagRelation');
     Bookmark = mongoose.model('Bookmark');
     Comment = mongoose.model('Comment');

+ 125 - 7
packages/app/test/integration/service/user-groups.test.ts

@@ -6,16 +6,23 @@ import { getInstance } from '../setup-crowi';
 describe('UserGroupService', () => {
   let crowi;
   let UserGroup;
+  let UserGroupRelation;
 
   const groupId1 = new mongoose.Types.ObjectId();
   const groupId2 = new mongoose.Types.ObjectId();
   const groupId3 = new mongoose.Types.ObjectId();
+  const groupId4 = new mongoose.Types.ObjectId();
+  const groupId5 = new mongoose.Types.ObjectId();
+  const groupId6 = new mongoose.Types.ObjectId();
+  const groupId7 = new mongoose.Types.ObjectId();
+  const groupId8 = new mongoose.Types.ObjectId();
 
+  const userId1 = new mongoose.Types.ObjectId();
 
   beforeAll(async() => {
     crowi = await getInstance();
-
     UserGroup = mongoose.model('UserGroup');
+    UserGroupRelation = mongoose.model('UserGroupRelation');
 
 
     // Create Groups
@@ -32,26 +39,74 @@ describe('UserGroupService', () => {
         name: 'v5_group2',
         description: 'description2',
       },
+      // No parent
       {
         _id: groupId3,
         name: 'v5_group3',
-        parent: groupId1,
         description: 'description3',
       },
+      // No parent
+      {
+        _id: groupId4,
+        name: 'v5_group4',
+        description: 'description4',
+      },
+      // No parent
+      {
+        _id: groupId5,
+        name: 'v5_group5',
+        description: 'description5',
+      },
+      // No parent
+      {
+        _id: groupId6,
+        name: 'v5_group6',
+        description: 'description6',
+      },
+      // No parent
+      {
+        _id: groupId7,
+        name: 'v5_group7',
+        description: 'description7',
+        parent: groupId6,
+      },
+      // No parent
+      {
+        _id: groupId8,
+        name: 'v5_group8',
+        description: 'description8',
+      },
     ]);
+
+    // Create UserGroupRelations
+    await UserGroupRelation.insertMany([
+      {
+        relatedGroup: groupId4,
+        relatedUser: userId1,
+      },
+      {
+        relatedGroup: groupId6,
+        relatedUser: userId1,
+      },
+      {
+        relatedGroup: groupId8,
+        relatedUser: userId1,
+      },
+    ]);
+
   });
 
   /*
     * Update UserGroup
     */
   test('Updated values should be reflected. (name, description, parent)', async() => {
-    const userGroup = await UserGroup.findOne({ _id: groupId1 });
+    const userGroup2 = await UserGroup.findOne({ _id: groupId2 });
 
     const newGroupName = 'v5_group1_new';
     const newGroupDescription = 'description1_new';
-    const newParentId = groupId2;
+    const newParentId = userGroup2._id;
 
-    const updatedUserGroup = await crowi.userGroupService.updateGroup(userGroup._id, newGroupName, newGroupDescription, newParentId);
+    const updatedUserGroup = await crowi.userGroupService.updateGroup(groupId1, newGroupName, newGroupDescription, newParentId);
 
     expect(updatedUserGroup.name).toBe(newGroupName);
     expect(updatedUserGroup.description).toBe(newGroupDescription);
@@ -59,10 +114,10 @@ describe('UserGroupService', () => {
   });
 
   test('Should throw an error when trying to set existing group name', async() => {
-    const userGroup1 = await UserGroup.findOne({ _id: groupId1 });
+
     const userGroup2 = await UserGroup.findOne({ _id: groupId2 });
 
-    const result = crowi.userGroupService.updateGroup(userGroup1._id, userGroup2.name);
+    const result = crowi.userGroupService.updateGroup(groupId1, userGroup2.name);
 
     await expect(result).rejects.toThrow('The group name is already taken');
   });
@@ -74,4 +129,67 @@ describe('UserGroupService', () => {
     expect(updatedUserGroup.parent).toBeNull();
   });
 
+  /*
+  * forceUpdateParents: false
+  */
+  test('Should throw an error when users in child group do not exist in parent group', async() => {
+    const userGroup4 = await UserGroup.findOne({ _id: groupId4, parent: null });
+    const result = crowi.userGroupService.updateGroup(userGroup4._id, userGroup4.name, userGroup4.description, groupId5);
+
+    await expect(result).rejects.toThrow('The parent group does not contain the users in this group.');
+  });
+
+  /*
+  * forceUpdateParents: true
+  */
+  test('User should be included to parent group (2 groups ver)', async() => {
+    const userGroup4 = await UserGroup.findOne({ _id: groupId4, parent: null });
+    const userGroup5 = await UserGroup.findOne({ _id: groupId5, parent: null });
+    // userGroup4 has userId1
+    const userGroupRelation4BeforeUpdate = await UserGroupRelation.findOne({ relatedGroup:  userGroup4, relatedUser: userId1 });
+    expect(userGroupRelation4BeforeUpdate).not.toBeNull();
+
+    // userGroup5 has not userId1
+    const userGroupRelation5BeforeUpdate = await UserGroupRelation.findOne({ relatedGroup:  userGroup5, relatedUser: userId1 });
+    expect(userGroupRelation5BeforeUpdate).toBeNull();
+
+    // update userGroup4's parent with userGroup5 (forceUpdate: true)
+    const forceUpdateParents = true;
+    const updatedUserGroup = await crowi.userGroupService.updateGroup(
+      userGroup4._id, userGroup4.name, userGroup4.description, groupId5, forceUpdateParents,
+    );
+
+    expect(updatedUserGroup.parent).toStrictEqual(groupId5);
+    // userGroup5 should have userId1
+    const userGroupRelation5AfterUpdate = await UserGroupRelation.findOne({ relatedGroup: groupId5, relatedUser: userGroupRelation4BeforeUpdate.relatedUser });
+    expect(userGroupRelation5AfterUpdate).not.toBeNull();
+  });
+
+  test('User should be included to parent group (3 groups ver)', async() => {
+    const userGroup8 = await UserGroup.findOne({ _id: groupId8, parent: null });
+
+    // userGroup7 has not userId1
+    const userGroupRelation6BeforeUpdate = await UserGroupRelation.findOne({ relatedGroup:  groupId6, relatedUser: userId1 });
+    const userGroupRelation7BeforeUpdate = await UserGroupRelation.findOne({ relatedGroup:  groupId7, relatedUser: userId1 });
+    const userGroupRelation8BeforeUpdate = await UserGroupRelation.findOne({ relatedGroup:  groupId8, relatedUser: userId1 });
+    expect(userGroupRelation6BeforeUpdate).not.toBeNull();
+    // userGroup7 does not have userId1
+    expect(userGroupRelation7BeforeUpdate).toBeNull();
+    expect(userGroupRelation8BeforeUpdate).not.toBeNull();
+
+    // update userGroup8's parent with userGroup7 (forceUpdate: true)
+    const forceUpdateParents = true;
+    await crowi.userGroupService.updateGroup(
+      userGroup8._id, userGroup8.name, userGroup8.description, groupId7, forceUpdateParents,
+    );
+
+    const userGroupRelation6AfterUpdate = await UserGroupRelation.findOne({ relatedGroup: groupId6, relatedUser: userId1 });
+    const userGroupRelation7AfterUpdate = await UserGroupRelation.findOne({ relatedGroup: groupId7, relatedUser: userId1 });
+    const userGroupRelation8AfterUpdate = await UserGroupRelation.findOne({ relatedGroup: groupId8, relatedUser: userId1 });
+    expect(userGroupRelation6AfterUpdate).not.toBeNull();
+    // userGroup7 should have userId1
+    expect(userGroupRelation7AfterUpdate).not.toBeNull();
+    expect(userGroupRelation8AfterUpdate).not.toBeNull();
+  });
+
 });

+ 5 - 7
packages/app/test/integration/service/v5.non-public-page.test.ts

@@ -1,8 +1,8 @@
 /* eslint-disable no-unused-vars */
 import { advanceTo } from 'jest-date-mock';
-
 import mongoose from 'mongoose';
 
+import Tag from '../../../src/server/models/tag';
 import { getInstance } from '../setup-crowi';
 
 describe('PageService page operations with non-public pages', () => {
@@ -22,7 +22,6 @@ describe('PageService page operations with non-public pages', () => {
   let User;
   let UserGroup;
   let UserGroupRelation;
-  let Tag;
   let PageTagRelation;
   let Bookmark;
   let Comment;
@@ -93,7 +92,6 @@ describe('PageService page operations with non-public pages', () => {
     UserGroupRelation = mongoose.model('UserGroupRelation');
     Page = mongoose.model('Page');
     Revision = mongoose.model('Revision');
-    Tag = mongoose.model('Tag');
     PageTagRelation = mongoose.model('PageTagRelation');
     Bookmark = mongoose.model('Bookmark');
     Comment = mongoose.model('Comment');
@@ -1044,7 +1042,7 @@ describe('PageService page operations with non-public pages', () => {
       const trashedPage = await Page.findOne({ path: '/trash/np_revert1', status: Page.STATUS_DELETED, grant: Page.GRANT_RESTRICTED });
       const revision = await Revision.findOne({ pageId: trashedPage._id });
       const tag = await Tag.findOne({ name: 'np_revertTag1' });
-      const deletedPageTagRelation = await PageTagRelation.findOne({ relatedPage: trashedPage._id, relatedTag: tag._id, isPageTrashed: true });
+      const deletedPageTagRelation = await PageTagRelation.findOne({ relatedPage: trashedPage._id, relatedTag: tag?._id, isPageTrashed: true });
       expect(trashedPage).toBeTruthy();
       expect(revision).toBeTruthy();
       expect(tag).toBeTruthy();
@@ -1054,7 +1052,7 @@ describe('PageService page operations with non-public pages', () => {
 
       const revertedPage = await Page.findOne({ path: '/np_revert1' });
       const deltedPageBeforeRevert = await Page.findOne({ path: '/trash/np_revert1' });
-      const pageTagRelation = await PageTagRelation.findOne({ relatedPage: revertedPage._id, relatedTag: tag._id });
+      const pageTagRelation = await PageTagRelation.findOne({ relatedPage: revertedPage._id, relatedTag: tag?._id });
       expect(revertedPage).toBeTruthy();
       expect(pageTagRelation).toBeTruthy();
       expect(deltedPageBeforeRevert).toBeNull();
@@ -1071,7 +1069,7 @@ describe('PageService page operations with non-public pages', () => {
       const trashedPage = await Page.findOne({ path: beforeRevertPath, status: Page.STATUS_DELETED, grant: Page.GRANT_USER_GROUP });
       const revision = await Revision.findOne({ pageId: trashedPage._id });
       const tag = await Tag.findOne({ name: 'np_revertTag2' });
-      const deletedPageTagRelation = await PageTagRelation.findOne({ relatedPage: trashedPage._id, relatedTag: tag._id, isPageTrashed: true });
+      const deletedPageTagRelation = await PageTagRelation.findOne({ relatedPage: trashedPage._id, relatedTag: tag?._id, isPageTrashed: true });
       expect(trashedPage).toBeTruthy();
       expect(revision).toBeTruthy();
       expect(tag).toBeTruthy();
@@ -1081,7 +1079,7 @@ describe('PageService page operations with non-public pages', () => {
 
       const revertedPage = await Page.findOne({ path: '/np_revert2' });
       const trashedPageBR = await Page.findOne({ path: beforeRevertPath });
-      const pageTagRelation = await PageTagRelation.findOne({ relatedPage: revertedPage._id, relatedTag: tag._id });
+      const pageTagRelation = await PageTagRelation.findOne({ relatedPage: revertedPage._id, relatedTag: tag?._id });
       expect(revertedPage).toBeTruthy();
       expect(pageTagRelation).toBeTruthy();
       expect(trashedPageBR).toBeNull();

+ 8 - 10
packages/app/test/integration/service/v5.public-page.test.ts

@@ -1,8 +1,8 @@
 /* eslint-disable no-unused-vars */
 import { advanceTo } from 'jest-date-mock';
-
 import mongoose from 'mongoose';
 
+import Tag from '../../../src/server/models/tag';
 import { getInstance } from '../setup-crowi';
 
 describe('PageService page operations with only public pages', () => {
@@ -14,7 +14,6 @@ describe('PageService page operations with only public pages', () => {
   let Page;
   let Revision;
   let User;
-  let Tag;
   let PageTagRelation;
   let Bookmark;
   let Comment;
@@ -31,7 +30,6 @@ describe('PageService page operations with only public pages', () => {
     User = mongoose.model('User');
     Page = mongoose.model('Page');
     Revision = mongoose.model('Revision');
-    Tag = mongoose.model('Tag');
     PageTagRelation = mongoose.model('PageTagRelation');
     Bookmark = mongoose.model('Bookmark');
     Comment = mongoose.model('Comment');
@@ -1304,8 +1302,8 @@ describe('PageService page operations with only public pages', () => {
       const basePage = await Page.findOne({ path: '/v5_PageForDuplicate5' });
       const tag1 = await Tag.findOne({ name: 'duplicate_Tag1' });
       const tag2 = await Tag.findOne({ name:  'duplicate_Tag2' });
-      const basePageTagRelation1 = await PageTagRelation.findOne({ relatedTag: tag1._id });
-      const basePageTagRelation2 = await PageTagRelation.findOne({ relatedTag: tag2._id });
+      const basePageTagRelation1 = await PageTagRelation.findOne({ relatedTag: tag1?._id });
+      const basePageTagRelation2 = await PageTagRelation.findOne({ relatedTag: tag2?._id });
       expect(basePage).toBeTruthy();
       expect(tag1).toBeTruthy();
       expect(tag2).toBeTruthy();
@@ -1463,8 +1461,8 @@ describe('PageService page operations with only public pages', () => {
       const pageToDelete = await Page.findOne({ path: '/v5_PageForDelete6' });
       const tag1 = await Tag.findOne({ name: 'TagForDelete1' });
       const tag2 = await Tag.findOne({ name: 'TagForDelete2' });
-      const pageRelation1 = await PageTagRelation.findOne({ relatedTag: tag1._id });
-      const pageRelation2 = await PageTagRelation.findOne({ relatedTag: tag2._id });
+      const pageRelation1 = await PageTagRelation.findOne({ relatedTag: tag1?._id });
+      const pageRelation2 = await PageTagRelation.findOne({ relatedTag: tag2?._id });
       expect(pageToDelete).toBeTruthy();
       expect(tag1).toBeTruthy();
       expect(tag2).toBeTruthy();
@@ -1549,7 +1547,7 @@ describe('PageService page operations with only public pages', () => {
       await deleteCompletely(parentPage, dummyUser1, {}, true);
       const deletedPages = await Page.find({ _id: { $in: [parentPage._id, childPage._id, grandchildPage._id] } });
       const deletedRevisions = await Revision.find({ pageId: { $in: [parentPage._id, grandchildPage._id] } });
-      const tags = await Tag.find({ _id: { $in: [tag1._id, tag2._id] } });
+      const tags = await Tag.find({ _id: { $in: [tag1?._id, tag2?._id] } });
       const deletedPageTagRelations = await PageTagRelation.find({ _id: { $in: [pageTagRelation1._id, pageTagRelation2._id] } });
       const deletedBookmarks = await Bookmark.find({ _id: bookmark._id });
       const deletedComments = await Comment.find({ _id: comment._id });
@@ -1632,14 +1630,14 @@ describe('PageService page operations with only public pages', () => {
       const deletedPage = await Page.findOne({ path: '/trash/v5_revert1', status: Page.STATUS_DELETED });
       const revision = await Revision.findOne({ pageId: deletedPage._id });
       const tag = await Tag.findOne({ name: 'revertTag1' });
-      const deletedPageTagRelation = await PageTagRelation.findOne({ relatedPage: deletedPage._id, relatedTag: tag._id, isPageTrashed: true });
+      const deletedPageTagRelation = await PageTagRelation.findOne({ relatedPage: deletedPage._id, relatedTag: tag?._id, isPageTrashed: true });
       expect(deletedPage).toBeTruthy();
       expect(revision).toBeTruthy();
       expect(tag).toBeTruthy();
       expect(deletedPageTagRelation).toBeTruthy();
 
       const revertedPage = await revertDeletedPage(deletedPage, dummyUser1, {}, false);
-      const pageTagRelation = await PageTagRelation.findOne({ relatedPage: deletedPage._id, relatedTag: tag._id });
+      const pageTagRelation = await PageTagRelation.findOne({ relatedPage: deletedPage._id, relatedTag: tag?._id });
 
       expect(revertedPage.parent).toStrictEqual(rootPage._id);
       expect(revertedPage.path).toBe('/v5_revert1');

+ 1 - 1
packages/codemirror-textlint/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/codemirror-textlint",
-  "version": "5.0.4",
+  "version": "5.0.5-RC.0",
   "license": "MIT",
   "main": "dist/index.js",
   "scripts": {

+ 1 - 1
packages/core/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/core",
-  "version": "5.0.4",
+  "version": "5.0.5-RC.0",
   "description": "GROWI Core Libraries",
   "license": "MIT",
   "keywords": [

+ 1 - 1
packages/plugin-attachment-refs/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/plugin-attachment-refs",
-  "version": "5.0.4",
+  "version": "5.0.5-RC.0",
   "description": "GROWI Plugin to add ref/refimg/refs/refsimg tags",
   "license": "MIT",
   "keywords": [

+ 1 - 1
packages/plugin-lsx/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/plugin-lsx",
-  "version": "5.0.4",
+  "version": "5.0.5-RC.0",
   "description": "GROWI plugin to list pages",
   "license": "MIT",
   "keywords": [

+ 1 - 1
packages/plugin-pukiwiki-like-linker/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/plugin-pukiwiki-like-linker",
-  "version": "5.0.4",
+  "version": "5.0.5-RC.0",
   "description": "GROWI plugin to add PukiwikiLikeLinker",
   "license": "MIT",
   "keywords": [

+ 1 - 1
packages/slack/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/slack",
-  "version": "5.0.4",
+  "version": "5.0.5-RC.0",
   "license": "MIT",
   "main": "dist/index.js",
   "typings": "dist/index.d.ts",

+ 2 - 2
packages/slackbot-proxy/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/slackbot-proxy",
-  "version": "5.0.4",
+  "version": "5.0.5-slackbot-proxy.0",
   "license": "MIT",
   "scripts": {
     "build": "yarn tsc && tsc-alias -p tsconfig.build.json",
@@ -25,7 +25,7 @@
   },
   "dependencies": {
     "@godaddy/terminus": "^4.9.0",
-    "@growi/slack": "^5.0.4",
+    "@growi/slack": "^5.0.5-RC.0",
     "@slack/oauth": "^2.0.1",
     "@slack/web-api": "^6.2.4",
     "@tsed/common": "^6.43.0",

+ 1 - 1
packages/ui/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/ui",
-  "version": "5.0.4",
+  "version": "5.0.5-RC.0",
   "description": "GROWI UI Libraries",
   "license": "MIT",
   "keywords": [