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

Merge commit '8dd2678de3252ac19b75f164633a62e65611752a' into
imprv/87813-open-duplicate-modal-on-subnavigation

kaori 4 лет назад
Родитель
Сommit
633a6803bc
84 измененных файлов с 1789 добавлено и 1111 удалено
  1. 9 3
      .devcontainer/docker-compose.yml
  2. 18 1
      CHANGELOG.md
  3. 0 2
      packages/app/.env.development
  4. 2 2
      packages/app/docker/README.md
  5. 1 1
      packages/app/package.json
  6. 5 0
      packages/app/resource/locales/en_US/admin/admin.json
  7. 5 0
      packages/app/resource/locales/ja_JP/admin/admin.json
  8. 5 0
      packages/app/resource/locales/zh_CN/admin/admin.json
  9. 7 7
      packages/app/src/client/app.jsx
  10. 4 0
      packages/app/src/client/base.jsx
  11. 0 54
      packages/app/src/client/services/PageAccessoriesContainer.js
  12. 0 65
      packages/app/src/client/services/PageContainer.js
  13. 2 2
      packages/app/src/client/util/smooth-scroll.ts
  14. 6 1
      packages/app/src/components/Admin/ImportData/GrowiArchive/ImportForm.jsx
  15. 70 0
      packages/app/src/components/Admin/UserGroup/UserGroupDropdown.tsx
  16. 3 3
      packages/app/src/components/Admin/UserGroup/UserGroupPage.tsx
  17. 45 34
      packages/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx
  18. 19 12
      packages/app/src/components/Common/ClosableTextInput.tsx
  19. 0 96
      packages/app/src/components/ContentLinkButtons.jsx
  20. 66 0
      packages/app/src/components/ContentLinkButtons.tsx
  21. 22 4
      packages/app/src/components/CustomNavigation/CustomTabContent.tsx
  22. 3 2
      packages/app/src/components/DescendantsPageList.tsx
  23. 100 0
      packages/app/src/components/DescendantsPageListModal.tsx
  24. 3 3
      packages/app/src/components/EventListeneres/HashChanged.tsx
  25. 3 3
      packages/app/src/components/ForbiddenPage.tsx
  26. 2 1
      packages/app/src/components/Icons/AttachmentIcon.jsx
  27. 2 1
      packages/app/src/components/Icons/HistoryIcon.jsx
  28. 1 1
      packages/app/src/components/Icons/ShareLinkIcon.jsx
  29. 24 11
      packages/app/src/components/IdenticalPathPage.tsx
  30. 63 16
      packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  31. 1 1
      packages/app/src/components/Navbar/GrowiSubNavigation.tsx
  32. 3 1
      packages/app/src/components/Navbar/GrowiSubNavigationSwitcher.jsx
  33. 12 32
      packages/app/src/components/Navbar/SubNavButtons.tsx
  34. 0 89
      packages/app/src/components/Page/DisplaySwitcher.jsx
  35. 134 0
      packages/app/src/components/Page/DisplaySwitcher.tsx
  36. 2 2
      packages/app/src/components/Page/PageManagement.jsx
  37. 10 18
      packages/app/src/components/Page/TrashPageAlert.jsx
  38. 0 40
      packages/app/src/components/PageAccessories.jsx
  39. 0 160
      packages/app/src/components/PageAccessoriesModal.jsx
  40. 134 0
      packages/app/src/components/PageAccessoriesModal.tsx
  41. 2 3
      packages/app/src/components/PageAccessoriesModalControl.jsx
  42. 70 37
      packages/app/src/components/PageDeleteModal.tsx
  43. 1 1
      packages/app/src/components/SearchPage.jsx
  44. 2 2
      packages/app/src/components/SearchPage/SearchResultContent.tsx
  45. 94 20
      packages/app/src/components/Sidebar.tsx
  46. 15 17
      packages/app/src/components/Sidebar/PageTree/Item.tsx
  47. 50 10
      packages/app/src/components/Sidebar/PageTree/ItemsTree.tsx
  48. 2 2
      packages/app/src/components/TableOfContents.jsx
  49. 3 3
      packages/app/src/components/User/SeenUserInfo.tsx
  50. 2 0
      packages/app/src/interfaces/common.ts
  51. 14 1
      packages/app/src/interfaces/page.ts
  52. 11 0
      packages/app/src/interfaces/ui.ts
  53. 9 0
      packages/app/src/interfaces/user-group-response.ts
  54. 2 2
      packages/app/src/server/models/obsolete-page.js
  55. 6 0
      packages/app/src/server/models/page-redirect.ts
  56. 66 4
      packages/app/src/server/models/page.ts
  57. 14 1
      packages/app/src/server/routes/apiv3/import.js
  58. 8 0
      packages/app/src/server/routes/apiv3/overwrite-params/pages.js
  59. 13 3
      packages/app/src/server/routes/apiv3/page.js
  60. 65 4
      packages/app/src/server/routes/apiv3/pages.js
  61. 94 41
      packages/app/src/server/routes/apiv3/user-group.js
  62. 17 10
      packages/app/src/server/routes/page.js
  63. 1 1
      packages/app/src/server/service/config-loader.ts
  64. 12 0
      packages/app/src/server/service/import.js
  65. 1 0
      packages/app/src/server/service/installer.ts
  66. 85 41
      packages/app/src/server/service/page.ts
  67. 1 1
      packages/app/src/server/service/user-group.ts
  68. 16 18
      packages/app/src/server/util/importer.js
  69. 3 0
      packages/app/src/server/views/layout/layout.html
  70. 14 9
      packages/app/src/stores/page.tsx
  71. 90 11
      packages/app/src/stores/ui.tsx
  72. 29 3
      packages/app/src/stores/user-group.tsx
  73. 59 0
      packages/app/src/styles/_mixins.scss
  74. 2 9
      packages/app/src/styles/_page-accessories-control.scss
  75. 0 3
      packages/app/src/styles/_sidebar.scss
  76. 11 0
      packages/app/src/styles/_subnav.scss
  77. 0 1
      packages/app/src/styles/_toc.scss
  78. 18 0
      packages/app/src/styles/atoms/_buttons.scss
  79. 0 15
      packages/app/src/styles/theme/_apply-colors.scss
  80. 0 59
      packages/app/src/styles/theme/_reboot-bootstrap-theme-colors.scss
  81. 3 3
      packages/app/test/integration/service/v5-migration.test.js
  82. 33 3
      packages/core/src/test/util/page-path-utils.test.js
  83. 27 0
      packages/core/src/utils/page-path-utils.ts
  84. 38 105
      yarn.lock

+ 9 - 3
.devcontainer/docker-compose.yml

@@ -40,6 +40,7 @@ services:
     build:
       context: ../../growi-docker-compose/elasticsearch
       dockerfile: ./Dockerfile
+    container_name: elasticsearch
     restart: unless-stopped
     ports:
       - 9200:9200
@@ -54,11 +55,16 @@ services:
       - /usr/share/elasticsearch/data
       - ../../growi-docker-compose/elasticsearch/config/elasticsearch.yml:/usr/share/elasticsearch/config/elasticsearch.yml
 
-  elasticsearch-head:
-    image: tobias74/elasticsearch-head:6
+  #need to adjust kibana version based on elasticsearch version
+  kibana:
+    image: docker.elastic.co/kibana/kibana:6.8.0
     restart: unless-stopped
+    environment:
+      ELASTICSEARCH_HOSTS: 'http://elasticsearch:9200'
     ports:
-      - 9100:9100
+      - 5601:5601
+    depends_on:
+      - elasticsearch
 
   # This container requires '../../growi-docker-compose' repository
   #   cloned from https://github.com/weseek/growi-docker-compose.git

+ 18 - 1
CHANGELOG.md

@@ -1,9 +1,26 @@
 # Changelog
 
-## [Unreleased](https://github.com/weseek/growi/compare/v4.5.11...HEAD)
+## [Unreleased](https://github.com/weseek/growi/compare/v4.5.13...HEAD)
 
 *Please do not manually update this file. We've automated the process.*
 
+## [v4.5.13](https://github.com/weseek/growi/compare/v4.5.12...v4.5.13) - 2022-02-08
+
+### 🐛 Bug Fixes
+
+- fix: fix: Sidebar collapsing (#5283) @yuki-takei
+
+## [v4.5.12](https://github.com/weseek/growi/compare/v4.5.11...v4.5.12) - 2022-02-01
+
+### 🚀 Improvement
+
+- imprv: Sidebar opening delay (for v4.5.x) (#5218) @yuki-takei
+
+### 🐛 Bug Fixes
+
+- fix: /_api/v3/page with pageId param occurs an 500 error (#5212) @yuki-takei
+- fix: Resolving OIDC issure host (#5220) @yuki-takei
+
 ## [v4.5.11](https://github.com/weseek/growi/compare/v4.5.10...v4.5.11) - 2022-01-26
 
 ### 🐛 Bug Fixes

+ 0 - 2
packages/app/.env.development

@@ -13,8 +13,6 @@ MONGO_URI="mongodb://mongo:27017/growi"
 # NCHAN_URI="http://nchan"
 ELASTICSEARCH_URI="http://elasticsearch:9200/growi"
 ELASTICSEARCH_REQUEST_TIMEOUT=15000
-ELASTICSEARCH_REJECT_UNAUTHORIZED=false
-USE_ELASTICSEARCH_V6=false
 HACKMD_URI="http://localhost:3010"
 HACKMD_URI_FOR_SERVER="http://hackmd:3000"
 # DRAWIO_URI="http://localhost:8080/?offline=1&https=0"

+ 2 - 2
packages/app/docker/README.md

@@ -12,8 +12,8 @@ Supported tags and respective Dockerfile links
 
 * [`5.0.0`, `5.0`, `5`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.0/docker/Dockerfile)
 * [`5.0.0-nocdn`, `5.0-nocdn`, `5-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.0/docker/Dockerfile)
-* [`4.5.11`, `4.5`, `4`, (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.11/docker/Dockerfile)
-* [`4.5.11-nocdn`, `4.5-nocdn`, `4-nocdn`, (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.11/docker/Dockerfile)
+* [`4.5.13`, `4.5`, `4`, (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.13/docker/Dockerfile)
+* [`4.5.13-nocdn`, `4.5-nocdn`, `4-nocdn`, (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.13/docker/Dockerfile)
 * [`4.4.13`, `4.4` (Dockerfile)](https://github.com/weseek/growi/blob/v4.4.13/docker/Dockerfile)
 * [`4.4.13-nocdn`, `4.4-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.4.13/docker/Dockerfile)
 

+ 1 - 1
packages/app/package.json

@@ -93,7 +93,7 @@
     "@elastic/elasticsearch7": "npm:@elastic/elasticsearch@^7.16.0",
     "diff_match_patch": "^0.1.1",
     "entities": "^2.0.0",
-    "esa-nodejs": "^0.0.7",
+    "esa-node": "^0.2.2",
     "escape-string-regexp": "=4.0.0",
     "eslint-plugin-regex": "^1.8.0",
     "express": "^4.16.1",

+ 5 - 0
packages/app/resource/locales/en_US/admin/admin.json

@@ -189,6 +189,9 @@
     "beta_warning": "This function is Beta.",
     "import_from": "Import from {{from}}",
     "import_growi_archive": "Import GROWI archive",
+    "error": {
+      "only_upsert_available": "Only 'Upsert' option is available for pages collection."
+    },
     "growi_settings": {
       "description_of_import_mode": {
         "about": "When you import data with the same name as an existing one, choose from the following three modes below.",
@@ -451,6 +454,7 @@
   },
   "user_group_management": {
     "create_group": "Create new group",
+    "add_child_group": "Add child group",
     "deny_create_group": "You can't create a new group with the current settings.",
     "group_name": "Group name",
     "group_example": "e.g. : Group1",
@@ -463,6 +467,7 @@
       "backward_match": "Backward match"
     },
     "group_list": "Group list",
+    "child_group_list": "Child group list",
     "back_to_list": "Go back to group list",
     "basic_info": "Basic info",
     "user_list": "User list",

+ 5 - 0
packages/app/resource/locales/ja_JP/admin/admin.json

@@ -207,6 +207,9 @@
     "beta_warning": "この機能はベータ版です",
     "import_from": "{{from}} からインポート",
     "import_growi_archive": "GROWI アーカイブをインポート",
+    "error": {
+      "only_upsert_available": "pages コレクションには 'Upsert' オプションのみ対応しています"
+    },
     "growi_settings": {
       "description_of_import_mode": {
         "about": "既存のデータと同名であるデータをインポートする際の挙動は以下の3つのモードから選べます。",
@@ -450,6 +453,7 @@
   },
   "user_group_management": {
     "create_group": "新規グループの作成",
+    "add_child_group": "子グループの追加",
     "deny_create_group": "新規グループの作成はできません。",
     "group_name": "グループ名",
     "group_example": "例: Group1",
@@ -462,6 +466,7 @@
       "backward_match": "後方一致"
     },
     "group_list": "グループ一覧",
+    "child_group_list": "子グループ一覧",
     "back_to_list": "グループ一覧に戻る",
     "basic_info": "基本情報",
     "user_list": "ユーザー一覧",

+ 5 - 0
packages/app/resource/locales/zh_CN/admin/admin.json

@@ -199,6 +199,9 @@
     "beta_warning": "这个函数是Beta。",
     "import_from": "Import from {{from}}",
     "import_growi_archive": "Import GROWI archive",
+    "error": {
+      "only_upsert_available": "Only 'Upsert' option is available for pages collection."
+    },
     "growi_settings": {
       "description_of_import_mode": {
         "about": "When you import data with the same name as an existing one, choose from the following three modes below.",
@@ -460,6 +463,7 @@
   },
   "user_group_management": {
     "create_group": "创建新组",
+    "add_child_group": "添加一个子组",
     "deny_create_group": "不能用当前设置创建新组。",
     "group_name": "组名",
     "group_example": "e.g.:第1组",
@@ -472,6 +476,7 @@
       "backward_match": "向后匹配"
     },
     "group_list": "组列表",
+    "child_group_list": "儿童组名单",
     "back_to_list": "返回组列表",
     "basic_info": "基本信息",
     "user_list": "用户列表",

+ 7 - 7
packages/app/src/client/app.jsx

@@ -51,9 +51,9 @@ import CommentContainer from '~/client/services/CommentContainer';
 import EditorContainer from '~/client/services/EditorContainer';
 import TagContainer from '~/client/services/TagContainer';
 import PersonalContainer from '~/client/services/PersonalContainer';
-import PageAccessoriesContainer from '~/client/services/PageAccessoriesContainer';
 
 import { appContainer, componentMappings } from './base';
+import { toastError } from './util/apiNotification';
 
 const logger = loggerFactory('growi:cli:app');
 
@@ -70,10 +70,9 @@ const commentContainer = new CommentContainer(appContainer);
 const editorContainer = new EditorContainer(appContainer, defaultEditorOptions, defaultPreviewOptions);
 const tagContainer = new TagContainer(appContainer);
 const personalContainer = new PersonalContainer(appContainer);
-const pageAccessoriesContainer = new PageAccessoriesContainer(appContainer);
 const injectableContainers = [
   appContainer, socketIoContainer, pageContainer, pageHistoryContainer, revisionComparerContainer,
-  commentContainer, editorContainer, tagContainer, personalContainer, pageAccessoriesContainer,
+  commentContainer, editorContainer, tagContainer, personalContainer,
 ];
 
 logger.info('unstated containers have been initialized');
@@ -101,7 +100,7 @@ Object.assign(componentMappings, {
 
   'not-found-page': <NotFoundPage />,
 
-  'forbidden-page': <ForbiddenPage isSharePage={appContainer.config.disableLinkSharing} />,
+  'forbidden-page': <ForbiddenPage isLinkSharingDisabled={appContainer.config.disableLinkSharing} />,
 
   'page-timeline': <PageTimeline />,
 
@@ -133,7 +132,8 @@ if (pageContainer.state.pageId != null) {
 
   // show the Page accessory modal when query of "compare" is requested
   if (revisionComparerContainer.getRevisionIDsToCompareAsParam().length > 0) {
-    pageAccessoriesContainer.openPageAccessoriesModal('pageHistory');
+    toastError('Sorry, opening PageAccessoriesModal is not implemented yet in v5.');
+  //   pageAccessoriesContainer.openPageAccessoriesModal('pageHistory');
   }
 }
 if (pageContainer.state.creator != null) {
@@ -146,8 +146,8 @@ if (pageContainer.state.path != null) {
   Object.assign(componentMappings, {
     // eslint-disable-next-line quote-props
     'page': <Page />,
-    'grw-subnav-container': <GrowiContextualSubNavigation />,
-    'grw-subnav-switcher-container': <GrowiSubNavigationSwitcher />,
+    'grw-subnav-container': <GrowiContextualSubNavigation isLinkSharingDisabled={appContainer.config.disableLinkSharing} />,
+    'grw-subnav-switcher-container': <GrowiSubNavigationSwitcher isLinkSharingDisabled={appContainer.config.disableLinkSharing} />,
     'display-switcher': <DisplaySwitcher />,
   });
 }

+ 4 - 0
packages/app/src/client/base.jsx

@@ -10,9 +10,11 @@ import PageCreateModal from '../components/PageCreateModal';
 import PageDeleteModal from '../components/PageDeleteModal';
 import PageDuplicateModal from '../components/PageDuplicateModal';
 import PageRenameModal from '../components/PageRenameModal';
+import PageAccessoriesModal from '../components/PageAccessoriesModal';
 
 import AppContainer from '~/client/services/AppContainer';
 import SocketIoContainer from '~/client/services/SocketIoContainer';
+import { DescendantsPageListModal } from '~/components/DescendantsPageListModal';
 
 const logger = loggerFactory('growi:cli:app');
 
@@ -46,6 +48,8 @@ const componentMappings = {
   'page-delete-modal': <PageDeleteModal />,
   'page-duplicate-modal': <PageDuplicateModal />,
   'page-rename-modal': <PageRenameModal />,
+  'page-accessories-modal': <PageAccessoriesModal />,
+  'descendants-page-list-modal': <DescendantsPageListModal />,
 
   'grw-hotkeys-manager': <HotkeysManager />,
 

+ 0 - 54
packages/app/src/client/services/PageAccessoriesContainer.js

@@ -1,54 +0,0 @@
-import { Container } from 'unstated';
-
-/**
- * Service container related to options for Application
- * @extends {Container} unstated Container
- */
-
-export default class PageAccessoriesContainer extends Container {
-
-  constructor(appContainer) {
-    super();
-
-    this.appContainer = appContainer;
-
-    this.state = {
-      isPageAccessoriesModalShown: false,
-      activeTab: '',
-      // Prevent unnecessary rendering
-      activeComponents: new Set(['']),
-    };
-    this.openPageAccessoriesModal = this.openPageAccessoriesModal.bind(this);
-    this.closePageAccessoriesModal = this.closePageAccessoriesModal.bind(this);
-    this.switchActiveTab = this.switchActiveTab.bind(this);
-  }
-
-  /**
-   * Workaround for the mangling in production build to break constructor.name
-   */
-  static getClassName() {
-    return 'PageAccessoriesContainer';
-  }
-
-
-  openPageAccessoriesModal(activeTab) {
-    this.setState({
-      isPageAccessoriesModalShown: true,
-    });
-    this.switchActiveTab(activeTab);
-  }
-
-  closePageAccessoriesModal() {
-    this.setState({
-      isPageAccessoriesModalShown: false,
-      activeTab: '',
-    });
-  }
-
-  switchActiveTab(activeTab) {
-    this.setState({
-      activeTab, activeComponents: this.state.activeComponents.add(activeTab),
-    });
-  }
-
-}

+ 0 - 65
packages/app/src/client/services/PageContainer.js

@@ -138,71 +138,6 @@ export default class PageContainer extends Container {
     return 'PageContainer';
   }
 
-
-  /**
-   * whether to display reaction buttons
-   * ex.) like, bookmark
-   */
-  get isAbleToShowPageReactionButtons() {
-    const { isTrashPage, isPageExist } = this.state;
-    const { isSharedUser } = this.appContainer;
-
-    return (!isTrashPage && isPageExist && !isSharedUser);
-  }
-
-  /**
-   * whether to display tag labels
-   */
-  get isAbleToShowTagLabel() {
-    const { isUserPage } = this.state;
-    const { isSharedUser } = this.appContainer;
-
-    return (!isUserPage && !isSharedUser);
-  }
-
-  /**
-   * whether to display page management
-   * ex.) duplicate, rename
-   */
-  get isAbleToShowPageManagement() {
-    const { isPageExist, isTrashPage } = this.state;
-    const { isSharedUser } = this.appContainer;
-
-    return (isPageExist && !isTrashPage && !isSharedUser);
-  }
-
-  /**
-   * whether to display pageEditorModeManager
-   * ex.) view, edit, hackmd
-   */
-  get isAbleToShowPageEditorModeManager() {
-    const { isNotCreatable, isTrashPage } = this.state;
-    const { isSharedUser } = this.appContainer;
-
-    return (!isNotCreatable && !isTrashPage && !isSharedUser);
-  }
-
-  /**
-   * whether to display pageAuthors
-   * ex.) creator, lastUpdateUser
-   */
-  get isAbleToShowPageAuthors() {
-    const { isPageExist, isUserPage } = this.state;
-
-    return (isPageExist && !isUserPage);
-  }
-
-  /**
-   * whether to like button
-   * not displayed on user page
-   */
-  get isAbleToShowLikeButtons() {
-    const { isUserPage } = this.state;
-    const { isSharedUser } = this.appContainer;
-
-    return (!isUserPage && !isSharedUser);
-  }
-
   /**
    * whether to Empty Trash Page
    * not displayed when guest user and not on trash page

+ 2 - 2
packages/app/src/client/util/smooth-scroll.ts

@@ -1,6 +1,6 @@
 const WIKI_HEADER_LINK = 120;
 
-export const smoothScrollIntoView = (element: HTMLElement, offsetTop = 0): void => {
+export const smoothScrollIntoView = (element: HTMLElement, offsetTop = 0, scrollElement: HTMLElement | Window = window): void => {
   const targetElement = element || window.document.body;
 
   // get the distance to the target element top
@@ -8,7 +8,7 @@ export const smoothScrollIntoView = (element: HTMLElement, offsetTop = 0): void
 
   const top = window.pageYOffset + rectTop - offsetTop;
 
-  window.scrollTo({
+  scrollElement.scrollTo({
     top,
     behavior: 'smooth',
   });

+ 6 - 1
packages/app/src/components/Admin/ImportData/GrowiArchive/ImportForm.jsx

@@ -287,7 +287,9 @@ class ImportForm extends React.Component {
   }
 
   async import() {
-    const { appContainer, fileName, onPostImport } = this.props;
+    const {
+      appContainer, fileName, onPostImport, t,
+    } = this.props;
     const { selectedCollections, optionsMap } = this.state;
 
     // init progress data
@@ -312,6 +314,9 @@ class ImportForm extends React.Component {
       toastSuccess(undefined, 'Import process has requested.');
     }
     catch (err) {
+      if (err.code === 'only_upsert_available') {
+        toastError(t('admin:importer_management.error.only_upsert_available'));
+      }
       toastError(err, 'Import request failed.');
     }
   }

+ 70 - 0
packages/app/src/components/Admin/UserGroup/UserGroupDropdown.tsx

@@ -0,0 +1,70 @@
+import React, { FC, useCallback } from 'react';
+import { useTranslation } from 'react-i18next';
+
+import { IUserGroupHasId } from '~/interfaces/user';
+
+type Props = {
+  selectableUserGroups?: IUserGroupHasId[]
+  onClickAddExistingUserGroupButtonHandler?(userGroup: IUserGroupHasId | null): void
+  onClickCreateUserGroupButtonHandler?(): void
+};
+
+const UserGroupDropdown: FC<Props> = (props: Props) => {
+  const { t } = useTranslation();
+
+  const { selectableUserGroups, onClickAddExistingUserGroupButtonHandler, onClickCreateUserGroupButtonHandler } = props;
+
+  const onClickAddExistingUserGroupButton = useCallback((userGroup: IUserGroupHasId) => {
+    if (onClickAddExistingUserGroupButtonHandler != null) {
+      onClickAddExistingUserGroupButtonHandler(userGroup);
+    }
+  }, [onClickAddExistingUserGroupButtonHandler]);
+
+  const onClickCreateUserGroupButton = useCallback(() => {
+    if (onClickCreateUserGroupButtonHandler != null) {
+      onClickCreateUserGroupButtonHandler();
+    }
+  }, [onClickCreateUserGroupButtonHandler]);
+
+  return (
+    <>
+      <div className="dropdown">
+        <button className="btn btn-secondary dropdown-toggle" type="button" id="dropdownMenuButton" data-toggle="dropdown">
+          {t('admin:user_group_management.add_child_group')}
+        </button>
+
+        <div className="dropdown-menu" aria-labelledby="dropdownMenuButton">
+
+          {
+            (selectableUserGroups != null && selectableUserGroups.length > 0) && (
+              <>
+                {
+                  selectableUserGroups.map(userGroup => (
+                    <button
+                      key={userGroup._id}
+                      type="button"
+                      className="dropdown-item"
+                      onClick={() => onClickAddExistingUserGroupButton(userGroup)}
+                    >
+                      {userGroup.name}
+                    </button>
+                  ))
+                }
+                <div className="dropdown-divider"></div>
+              </>
+            )
+          }
+
+          <button
+            className="dropdown-item"
+            type="button"
+            onClick={() => onClickCreateUserGroupButton()}
+          >{t('admin:user_group_management.create_group')}
+          </button>
+        </div>
+      </div>
+    </>
+  );
+};
+
+export default UserGroupDropdown;

+ 3 - 3
packages/app/src/components/Admin/UserGroup/UserGroupPage.tsx

@@ -44,7 +44,7 @@ const UserGroupPage: FC<Props> = (props: Props) => {
    */
   const syncUserGroupAndRelations = useCallback(async() => {
     try {
-      await mutateUserGroups(undefined, true);
+      await mutateUserGroups();
     }
     catch (err) {
       toastError(err);
@@ -77,7 +77,7 @@ const UserGroupPage: FC<Props> = (props: Props) => {
       });
 
       // sync
-      await mutateUserGroups(undefined, true);
+      await mutateUserGroups();
     }
     catch (err) {
       toastError(err);
@@ -92,7 +92,7 @@ const UserGroupPage: FC<Props> = (props: Props) => {
       });
 
       // sync
-      await mutateUserGroups(undefined, true);
+      await mutateUserGroups();
 
       setSelectedUserGroup(undefined);
       setDeleteModalShown(false);

+ 45 - 34
packages/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx

@@ -1,9 +1,10 @@
 import React, {
-  FC, useState, useCallback, useEffect,
+  FC, useState, useCallback,
 } from 'react';
 import { useTranslation } from 'react-i18next';
 
 import UserGroupForm from '../UserGroup/UserGroupForm';
+import UserGroupDropdown from '../UserGroup/UserGroupDropdown';
 import UserGroupUserTable from './UserGroupUserTable';
 import UserGroupUserModal from './UserGroupUserModal';
 import UserGroupPageList from './UserGroupPageList';
@@ -12,11 +13,13 @@ import AppContainer from '~/client/services/AppContainer';
 import {
   apiv3Get, apiv3Put, apiv3Delete, apiv3Post,
 } from '~/client/util/apiv3-client';
-import { toastError } from '~/client/util/apiNotification';
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { IPageHasId } from '~/interfaces/page';
 import {
-  IUserGroup, IUserGroupHasId, IUserGroupRelation, IUserGroupRelationHasId,
+  IUserGroup, IUserGroupHasId, IUserGroupRelation,
 } from '~/interfaces/user';
+import { useSWRxUserGroupPages, useSWRxUserGroupRelations, useSWRxSelectableUserGroups } from '~/stores/user-group';
+
 
 const UserGroupDetailPage: FC = () => {
   const rootElem = document.getElementById('admin-user-group-detail');
@@ -26,7 +29,6 @@ const UserGroupDetailPage: FC = () => {
    * State (from AdminUserGroupDetailContainer)
    */
   const [userGroup, setUserGroup] = useState<IUserGroupHasId>(JSON.parse(rootElem?.getAttribute('data-user-group') || 'null'));
-  const [userGroupRelations, setUserGroupRelations] = useState<IUserGroupRelationHasId[]>([]); // For user list
 
   // TODO 85062: /_api/v3/user-groups/children?include_grand_child=boolean
   const [childUserGroups, setChildUserGroups] = useState<IUserGroupHasId[]>([]); // TODO 85062: fetch data on init (findChildGroupsByParentIds) For child group list
@@ -40,26 +42,15 @@ const UserGroupDetailPage: FC = () => {
   const [isAlsoNameSearched, setAlsoNameSearched] = useState<boolean>(false);
 
   /*
-   * Function
+   * Fetch
    */
-  const sync = useCallback(async() => {
-    try {
-      const [
-        userGroupRelations,
-        relatedPages,
-      ] = await Promise.all([
-        apiv3Get(`/user-groups/${userGroup._id}/user-group-relations`).then(res => res.data.userGroupRelations),
-        apiv3Get(`/user-groups/${userGroup._id}/pages`).then(res => res.data.pages),
-      ]);
-
-      setUserGroupRelations(userGroupRelations);
-      setRelatedPages(relatedPages);
-    }
-    catch (err) {
-      toastError(new Error('Failed to fetch data'));
-    }
-  }, [userGroup]);
+  const { data: userGroupPages } = useSWRxUserGroupPages(userGroup._id, 10, 0);
+  const { data: userGroupRelations, mutate: mutateUserGroupRelations } = useSWRxUserGroupRelations(userGroup._id);
+  const { data: selectableUserGroups, mutate: mutateSelectableUserGroups } = useSWRxSelectableUserGroups(userGroup._id);
 
+  /*
+   * Function
+   */
   // TODO 85062: old name: switchIsAlsoMailSearched
   const toggleIsAlsoMailSearched = useCallback(() => {
     setAlsoMailSearched(prev => !prev);
@@ -107,22 +98,34 @@ const UserGroupDetailPage: FC = () => {
   // TODO 85062: will be used in UserGroupUserFormByInput
   const addUserByUsername = useCallback(async(username: string) => {
     await apiv3Post(`/user-groups/${userGroup._id}/users/${username}`);
-
-    await sync();
-  }, [userGroup, sync]);
+    mutateUserGroupRelations();
+  }, [userGroup, mutateUserGroupRelations]);
 
   const removeUserByUsername = useCallback(async(username: string) => {
-    const res = await apiv3Delete(`/user-groups/${userGroup._id}/users/${username}`);
+    await apiv3Delete(`/user-groups/${userGroup._id}/users/${username}`);
+    mutateUserGroupRelations();
+  }, [userGroup, mutateUserGroupRelations]);
 
-    setUserGroupRelations(prev => prev.filter(u => u._id !== res.data.userGroupRelation._id)); // TODO 85062: use swr to sync
-  }, [userGroup]);
+  const onClickAddChildButtonHandler = async(selectedUserGroup: IUserGroupHasId) => {
+    try {
+      await apiv3Put(`/user-groups/${selectedUserGroup._id}`, {
+        name: selectedUserGroup.name,
+        description: selectedUserGroup.description,
+        parentId: userGroup._id,
+        forceUpdateParents: false, //  TODO 87748: Make forceUpdateParents optionally selectable
+      });
+      mutateSelectableUserGroups();
+      toastSuccess(t('toaster.update_successed', { target: t('UserGroup') }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  };
 
-  /*
-   * componentDidMount
-   */
-  useEffect(() => {
-    sync();
-  }, []);
+  // TODO 87614: UserGroup New creation form can be displayed in modal
+  const onClickCreateChildGroupButtonHandler = () => {
+    console.log('button clicked!');
+  };
 
   /*
    * Dependencies
@@ -150,6 +153,14 @@ const UserGroupDetailPage: FC = () => {
       <h2 className="admin-setting-header mt-4">{t('admin:user_group_management.user_list')}</h2>
       <UserGroupUserTable />
       <UserGroupUserModal />
+
+      <h2 className="admin-setting-header mt-4">{t('admin:user_group_management.child_group_list')}</h2>
+      <UserGroupDropdown
+        selectableUserGroups={selectableUserGroups}
+        onClickAddExistingUserGroupButtonHandler={onClickAddChildButtonHandler}
+        onClickCreateUserGroupButtonHandler={() => onClickCreateChildGroupButtonHandler()}
+      />
+
       <h2 className="admin-setting-header mt-4">{t('Page')}</h2>
       <div className="page-list">
         <UserGroupPageList />

+ 19 - 12
packages/app/src/components/Common/ClosableTextInput.tsx

@@ -31,25 +31,31 @@ const ClosableTextInput: FC<ClosableTextInputProps> = memo((props: ClosableTextI
   const [inputText, setInputText] = useState(props.value);
   const [currentAlertInfo, setAlertInfo] = useState<AlertInfo | null>(null);
 
-  const onChangeHandler = async(e) => {
-    if (props.inputValidator == null) { return }
+  const createValidation = async(inputText: string) => {
+    if (props.inputValidator != null) {
+      const alertInfo = await props.inputValidator(inputText);
+      setAlertInfo(alertInfo);
+    }
+  };
 
+  const onChangeHandler = async(e: React.ChangeEvent<HTMLInputElement>) => {
     const inputText = e.target.value;
-
-    const alertInfo = await props.inputValidator(inputText);
-
-    setAlertInfo(alertInfo);
+    createValidation(inputText);
     setInputText(inputText);
   };
 
+  const onFocusHandler = async(e: React.ChangeEvent<HTMLInputElement>) => {
+    const inputText = e.target.value;
+    await createValidation(inputText);
+  };
+
   const onPressEnter = () => {
-    if (props.onPressEnter == null) {
-      return;
+    if (props.onPressEnter != null) {
+      const text = inputText != null ? inputText.trim() : null;
+      if (currentAlertInfo == null) {
+        props.onPressEnter(text);
+      }
     }
-
-    const text = inputText != null ? inputText.trim() : null;
-
-    props.onPressEnter(text);
   };
 
   const onKeyDownHandler = (e) => {
@@ -107,6 +113,7 @@ const ClosableTextInput: FC<ClosableTextInputProps> = memo((props: ClosableTextI
         className="form-control"
         placeholder={props.placeholder}
         name="input"
+        onFocus={onFocusHandler}
         onChange={onChangeHandler}
         onKeyDown={onKeyDownHandler}
         onBlur={onBlurHandler}

+ 0 - 96
packages/app/src/components/ContentLinkButtons.jsx

@@ -1,96 +0,0 @@
-import React, { useMemo } from 'react';
-import PropTypes from 'prop-types';
-
-import { pagePathUtils } from '@growi/core';
-import AppContainer from '~/client/services/AppContainer';
-import PageContainer from '~/client/services/PageContainer';
-
-import { withUnstatedContainers } from './UnstatedUtils';
-
-import RecentlyCreatedIcon from './Icons/RecentlyCreatedIcon';
-import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
-
-const { isTopPage } = pagePathUtils;
-
-const WIKI_HEADER_LINK = 120;
-
-/**
- * @author Yuki Takei <yuki@weseek.co.jp>
- *
- */
-const ContentLinkButtons = (props) => {
-
-  const { appContainer, pageContainer } = props;
-  const { pageUser, path } = pageContainer.state;
-  const { isPageExist } = pageContainer.state;
-  const { isSharedUser } = appContainer;
-
-  const isTopPagePath = isTopPage(path);
-
-  // get element for smoothScroll
-  const getCommentListDom = useMemo(() => { return document.getElementById('page-comments-list') }, []);
-  const getBookMarkListHeaderDom = useMemo(() => { return document.getElementById('bookmarks-list') }, []);
-  const getRecentlyCreatedListHeaderDom = useMemo(() => { return document.getElementById('recently-created-list') }, []);
-
-
-  const CommentLinkButton = () => {
-    return (
-      <div className="mt-3">
-        <button
-          type="button"
-          className="btn btn-outline-secondary btn-sm btn-block"
-          onClick={() => smoothScrollIntoView(getCommentListDom, WIKI_HEADER_LINK)}
-        >
-          <i className="mr-2 icon-fw icon-bubbles"></i>
-          <span>Comments</span>
-        </button>
-      </div>
-    );
-  };
-
-  const BookMarkLinkButton = () => {
-    return (
-      <button
-        type="button"
-        className="btn btn-outline-secondary btn-sm px-2"
-        onClick={() => smoothScrollIntoView(getBookMarkListHeaderDom, WIKI_HEADER_LINK)}
-      >
-        <i className="fa fa-fw fa-bookmark-o"></i>
-        <span>Bookmarks</span>
-      </button>
-
-    );
-  };
-
-  const RecentlyCreatedLinkButton = () => {
-    return (
-      <button
-        type="button"
-        className="btn btn-outline-secondary btn-sm px-3"
-        onClick={() => smoothScrollIntoView(getRecentlyCreatedListHeaderDom, WIKI_HEADER_LINK)}
-      >
-        <i className="grw-icon-container-recently-created mr-2"><RecentlyCreatedIcon /></i>
-        <span>Recently Created</span>
-      </button>
-
-    );
-  };
-
-  return (
-    <>
-      {isPageExist && !isSharedUser && !isTopPagePath && <CommentLinkButton />}
-
-      <div className="mt-3 d-flex justify-content-between">
-        {pageUser && <><BookMarkLinkButton /><RecentlyCreatedLinkButton /></>}
-      </div>
-    </>
-  );
-
-};
-
-ContentLinkButtons.propTypes = {
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
-};
-
-export default withUnstatedContainers(ContentLinkButtons, [AppContainer, PageContainer]);

+ 66 - 0
packages/app/src/components/ContentLinkButtons.tsx

@@ -0,0 +1,66 @@
+import React, { useCallback, useMemo } from 'react';
+
+import RecentlyCreatedIcon from './Icons/RecentlyCreatedIcon';
+import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
+import { usePageUser } from '~/stores/context';
+
+const WIKI_HEADER_LINK = 120;
+
+
+const ContentLinkButtons = (): JSX.Element => {
+
+  const { data: pageUser } = usePageUser();
+
+  // get element for smoothScroll
+  const getBookMarkListHeaderDom = useMemo(() => { return document.getElementById('bookmarks-list') }, []);
+  const getRecentlyCreatedListHeaderDom = useMemo(() => { return document.getElementById('recently-created-list') }, []);
+
+
+  const BookMarkLinkButton = useCallback((): JSX.Element => {
+    if (getBookMarkListHeaderDom == null) {
+      return <></>;
+    }
+
+    return (
+      <button
+        type="button"
+        className="btn btn-outline-secondary btn-sm px-2"
+        onClick={() => smoothScrollIntoView(getBookMarkListHeaderDom, WIKI_HEADER_LINK)}
+      >
+        <i className="fa fa-fw fa-bookmark-o"></i>
+        <span>Bookmarks</span>
+      </button>
+    );
+  }, [getBookMarkListHeaderDom]);
+
+  const RecentlyCreatedLinkButton = useCallback(() => {
+    if (getRecentlyCreatedListHeaderDom == null) {
+      return <></>;
+    }
+
+    return (
+      <button
+        type="button"
+        className="btn btn-outline-secondary btn-sm px-3"
+        onClick={() => smoothScrollIntoView(getRecentlyCreatedListHeaderDom, WIKI_HEADER_LINK)}
+      >
+        <i className="grw-icon-container-recently-created mr-2"><RecentlyCreatedIcon /></i>
+        <span>Recently Created</span>
+      </button>
+    );
+  }, [getRecentlyCreatedListHeaderDom]);
+
+  if (pageUser == null) {
+    return <></>;
+  }
+
+  return (
+    <div className="mt-3 d-flex justify-content-between">
+      <BookMarkLinkButton />
+      <RecentlyCreatedLinkButton />
+    </div>
+  );
+
+};
+
+export default ContentLinkButtons;

+ 22 - 4
packages/app/src/components/CustomNavigation/CustomTabContent.jsx → packages/app/src/components/CustomNavigation/CustomTabContent.tsx

@@ -1,22 +1,40 @@
-import React from 'react';
+import React, { useEffect, useState } from 'react';
 import PropTypes from 'prop-types';
 import {
   TabContent, TabPane,
 } from 'reactstrap';
 
-const CustomTabContent = (props) => {
+import { ICustomNavTabMappings } from '~/interfaces/ui';
+
+
+type Props = {
+  activeTab: string,
+  navTabMapping: ICustomNavTabMappings,
+  additionalClassNames?: string[],
+
+}
+
+const CustomTabContent = (props: Props): JSX.Element => {
 
   const { activeTab, navTabMapping, additionalClassNames } = props;
 
+  const [activatedContent, setActivatedContent] = useState<Set<string>>(new Set<string>());
+
+  // add activated content to Set
+  useEffect(() => {
+    setActivatedContent(activatedContent.add(activeTab));
+  }, [activatedContent, activeTab]);
+
   return (
-    <TabContent activeTab={activeTab} className={additionalClassNames.join(' ')}>
+    <TabContent activeTab={activeTab} className={additionalClassNames != null ? additionalClassNames.join(' ') : ''}>
       {Object.entries(navTabMapping).map(([key, value]) => {
 
+        const shouldRender = key === activeTab || activatedContent.has(key);
         const { Content } = value;
 
         return (
           <TabPane key={key} tabId={key}>
-            <Content />
+            { shouldRender && <Content /> }
           </TabPane>
         );
       })}

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

@@ -3,7 +3,7 @@ import {
   IPageHasId, IPageWithMeta,
 } from '~/interfaces/page';
 import { IPagingResult } from '~/interfaces/paging-result';
-import { useIsGuestUser } from '~/stores/context';
+import { useIsGuestUser, useIsSharedUser } from '~/stores/context';
 
 import { useSWRxPageInfoForList, useSWRxPageList } from '~/stores/page';
 
@@ -25,8 +25,9 @@ const DescendantsPageList = (props: Props): JSX.Element => {
   const [activePage, setActivePage] = useState(1);
 
   const { data: isGuestUser } = useIsGuestUser();
+  const { data: isSharedUser } = useIsSharedUser();
 
-  const { data: pagingResult, error } = useSWRxPageList(path, activePage);
+  const { data: pagingResult, error } = useSWRxPageList(isSharedUser ? null : path, activePage);
 
   const pageIds = pagingResult?.items?.map(page => page._id);
   const { data: idToPageInfo } = useSWRxPageInfoForList(pageIds);

+ 100 - 0
packages/app/src/components/DescendantsPageListModal.tsx

@@ -0,0 +1,100 @@
+
+import React, { useState, useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
+
+import {
+  Modal, ModalHeader, ModalBody,
+} from 'reactstrap';
+
+import { useDescendantsPageListModal } from '~/stores/ui';
+import { useIsSharedUser } from '~/stores/context';
+
+import DescendantsPageList from './DescendantsPageList';
+import ExpandOrContractButton from './ExpandOrContractButton';
+import { CustomNavTab } from './CustomNavigation/CustomNav';
+import PageListIcon from './Icons/PageListIcon';
+import TimeLineIcon from './Icons/TimeLineIcon';
+import CustomTabContent from './CustomNavigation/CustomTabContent';
+import PageTimeline from './PageTimeline';
+
+
+type Props = {
+}
+
+export const DescendantsPageListModal = (props: Props): JSX.Element => {
+  const { t } = useTranslation();
+
+  const [activeTab, setActiveTab] = useState('pagelist');
+  const [isWindowExpanded, setIsWindowExpanded] = useState(false);
+
+  const { data: isSharedUser } = useIsSharedUser();
+
+  const { data: status, close } = useDescendantsPageListModal();
+
+  const navTabMapping = useMemo(() => {
+    return {
+      pagelist: {
+        Icon: PageListIcon,
+        Content: () => {
+          if (status == null || status.path == null || !status.isOpened) {
+            return <></>;
+          }
+          return <DescendantsPageList path={status.path} />;
+        },
+        i18n: t('page_list'),
+        index: 0,
+        isLinkEnabled: () => !isSharedUser,
+      },
+      timeline: {
+        Icon: TimeLineIcon,
+        Content: () => <PageTimeline />,
+        i18n: t('Timeline View'),
+        index: 1,
+        isLinkEnabled: () => !isSharedUser,
+      },
+    };
+  }, [isSharedUser, status, t]);
+
+  const buttons = useMemo(() => (
+    <div className="d-flex flex-nowrap">
+      <ExpandOrContractButton
+        isWindowExpanded={isWindowExpanded}
+        expandWindow={() => setIsWindowExpanded(true)}
+        contractWindow={() => setIsWindowExpanded(false)}
+      />
+      <button type="button" className="close" onClick={close} aria-label="Close">
+        <span aria-hidden="true">&times;</span>
+      </button>
+    </div>
+  ), [close, isWindowExpanded]);
+
+
+  if (status == null) {
+    return <></>;
+  }
+
+  const { isOpened } = status;
+
+  return (
+    <Modal
+      size="xl"
+      isOpen={isOpened}
+      toggle={close}
+      className={`grw-page-accessories-modal ${isWindowExpanded ? 'grw-modal-expanded' : ''} `}
+    >
+      <ModalHeader className="p-0" toggle={close} close={buttons}>
+        <CustomNavTab
+          activeTab={activeTab}
+          navTabMapping={navTabMapping}
+          breakpointToHideInactiveTabsDown="md"
+          onNavSelected={v => setActiveTab(v)}
+          hideBorderBottom
+        />
+      </ModalHeader>
+      <ModalBody>
+        <CustomTabContent activeTab={activeTab} navTabMapping={navTabMapping} />
+      </ModalBody>
+    </Modal>
+  );
+
+};

+ 3 - 3
packages/app/src/components/EventListeneres/HashChanged.tsx

@@ -1,4 +1,4 @@
-import { FC, useCallback, useEffect } from 'react';
+import React, { useCallback, useEffect } from 'react';
 
 import { useEditorMode, determineEditorModeByHash } from '~/stores/ui';
 import { useIsEditable } from '~/stores/context';
@@ -6,7 +6,7 @@ import { useIsEditable } from '~/stores/context';
 /**
  * Change editorMode by browser forward/back operation
  */
-const HashChanged: FC<void> = () => {
+const HashChanged = (): JSX.Element => {
   const { data: isEditable } = useIsEditable();
   const { data: editorMode, mutate: mutateEditorMode } = useEditorMode();
 
@@ -33,7 +33,7 @@ const HashChanged: FC<void> = () => {
 
   }, [hashchangeHandler, isEditable, mutateEditorMode]);
 
-  return null;
+  return <></>;
 };
 
 export default HashChanged;

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

@@ -7,7 +7,7 @@ import DescendantsPageList from './DescendantsPageList';
 
 
 type Props = {
-  isSharePage?: boolean,
+  isLinkSharingDisabled?: boolean,
 }
 
 const ForbiddenPage = React.memo((props: Props): JSX.Element => {
@@ -39,12 +39,12 @@ const ForbiddenPage = React.memo((props: Props): JSX.Element => {
         <div className="col-sm-12">
           <p className="alert alert-primary py-3 px-4">
             <i className="icon-fw icon-lock" aria-hidden="true" />
-            { props.isSharePage ? t('custom_navigation.link_sharing_is_disabled') : t('Browsing of this page is restricted')}
+            { props.isLinkSharingDisabled ? t('custom_navigation.link_sharing_is_disabled') : t('Browsing of this page is restricted')}
           </p>
         </div>
       </div>
 
-      { !props.isSharePage && (
+      { !props.isLinkSharingDisabled && (
         <div className="mt-5">
           <CustomNavAndContents navTabMapping={navTabMapping} />
         </div>

+ 2 - 1
packages/app/src/components/Icons/AttachmentIcon.jsx

@@ -4,7 +4,8 @@ const Attachment = () => (
   <svg
     xmlns="http://www.w3.org/2000/svg"
     viewBox="0 0 14 14"
-
+    width="14px"
+    height="14px"
   >
     <rect width="14" height="14" fillOpacity="0" />
     <g className="cls-1">

+ 2 - 1
packages/app/src/components/Icons/HistoryIcon.jsx

@@ -4,7 +4,8 @@ const RecentChanges = () => (
   <svg
     xmlns="http://www.w3.org/2000/svg"
     viewBox="0 0 14 14"
-
+    width="14px"
+    height="14px"
   >
     <rect width="14" height="14" fillOpacity="0" />
     <path

+ 1 - 1
packages/app/src/components/Icons/ShareLinkIcon.jsx

@@ -1,7 +1,7 @@
 import React from 'react';
 
 const ShareLink = () => (
-  <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20">
+  <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 20 20">
     <g transform="translate(-142 -502)">
       <rect width="20" height="20" transform="translate(142 502)" fill="none" />
       <g transform="translate(16 286.938)">

+ 24 - 11
packages/app/src/components/IdenticalPathPage.tsx

@@ -1,15 +1,15 @@
-import React, {
-  FC,
-} from 'react';
+import React, { FC } from 'react';
 import { useTranslation } from 'react-i18next';
 
 import { DevidedPagePath } from '@growi/core';
 
-import { useCurrentPagePath } from '~/stores/context';
+import { IPageHasId, IPageWithMeta } from '~/interfaces/page';
+import { useCurrentPagePath, useIsSharedUser } from '~/stores/context';
+import { useDescendantsPageListModal } from '~/stores/ui';
+import { useSWRxPageInfoForList } from '~/stores/page';
 
+import PageListIcon from './Icons/PageListIcon';
 import { PageListItemL } from './PageList/PageListItemL';
-import { useSWRxPageInfoForList } from '~/stores/page';
-import { IPageHasId, IPageWithMeta } from '~/interfaces/page';
 
 
 type IdenticalPathAlertProps = {
@@ -55,24 +55,37 @@ type IdenticalPathPageProps= {
 const jsonNull = 'null';
 
 const IdenticalPathPage:FC<IdenticalPathPageProps> = (props: IdenticalPathPageProps) => {
+  const { t } = useTranslation();
 
   const identicalPageDocument = document.getElementById('identical-path-page');
   const pages = JSON.parse(identicalPageDocument?.getAttribute('data-identical-path-pages') || jsonNull) as IPageHasId[];
 
   const pageIds = pages.map(page => page._id) as string[];
 
-  const { data: idToPageInfoMap } = useSWRxPageInfoForList(pageIds);
 
   const { data: currentPath } = useCurrentPagePath();
+  const { data: isSharedUser } = useIsSharedUser();
+
+  const { data: idToPageInfoMap } = useSWRxPageInfoForList(pageIds);
+
+  const { open: openDescendantPageListModal } = useDescendantsPageListModal();
 
   return (
     <div className="d-flex flex-column flex-lg-row-reverse">
 
       <div className="grw-side-contents-container">
-        <div className="grw-side-contents-sticky-container">
-          <div className="border-bottom pb-1">
-            {/* <PageAccessories isNotFoundPage={!isPageExist} /> */}
-          </div>
+        <div className="grw-page-accessories-control pb-1">
+          { currentPath != null && !isSharedUser && (
+            <button
+              type="button"
+              className="btn btn-block btn-outline-secondary grw-btn-page-accessories rounded-pill d-flex justify-content-between"
+              onClick={() => openDescendantPageListModal(currentPath)}
+            >
+              <PageListIcon />
+              {t('page_list')}
+              <span></span> {/* for a count badge */}
+            </button>
+          ) }
         </div>
       </div>
 

+ 63 - 16
packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -9,21 +9,26 @@ import { withUnstatedContainers } from '../UnstatedUtils';
 import EditorContainer from '~/client/services/EditorContainer';
 import {
   EditorMode, useDrawerMode, useEditorMode, useIsDeviceSmallerThanMd, useIsAbleToShowPageManagement, useIsAbleToShowTagLabel,
-  useIsAbleToShowPageEditorModeManager, useIsAbleToShowPageAuthors, usePageDuplicateModalStatus, usePageRenameModalStatus, usePageDeleteModalStatus,
+  useIsAbleToShowPageEditorModeManager, useIsAbleToShowPageAuthors, usePageAccessoriesModal, PageAccessoriesModalContents,
+  usePageDuplicateModalStatus, usePageRenameModalStatus, usePageDeleteModal,
 } from '~/stores/ui';
 import {
   useCurrentCreatedAt, useCurrentUpdatedAt, useCurrentPageId, useRevisionId, useCurrentPagePath,
-  useCreator, useRevisionAuthor, useIsGuestUser, useIsSharedUser,
+  useCreator, useRevisionAuthor, useIsGuestUser, useIsSharedUser, useShareLinkId,
 } from '~/stores/context';
 import { useSWRTagsInfo } from '~/stores/page';
 
-import { AdditionalMenuItemsRendererProps } from '../Common/Dropdown/PageItemControl';
-import { SubNavButtons } from './SubNavButtons';
-import PageEditorModeManager from './PageEditorModeManager';
 
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { apiPost } from '~/client/util/apiv1-client';
 import { IPageHasId } from '~/interfaces/page';
+
+import HistoryIcon from '../Icons/HistoryIcon';
+import AttachmentIcon from '../Icons/AttachmentIcon';
+import ShareLinkIcon from '../Icons/ShareLinkIcon';
+import { AdditionalMenuItemsRendererProps } from '../Common/Dropdown/PageItemControl';
+import { SubNavButtons } from './SubNavButtons';
+import PageEditorModeManager from './PageEditorModeManager';
 import { GrowiSubNavigation } from './GrowiSubNavigation';
 import PresentationIcon from '../Icons/PresentationIcon';
 import { exportAsMarkdown } from '~/client/services/page-operation';
@@ -32,17 +37,21 @@ import { exportAsMarkdown } from '~/client/services/page-operation';
 type AdditionalMenuItemsProps = AdditionalMenuItemsRendererProps & {
   pageId: string,
   revisionId: string,
+  isLinkSharingDisabled?: boolean,
 }
 
 const AdditionalMenuItems = (props: AdditionalMenuItemsProps): JSX.Element => {
   const { t } = useTranslation();
 
-  const { pageId, revisionId } = props;
+  const { pageId, revisionId, isLinkSharingDisabled } = props;
+
+  const { data: isGuestUser } = useIsGuestUser();
+  const { data: isSharedUser } = useIsSharedUser();
+
+  const { open } = usePageAccessoriesModal();
 
   return (
     <>
-      <DropdownItem divider />
-
       {/* Presentation */}
       <DropdownItem onClick={() => { /* TODO: implement in https://redmine.weseek.co.jp/issues/87672 */ }}>
         <i className="icon-fw"><PresentationIcon /></i>
@@ -57,6 +66,35 @@ const AdditionalMenuItems = (props: AdditionalMenuItemsProps): JSX.Element => {
 
       <DropdownItem divider />
 
+      {/*
+        TODO: show Tooltip when menu is disabled
+        refs: PageAccessoriesModalControl
+      */}
+      <DropdownItem
+        onClick={() => open(PageAccessoriesModalContents.PageHistory)}
+        disabled={isGuestUser || isSharedUser}
+      >
+        <span className="mr-1"><HistoryIcon /></span>
+        {t('History')}
+      </DropdownItem>
+
+      <DropdownItem
+        onClick={() => open(PageAccessoriesModalContents.Attachment)}
+      >
+        <span className="mr-1"><AttachmentIcon /></span>
+        {t('attachment_data')}
+      </DropdownItem>
+
+      <DropdownItem
+        onClick={() => open(PageAccessoriesModalContents.ShareLink)}
+        disabled={isGuestUser || isSharedUser || isLinkSharingDisabled}
+      >
+        <span className="mr-1"><ShareLinkIcon /></span>
+        {t('share_links.share_link_management')}
+      </DropdownItem>
+
+      <DropdownItem divider />
+
       {/* Create template */}
       <DropdownItem onClick={() => { /* TODO: implement in https://redmine.weseek.co.jp/issues/87673 */ }}>
         <i className="icon-fw icon-magic-wand"></i> { t('template.option_label.create/edit') }
@@ -79,6 +117,7 @@ const GrowiContextualSubNavigation = (props) => {
   const { data: revisionAuthor } = useRevisionAuthor();
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isSharedUser } = useIsSharedUser();
+  const { data: shareLinkId } = useShareLinkId();
 
   const { data: isAbleToShowPageManagement } = useIsAbleToShowPageManagement();
   const { data: isAbleToShowTagLabel } = useIsAbleToShowTagLabel();
@@ -89,10 +128,10 @@ const GrowiContextualSubNavigation = (props) => {
 
   const { open: openDuplicateModal } = usePageDuplicateModalStatus();
   const { open: openRenameModal } = usePageRenameModalStatus();
-  const { open: openDeleteModal } = usePageDeleteModalStatus();
+  const { open: openDeleteModal } = usePageDeleteModal();
 
   const {
-    editorContainer, isCompactMode,
+    editorContainer, isCompactMode, isLinkSharingDisabled,
   } = props;
 
   const isViewMode = editorMode === EditorMode.View;
@@ -123,7 +162,7 @@ const GrowiContextualSubNavigation = (props) => {
     openDuplicateModal(pageId, path);
   }, [openDuplicateModal]);
 
-  const reameItemClickedHandler = useCallback(async(pageId, revisionId, path) => {
+  const renameItemClickedHandler = useCallback(async(pageId, revisionId, path) => {
     openRenameModal(pageId, revisionId, path);
   }, [openRenameModal]);
 
@@ -143,13 +182,16 @@ const GrowiContextualSubNavigation = (props) => {
             <SubNavButtons
               isCompactMode={isCompactMode}
               pageId={pageId}
+              shareLinkId={shareLinkId}
               revisionId={revisionId}
               path={path}
               disableSeenUserInfoPopover={isSharedUser}
               showPageControlDropdown={isAbleToShowPageManagement}
-              additionalMenuItemRenderer={props => <AdditionalMenuItems {...props} pageId={pageId} revisionId={revisionId} />}
+              additionalMenuItemRenderer={props => (
+                <AdditionalMenuItems {...props} pageId={pageId} revisionId={revisionId} isLinkSharingDisabled={isLinkSharingDisabled} />
+              )}
               onClickDuplicateMenuItem={duplicateItemClickedHandler}
-              onClickRenameMenuItem={reameItemClickedHandler}
+              onClickRenameMenuItem={renameItemClickedHandler}
               onClickDeleteMenuItem={deleteItemClickedHandler}
             />
           ) }
@@ -167,11 +209,12 @@ const GrowiContextualSubNavigation = (props) => {
       </>
     );
   }, [
-    pageId, revisionId,
+    pageId, revisionId, shareLinkId,
     editorMode, mutateEditorMode,
-    isCompactMode, isDeviceSmallerThanMd, isGuestUser, isSharedUser,
+    isCompactMode, isLinkSharingDisabled,
+    isDeviceSmallerThanMd, isGuestUser, isSharedUser,
     isViewMode, isAbleToShowPageEditorModeManager, isAbleToShowPageManagement,
-    duplicateItemClickedHandler, reameItemClickedHandler, deleteItemClickedHandler, path,
+    duplicateItemClickedHandler, renameItemClickedHandler, deleteItemClickedHandler, path,
   ]);
 
 
@@ -196,6 +239,9 @@ const GrowiContextualSubNavigation = (props) => {
       showDrawerToggler={isDrawerMode}
       showTagLabel={isAbleToShowTagLabel}
       showPageAuthors={isAbleToShowPageAuthors}
+      isGuestUser={isGuestUser}
+      isDrawerMode={isDrawerMode}
+      isCompactMode={isCompactMode}
       tags={tagsInfoData?.tags || []}
       tagsUpdatedHandler={tagsUpdatedHandler}
       controls={ControlComponents}
@@ -213,6 +259,7 @@ GrowiContextualSubNavigation.propTypes = {
   editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
 
   isCompactMode: PropTypes.bool,
+  isLinkSharingDisabled: PropTypes.bool,
 };
 
 export default GrowiContextualSubNavigationWrapper;

+ 1 - 1
packages/app/src/components/Navbar/GrowiSubNavigation.tsx

@@ -79,7 +79,7 @@ export const GrowiSubNavigation = (props: Props): JSX.Element => {
       {/* Right side */}
       <div className="d-flex">
 
-        <div>
+        <div className="d-flex flex-column" style={{ gap: `${isCompactMode ? '5px' : '0'}` }}>
           { Controls && <Controls></Controls> }
         </div>
 

+ 3 - 1
packages/app/src/components/Navbar/GrowiSubNavigationSwitcher.jsx

@@ -1,6 +1,7 @@
 import React, {
   useMemo, useState, useRef, useEffect, useCallback,
 } from 'react';
+import PropTypes from 'prop-types';
 
 import StickyEvents from 'sticky-events';
 import { debounce } from 'throttle-debounce';
@@ -110,13 +111,14 @@ const GrowiSubNavigationSwitcher = (props) => {
   return (
     <div className={`grw-subnav-switcher ${isVisible ? '' : 'grw-subnav-switcher-hidden'}`}>
       <div id="grw-subnav-fixed-container" className="grw-subnav-fixed-container position-fixed" ref={fixedContainerRef} style={{ width }}>
-        <GrowiContextualSubNavigation isCompactMode />
+        <GrowiContextualSubNavigation isCompactMode isLinkSharingDisabled />
       </div>
     </div>
   );
 };
 
 GrowiSubNavigationSwitcher.propTypes = {
+  isLinkSharingDisabled: PropTypes.bool,
 };
 
 export default GrowiSubNavigationSwitcher;

+ 12 - 32
packages/app/src/components/Navbar/SubNavButtons.tsx

@@ -28,6 +28,7 @@ type CommonProps = {
 
 type SubNavButtonsSubstanceProps= CommonProps & {
   pageId: string,
+  shareLinkId?: string | null,
   revisionId: string,
   path?: string | null,
   pageInfo: IPageInfoAll,
@@ -35,13 +36,14 @@ type SubNavButtonsSubstanceProps= CommonProps & {
 
 const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element => {
   const {
-    pageInfo, pageId, revisionId, path, isCompactMode, disableSeenUserInfoPopover, showPageControlDropdown,
-    additionalMenuItemRenderer, onClickDuplicateMenuItem, onClickRenameMenuItem, onClickDeleteMenuItem,
+    pageInfo,
+    pageId, revisionId, path, shareLinkId,
+    isCompactMode, disableSeenUserInfoPopover, showPageControlDropdown, additionalMenuItemRenderer, onClickDuplicateMenuItem, onClickRenameMenuItem, onClickDeleteMenuItem,
   } = props;
 
   const { data: isGuestUser } = useIsGuestUser();
 
-  const { mutate: mutatePageInfo } = useSWRxPageInfo(pageId);
+  const { mutate: mutatePageInfo } = useSWRxPageInfo(pageId, shareLinkId);
 
   const { data: bookmarkInfo, mutate: mutateBookmarkInfo } = useSWRBookmarkInfo(pageId);
 
@@ -103,26 +105,18 @@ const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element
   }, [onClickDuplicateMenuItem, pageId, path]);
 
   const renameMenuItemClickHandler = useCallback(async(_pageId: string): Promise<void> => {
-    if (onClickRenameMenuItem == null) {
+    if (onClickRenameMenuItem == null || path == null) {
       return;
     }
 
-    if (path == null) {
-      throw Error('path must not be null.');
-    }
-
     onClickRenameMenuItem(pageId, revisionId, path);
   }, [onClickRenameMenuItem, pageId, path, revisionId]);
 
   const deleteMenuItemClickHandler = useCallback(async(_pageId: string): Promise<void> => {
-    if (onClickDeleteMenuItem == null) {
+    if (onClickDeleteMenuItem == null || path == null) {
       return;
     }
 
-    if (path == null) {
-      throw Error('path must not be null.');
-    }
-
     const pageToDelete: IPageForPageDeleteModal = {
       pageId,
       revisionId,
@@ -181,17 +175,17 @@ const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element
 
 type SubNavButtonsProps= CommonProps & {
   pageId: string,
+  shareLinkId?: string | null,
   revisionId?: string | null,
   path?: string | null
 };
 
 export const SubNavButtons = (props: SubNavButtonsProps): JSX.Element => {
   const {
-    pageId, revisionId, path, onClickDuplicateMenuItem, onClickRenameMenuItem, onClickDeleteMenuItem,
+    pageId, revisionId, path, shareLinkId, onClickDuplicateMenuItem, onClickRenameMenuItem, onClickDeleteMenuItem,
   } = props;
 
-  const { data: pageInfo, error } = useSWRxPageInfo(pageId ?? null);
-
+  const { data: pageInfo, error } = useSWRxPageInfo(pageId ?? null, shareLinkId);
 
   const duplicateItemClickedHandler = useCallback(async(pageId, path) => {
     if (onClickDuplicateMenuItem == null) {
@@ -200,20 +194,6 @@ export const SubNavButtons = (props: SubNavButtonsProps): JSX.Element => {
     await onClickDuplicateMenuItem(pageId, path);
   }, [onClickDuplicateMenuItem]);
 
-  const renameItemClickedHandler = useCallback(async(pageId, revisionId, path) => {
-    if (onClickRenameMenuItem == null) {
-      return;
-    }
-    await onClickRenameMenuItem(pageId, revisionId, path);
-  }, [onClickRenameMenuItem]);
-
-  const deleteItemClickedHandler = useCallback(async(pageToDelete) => {
-    if (onClickDeleteMenuItem == null) {
-      return;
-    }
-    await onClickDeleteMenuItem(pageToDelete);
-  }, [onClickDeleteMenuItem]);
-
   if (revisionId == null || error != null) {
     return <></>;
   }
@@ -231,8 +211,8 @@ export const SubNavButtons = (props: SubNavButtonsProps): JSX.Element => {
       revisionId={revisionId}
       path={path}
       onClickDuplicateMenuItem={duplicateItemClickedHandler}
-      onClickRenameMenuItem={renameItemClickedHandler}
-      onClickDeleteMenuItem={deleteItemClickedHandler}
+      onClickRenameMenuItem={onClickRenameMenuItem}
+      onClickDeleteMenuItem={onClickDeleteMenuItem}
     />
   );
 };

+ 0 - 89
packages/app/src/components/Page/DisplaySwitcher.jsx

@@ -1,89 +0,0 @@
-import React from 'react';
-import { TabContent, TabPane } from 'reactstrap';
-import propTypes from 'prop-types';
-
-import { withUnstatedContainers } from '../UnstatedUtils';
-import PageContainer from '~/client/services/PageContainer';
-import { EditorMode, useEditorMode } from '~/stores/ui';
-
-import Editor from '../PageEditor';
-import Page from '../Page';
-import UserInfo from '../User/UserInfo';
-import TableOfContents from '../TableOfContents';
-import ContentLinkButtons from '../ContentLinkButtons';
-import PageAccessories from '../PageAccessories';
-import PageEditorByHackmd from '../PageEditorByHackmd';
-import EditorNavbarBottom from '../PageEditor/EditorNavbarBottom';
-import HashChanged from '../EventListeneres/HashChanged';
-import { useIsEditable } from '~/stores/context';
-
-
-const DisplaySwitcher = (props) => {
-  const {
-    pageContainer,
-  } = props;
-  const { isPageExist, pageUser } = pageContainer.state;
-
-  const { data: isEditable } = useIsEditable();
-  const { data: editorMode } = useEditorMode();
-
-  const isViewMode = editorMode === EditorMode.View;
-
-  return (
-    <>
-      <TabContent activeTab={editorMode}>
-        <TabPane tabId={EditorMode.View}>
-          <div className="d-flex flex-column flex-lg-row-reverse">
-
-            { isPageExist && (
-              <div className="grw-side-contents-container">
-                <div className="grw-side-contents-sticky-container">
-                  <div className="border-bottom pb-1">
-                    <PageAccessories />
-                  </div>
-
-                  <div className="d-none d-lg-block">
-                    <div id="revision-toc" className="revision-toc">
-                      <TableOfContents />
-                    </div>
-                    <ContentLinkButtons />
-                  </div>
-                </div>
-              </div>
-            ) }
-
-            <div className="flex-grow-1 flex-basis-0 mw-0">
-              {pageUser && <UserInfo pageUser={pageUser} />}
-              <Page />
-            </div>
-
-          </div>
-        </TabPane>
-        { isEditable && (
-          <TabPane tabId={EditorMode.Editor}>
-            <div id="page-editor">
-              <Editor />
-            </div>
-          </TabPane>
-        ) }
-        { isEditable && (
-          <TabPane tabId={EditorMode.HackMD}>
-            <div id="page-editor-with-hackmd">
-              <PageEditorByHackmd />
-            </div>
-          </TabPane>
-        ) }
-      </TabContent>
-      { isEditable && !isViewMode && <EditorNavbarBottom /> }
-
-      { isEditable && <HashChanged></HashChanged> }
-    </>
-  );
-};
-
-DisplaySwitcher.propTypes = {
-  pageContainer: propTypes.instanceOf(PageContainer).isRequired,
-};
-
-
-export default withUnstatedContainers(DisplaySwitcher, [PageContainer]);

+ 134 - 0
packages/app/src/components/Page/DisplaySwitcher.tsx

@@ -0,0 +1,134 @@
+import React, { useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
+import { TabContent, TabPane } from 'reactstrap';
+
+import { pagePathUtils } from '@growi/core';
+
+import { EditorMode, useEditorMode, useDescendantsPageListModal } from '~/stores/ui';
+import {
+  useCurrentPagePath, useIsSharedUser, useIsEditable, useCurrentPageId, useIsUserPage, usePageUser,
+} from '~/stores/context';
+
+
+import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
+
+import PageListIcon from '../Icons/PageListIcon';
+import Editor from '../PageEditor';
+import Page from '../Page';
+import UserInfo from '../User/UserInfo';
+import TableOfContents from '../TableOfContents';
+import ContentLinkButtons from '../ContentLinkButtons';
+import PageEditorByHackmd from '../PageEditorByHackmd';
+import EditorNavbarBottom from '../PageEditor/EditorNavbarBottom';
+import HashChanged from '../EventListeneres/HashChanged';
+
+
+const WIKI_HEADER_LINK = 120;
+
+const { isTopPage } = pagePathUtils;
+
+
+const DisplaySwitcher = (): JSX.Element => {
+  const { t } = useTranslation();
+
+
+  // get element for smoothScroll
+  const getCommentListDom = useMemo(() => { return document.getElementById('page-comments-list') }, []);
+
+
+  const { data: currentPageId } = useCurrentPageId();
+  const { data: currentPath } = useCurrentPagePath();
+  const { data: isSharedUser } = useIsSharedUser();
+  const { data: isUserPage } = useIsUserPage();
+  const { data: isEditable } = useIsEditable();
+  const { data: pageUser } = usePageUser();
+
+  const { data: editorMode } = useEditorMode();
+
+  const { open: openDescendantPageListModal } = useDescendantsPageListModal();
+
+  const isPageExist = currentPageId != null;
+  const isViewMode = editorMode === EditorMode.View;
+  const isTopPagePath = isTopPage(currentPath ?? '');
+
+  return (
+    <>
+      <TabContent activeTab={editorMode}>
+        <TabPane tabId={EditorMode.View}>
+          <div className="d-flex flex-column flex-lg-row-reverse">
+
+            { isPageExist && (
+              <div className="grw-side-contents-container">
+                <div className="grw-side-contents-sticky-container">
+
+                  {/* Page list */}
+                  <div className="grw-page-accessories-control">
+                    { currentPath != null && !isSharedUser && (
+                      <button
+                        type="button"
+                        className="btn btn-block btn-outline-secondary grw-btn-page-accessories rounded-pill d-flex justify-content-between align-items-center"
+                        onClick={() => openDescendantPageListModal(currentPath)}
+                      >
+                        <PageListIcon />
+                        {t('page_list')}
+                        <span></span> {/* for a count badge */}
+                      </button>
+                    ) }
+                  </div>
+
+                  {/* Comments */}
+                  { getCommentListDom != null && !isTopPagePath && (
+                    <div className="mt-2">
+                      <button
+                        type="button"
+                        className="btn btn-block btn-outline-secondary grw-btn-page-accessories rounded-pill d-flex justify-content-between align-items-center"
+                        onClick={() => smoothScrollIntoView(getCommentListDom, WIKI_HEADER_LINK)}
+                      >
+                        <i className="mr-2 icon-fw icon-bubbles"></i>
+                        <span>Comments</span>
+                        <span></span> {/* for a count badge */}
+                      </button>
+                    </div>
+                  ) }
+
+                  <div className="d-none d-lg-block">
+                    <div id="revision-toc" className="revision-toc">
+                      <TableOfContents />
+                    </div>
+                    <ContentLinkButtons />
+                  </div>
+
+                </div>
+              </div>
+            ) }
+
+            <div className="flex-grow-1 flex-basis-0 mw-0">
+              { isUserPage && <UserInfo pageUser={pageUser} />}
+              <Page />
+            </div>
+
+          </div>
+        </TabPane>
+        { isEditable && (
+          <TabPane tabId={EditorMode.Editor}>
+            <div id="page-editor">
+              <Editor />
+            </div>
+          </TabPane>
+        ) }
+        { isEditable && (
+          <TabPane tabId={EditorMode.HackMD}>
+            <div id="page-editor-with-hackmd">
+              <PageEditorByHackmd />
+            </div>
+          </TabPane>
+        ) }
+      </TabContent>
+      { isEditable && !isViewMode && <EditorNavbarBottom /> }
+
+      { isEditable && <HashChanged></HashChanged> }
+    </>
+  );
+};
+
+export default DisplaySwitcher;

+ 2 - 2
packages/app/src/components/Page/PageManagement.jsx

@@ -5,7 +5,7 @@ import { withTranslation } from 'react-i18next';
 import urljoin from 'url-join';
 
 import { pagePathUtils } from '@growi/core';
-import { usePageDeleteModalStatus } from '~/stores/ui';
+import { usePageDeleteModal } from '~/stores/ui';
 
 import { withUnstatedContainers } from '../UnstatedUtils';
 import AppContainer from '~/client/services/AppContainer';
@@ -23,7 +23,7 @@ const LegacyPageManagemenet = (props) => {
     t, appContainer, isCompactMode, pageId, revisionId, path, isDeletable, isAbleToDeleteCompletely,
   } = props;
 
-  const { open: openDeleteModal } = usePageDeleteModalStatus();
+  const { open: openDeleteModal } = usePageDeleteModal();
 
   const { currentUser } = appContainer;
   const isTopPagePath = isTopPage(path);

+ 10 - 18
packages/app/src/components/Page/TrashPageAlert.jsx

@@ -7,11 +7,11 @@ import { UserPicture } from '@growi/ui';
 import { withUnstatedContainers } from '../UnstatedUtils';
 import AppContainer from '~/client/services/AppContainer';
 import PageContainer from '~/client/services/PageContainer';
-import { useCurrentUpdatedAt } from '~/stores/context';
 import PutbackPageModal from '../PutbackPageModal';
 import EmptyTrashModal from '../EmptyTrashModal';
-import PageDeleteModal from '../PageDeleteModal';
 
+import { useCurrentUpdatedAt } from '~/stores/context';
+import { usePageDeleteModal } from '~/stores/ui';
 
 const TrashPageAlert = (props) => {
   const { t, pageContainer } = props;
@@ -21,7 +21,8 @@ const TrashPageAlert = (props) => {
   const { data: updatedAt } = useCurrentUpdatedAt();
   const [isEmptyTrashModalShown, setIsEmptyTrashModalShown] = useState(false);
   const [isPutbackPageModalShown, setIsPutbackPageModalShown] = useState(false);
-  const [isPageDeleteModalShown, setIsPageDeleteModalShown] = useState(false);
+
+  const { open: openDeleteModal } = usePageDeleteModal();
 
   function openEmptyTrashModalHandler() {
     setIsEmptyTrashModalShown(true);
@@ -40,11 +41,12 @@ const TrashPageAlert = (props) => {
   }
 
   function openPageDeleteModalHandler() {
-    setIsPageDeleteModalShown(true);
-  }
-
-  function opclosePageDeleteModalHandler() {
-    setIsPageDeleteModalShown(false);
+    const pageToDelete = {
+      pageId,
+      revisionId,
+      path,
+    };
+    openDeleteModal([pageToDelete]);
   }
 
   function renderEmptyButton() {
@@ -97,16 +99,6 @@ const TrashPageAlert = (props) => {
           pageId={pageId}
           path={path}
         />
-        {/* TODO: show PageDeleteModal with usePageDeleteModalStatus by 87567  */}
-        <PageDeleteModal
-          isOpen={isPageDeleteModalShown}
-          onClose={opclosePageDeleteModalHandler}
-          pageId={pageId}
-          revisionId={revisionId}
-          path={path}
-          isDeleteCompletelyModal
-          isAbleToDeleteCompletely={isAbleToDeleteCompletely}
-        />
       </>
     );
   }

+ 0 - 40
packages/app/src/components/PageAccessories.jsx

@@ -1,40 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import PageAccessoriesModalControl from './PageAccessoriesModalControl';
-import PageAccessoriesModal from './PageAccessoriesModal';
-
-import { withUnstatedContainers } from './UnstatedUtils';
-import AppContainer from '~/client/services/AppContainer';
-import PageAccessoriesContainer from '~/client/services/PageAccessoriesContainer';
-
-const PageAccessories = (props) => {
-  const { appContainer, pageAccessoriesContainer } = props;
-  const { isGuestUser, isSharedUser } = appContainer;
-
-  return (
-    <>
-      <PageAccessoriesModalControl
-        isGuestUser={isGuestUser}
-        isSharedUser={isSharedUser}
-      />
-      <PageAccessoriesModal
-        isGuestUser={isGuestUser}
-        isSharedUser={isSharedUser}
-        isOpen={pageAccessoriesContainer.state.isPageAccessoriesModalShown}
-        onClose={pageAccessoriesContainer.closePageAccessoriesModal}
-      />
-    </>
-  );
-};
-/**
- * Wrapper component for using unstated
- */
-const PageAccessoriesWrapper = withUnstatedContainers(PageAccessories, [AppContainer, PageAccessoriesContainer]);
-
-PageAccessories.propTypes = {
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  pageAccessoriesContainer: PropTypes.instanceOf(PageAccessoriesContainer).isRequired,
-};
-
-export default PageAccessoriesWrapper;

+ 0 - 160
packages/app/src/components/PageAccessoriesModal.jsx

@@ -1,160 +0,0 @@
-import React, { useCallback, useMemo, useState } from 'react';
-import PropTypes from 'prop-types';
-
-import {
-  Modal, ModalBody, ModalHeader, TabContent, TabPane,
-} from 'reactstrap';
-
-import { withTranslation } from 'react-i18next';
-import PageListIcon from './Icons/PageListIcon';
-import TimeLineIcon from './Icons/TimeLineIcon';
-import HistoryIcon from './Icons/HistoryIcon';
-import AttachmentIcon from './Icons/AttachmentIcon';
-import ShareLinkIcon from './Icons/ShareLinkIcon';
-
-import { withUnstatedContainers } from './UnstatedUtils';
-import PageContainer from '~/client/services/PageContainer';
-import PageAccessoriesContainer from '~/client/services/PageAccessoriesContainer';
-import PageAttachment from './PageAttachment';
-import PageTimeline from './PageTimeline';
-import DescendantsPageList from './DescendantsPageList';
-import PageHistory from './PageHistory';
-import ShareLink from './ShareLink/ShareLink';
-import { CustomNavTab } from './CustomNavigation/CustomNav';
-import ExpandOrContractButton from './ExpandOrContractButton';
-
-const PageAccessoriesModal = (props) => {
-  const {
-    t, pageContainer, pageAccessoriesContainer, onClose, isGuestUser, isSharedUser,
-  } = props;
-  const isLinkSharingDisabled = pageAccessoriesContainer.appContainer.config.disableLinkSharing;
-  const { switchActiveTab } = pageAccessoriesContainer;
-  const { activeTab, activeComponents } = pageAccessoriesContainer.state;
-  const [isWindowExpanded, setIsWindowExpanded] = useState(false);
-
-  const navTabMapping = useMemo(() => {
-    return {
-      pagelist: {
-        Icon: PageListIcon,
-        i18n: t('page_list'),
-        index: 0,
-        isLinkEnabled: v => !isSharedUser,
-      },
-      timeline: {
-        Icon: TimeLineIcon,
-        i18n: t('Timeline View'),
-        index: 1,
-        isLinkEnabled: v => !isSharedUser,
-      },
-      pageHistory: {
-        Icon: HistoryIcon,
-        i18n: t('History'),
-        index: 2,
-        isLinkEnabled: v => !isGuestUser && !isSharedUser,
-      },
-      attachment: {
-        Icon: AttachmentIcon,
-        i18n: t('attachment_data'),
-        index: 3,
-      },
-      shareLink: {
-        Icon: ShareLinkIcon,
-        i18n: t('share_links.share_link_management'),
-        index: 4,
-        isLinkEnabled: v => !isGuestUser && !isSharedUser && !isLinkSharingDisabled,
-      },
-    };
-  }, [t, isGuestUser, isSharedUser, isLinkSharingDisabled]);
-
-  const closeModalHandler = useCallback(() => {
-    if (onClose == null) {
-      return;
-    }
-    onClose();
-  }, [onClose]);
-
-  const expandWindow = () => {
-    setIsWindowExpanded(true);
-  };
-
-  const contractWindow = () => {
-    setIsWindowExpanded(false);
-  };
-
-  const buttons = (
-    <div className="d-flex flex-nowrap">
-      <ExpandOrContractButton
-        isWindowExpanded={isWindowExpanded}
-        expandWindow={expandWindow}
-        contractWindow={contractWindow}
-      />
-      <button type="button" className="close" onClick={closeModalHandler} aria-label="Close">
-        <span aria-hidden="true">&times;</span>
-      </button>
-    </div>
-  );
-
-  return (
-    <React.Fragment>
-      <Modal
-        size="xl"
-        isOpen={props.isOpen}
-        toggle={closeModalHandler}
-        className={`grw-page-accessories-modal ${isWindowExpanded ? 'grw-modal-expanded' : ''} `}
-      >
-        <ModalHeader className="p-0" toggle={closeModalHandler} close={buttons}>
-          <CustomNavTab
-            activeTab={activeTab}
-            navTabMapping={navTabMapping}
-            onNavSelected={switchActiveTab}
-            breakpointToHideInactiveTabsDown="md"
-            hideBorderBottom
-          />
-        </ModalHeader>
-        <ModalBody className="overflow-auto grw-modal-body-style">
-          {/* Do not use CustomTabContent because of performance problem:
-              the 'navTabMapping[tabId].Content' for PageAccessoriesModal depends on activeComponents */}
-          <TabContent activeTab={activeTab}>
-            <TabPane tabId="pagelist">
-              {activeComponents.has('pagelist') && <DescendantsPageList path={pageContainer.state.path} />}
-            </TabPane>
-            <TabPane tabId="timeline">
-              {activeComponents.has('timeline') && <PageTimeline /> }
-            </TabPane>
-            {!isGuestUser && (
-              <TabPane tabId="pageHistory">
-                {activeComponents.has('pageHistory') && <PageHistory /> }
-              </TabPane>
-            )}
-            <TabPane tabId="attachment">
-              {activeComponents.has('attachment') && <PageAttachment />}
-            </TabPane>
-            {!isGuestUser && (
-              <TabPane tabId="shareLink">
-                {activeComponents.has('shareLink') && <ShareLink />}
-              </TabPane>
-            )}
-          </TabContent>
-        </ModalBody>
-      </Modal>
-    </React.Fragment>
-  );
-};
-
-/**
- * Wrapper component for using unstated
- */
-const PageAccessoriesModalWrapper = withUnstatedContainers(PageAccessoriesModal, [PageContainer, PageAccessoriesContainer]);
-
-PageAccessoriesModal.propTypes = {
-  t: PropTypes.func.isRequired, //  i18next
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
-  pageAccessoriesContainer: PropTypes.instanceOf(PageAccessoriesContainer).isRequired,
-
-  isGuestUser: PropTypes.bool.isRequired,
-  isSharedUser: PropTypes.bool.isRequired,
-  isOpen: PropTypes.bool.isRequired,
-  onClose: PropTypes.func,
-};
-
-export default withTranslation()(PageAccessoriesModalWrapper);

+ 134 - 0
packages/app/src/components/PageAccessoriesModal.tsx

@@ -0,0 +1,134 @@
+import React, { useEffect, useMemo, useState } from 'react';
+
+import {
+  Modal, ModalBody, ModalHeader,
+} from 'reactstrap';
+
+import { useTranslation } from 'react-i18next';
+
+import { useIsGuestUser, useIsSharedUser } from '~/stores/context';
+import { usePageAccessoriesModal, PageAccessoriesModalContents } from '~/stores/ui';
+import AppContainer from '~/client/services/AppContainer';
+
+import HistoryIcon from './Icons/HistoryIcon';
+import AttachmentIcon from './Icons/AttachmentIcon';
+import ShareLinkIcon from './Icons/ShareLinkIcon';
+import { withUnstatedContainers } from './UnstatedUtils';
+import PageAttachment from './PageAttachment';
+import PageHistory from './PageHistory';
+import ShareLink from './ShareLink/ShareLink';
+import { CustomNavTab } from './CustomNavigation/CustomNav';
+import ExpandOrContractButton from './ExpandOrContractButton';
+import CustomTabContent from './CustomNavigation/CustomTabContent';
+
+
+type Props = {
+  appContainer: AppContainer,
+  isLinkSharingDisabled: boolean,
+}
+
+const PageAccessoriesModal = (props: Props): JSX.Element => {
+  const {
+    appContainer,
+  } = props;
+
+  const isLinkSharingDisabled = appContainer.config.disableLinkSharing;
+
+  const { t } = useTranslation();
+
+  const [activeTab, setActiveTab] = useState<PageAccessoriesModalContents>(PageAccessoriesModalContents.PageHistory);
+  const [isWindowExpanded, setIsWindowExpanded] = useState(false);
+
+  const { data: isSharedUser } = useIsSharedUser();
+  const { data: isGuestUser } = useIsGuestUser();
+
+  const { data: status, mutate, close } = usePageAccessoriesModal();
+
+  // add event handler when opened
+  useEffect(() => {
+    if (status == null || status.onOpened != null) {
+      return;
+    }
+    mutate({
+      ...status,
+      onOpened: (activatedContents) => {
+        setActiveTab(activatedContents);
+      },
+    }, false);
+  }, [mutate, status]);
+
+  const navTabMapping = useMemo(() => {
+    return {
+      [PageAccessoriesModalContents.PageHistory]: {
+        Icon: HistoryIcon,
+        Content: () => <PageHistory />,
+        i18n: t('History'),
+        index: 0,
+        isLinkEnabled: () => !isGuestUser && !isSharedUser,
+      },
+      [PageAccessoriesModalContents.Attachment]: {
+        Icon: AttachmentIcon,
+        Content: () => <PageAttachment />,
+        i18n: t('attachment_data'),
+        index: 1,
+      },
+      [PageAccessoriesModalContents.ShareLink]: {
+        Icon: ShareLinkIcon,
+        Content: () => <ShareLink />,
+        i18n: t('share_links.share_link_management'),
+        index: 2,
+        isLinkEnabled: () => !isGuestUser && !isSharedUser && !isLinkSharingDisabled,
+      },
+    };
+  }, [t, isGuestUser, isSharedUser, isLinkSharingDisabled]);
+
+  const buttons = useMemo(() => (
+    <div className="d-flex flex-nowrap">
+      <ExpandOrContractButton
+        isWindowExpanded={isWindowExpanded}
+        expandWindow={() => setIsWindowExpanded(true)}
+        contractWindow={() => setIsWindowExpanded(false)}
+      />
+      <button type="button" className="close" onClick={close} aria-label="Close">
+        <span aria-hidden="true">&times;</span>
+      </button>
+    </div>
+  ), [close, isWindowExpanded]);
+
+  if (status == null) {
+    return <></>;
+  }
+
+  const { isOpened } = status;
+
+  return (
+    <Modal
+      size="xl"
+      isOpen={isOpened}
+      toggle={close}
+      className={`grw-page-accessories-modal ${isWindowExpanded ? 'grw-modal-expanded' : ''} `}
+    >
+      <ModalHeader className="p-0" toggle={close} close={buttons}>
+        <CustomNavTab
+          activeTab={activeTab}
+          navTabMapping={navTabMapping}
+          breakpointToHideInactiveTabsDown="md"
+          onNavSelected={(v) => {
+            setActiveTab(v);
+          }}
+          hideBorderBottom
+        />
+      </ModalHeader>
+      <ModalBody className="overflow-auto grw-modal-body-style">
+        <CustomTabContent activeTab={activeTab} navTabMapping={navTabMapping} />
+      </ModalBody>
+    </Modal>
+  );
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const PageAccessoriesModalWrapper = withUnstatedContainers(PageAccessoriesModal, [AppContainer]);
+
+export default PageAccessoriesModalWrapper;

+ 2 - 3
packages/app/src/components/PageAccessoriesModalControl.jsx

@@ -4,7 +4,6 @@ import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 
 import { UncontrolledTooltip } from 'reactstrap';
-import PageAccessoriesContainer from '~/client/services/PageAccessoriesContainer';
 
 import PageListIcon from './Icons/PageListIcon';
 import TimeLineIcon from './Icons/TimeLineIcon';
@@ -96,12 +95,12 @@ const PageAccessoriesModalControl = (props) => {
 /**
  * Wrapper component for using unstated
  */
-const PageAccessoriesModalControlWrapper = withUnstatedContainers(PageAccessoriesModalControl, [PageAccessoriesContainer]);
+const PageAccessoriesModalControlWrapper = withUnstatedContainers(PageAccessoriesModalControl, []);
 
 PageAccessoriesModalControl.propTypes = {
   t: PropTypes.func.isRequired, //  i18next
 
-  pageAccessoriesContainer: PropTypes.instanceOf(PageAccessoriesContainer).isRequired,
+  pageAccessoriesContainer: PropTypes.any,
 
   isGuestUser: PropTypes.bool.isRequired,
   isSharedUser: PropTypes.bool.isRequired,

+ 70 - 37
packages/app/src/components/PageDeleteModal.tsx

@@ -1,12 +1,14 @@
 import React, { useState, FC } from 'react';
-import toastr from 'toastr';
 import {
   Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
 import { useTranslation } from 'react-i18next';
 
-// import { apiPost } from '~/client/util/apiv1-client';
-import { usePageDeleteModalStatus, usePageDeleteModalOpened } from '~/stores/ui';
+import { apiPost } from '~/client/util/apiv1-client';
+import { apiv3Post } from '~/client/util/apiv3-client';
+import { usePageDeleteModal, usePageDeleteModalOpened } from '~/stores/ui';
+
+import { IDeleteSinglePageApiv1Result, IDeleteManyPageApiv3Result } from '~/interfaces/page';
 
 import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
 
@@ -25,7 +27,6 @@ const deleteIconAndKey = {
 };
 
 type Props = {
-  isOpen: boolean,
   isDeleteCompletelyModal: boolean,
   isAbleToDeleteCompletely: boolean,
   onClose?: () => void,
@@ -37,16 +38,17 @@ const PageDeleteModal: FC<Props> = (props: Props) => {
     isDeleteCompletelyModal, isAbleToDeleteCompletely,
   } = props;
 
+  const { data: deleteModalStatus, close: closeDeleteModal } = usePageDeleteModal();
+  const { data: pageDeleteModalOpened } = usePageDeleteModalOpened();
 
-  const { data: pagesDataToDelete, close: closeDeleteModal } = usePageDeleteModalStatus();
-  const { data: isOpened } = usePageDeleteModalOpened();
+  const isOpened = pageDeleteModalOpened?.isOpend != null ? pageDeleteModalOpened.isOpend : false;
 
   const [isDeleteRecursively, setIsDeleteRecursively] = useState(true);
   const [isDeleteCompletely, setIsDeleteCompletely] = useState(isDeleteCompletelyModal && isAbleToDeleteCompletely);
   const deleteMode = isDeleteCompletely ? 'completely' : 'temporary';
 
   // eslint-disable-next-line @typescript-eslint/no-unused-vars
-  const [errs, setErrs] = useState(null);
+  const [errs, setErrs] = useState<Error[] | null>(null);
 
   function changeIsDeleteRecursivelyHandler() {
     setIsDeleteRecursively(!isDeleteRecursively);
@@ -60,33 +62,65 @@ const PageDeleteModal: FC<Props> = (props: Props) => {
   }
 
   async function deletePage() {
-    toastr.warning(t('search_result.currently_not_implemented'));
-    // Todo implement page delete function at https://redmine.weseek.co.jp/issues/82222
-    // setErrs(null);
-
-    // try {
-    //   // control flag
-    //   // If is it not true, Request value must be `null`.
-    //   const recursively = isDeleteRecursively ? true : null;
-    //   const completely = isDeleteCompletely ? true : null;
-
-    //   const response = await apiPost('/pages.remove', {
-    //     page_id: pageId,
-    //     revision_id: revisionId,
-    //     recursively,
-    //     completely,
-    //   });
-
-    //   const trashPagePath = response.page.path;
-    //   window.location.href = encodeURI(trashPagePath);
-    // }
-    // catch (err) {
-    //   setErrs(err);
-    // }
+    if (deleteModalStatus == null || deleteModalStatus.pages == null) {
+      return;
+    }
+
+    /*
+     * When multiple pages
+     */
+    if (deleteModalStatus.pages.length > 1) {
+      try {
+        const isRecursively = isDeleteRecursively === true ? true : undefined;
+        const isCompletely = isDeleteCompletely === true ? true : undefined;
+
+        const pageIdToRevisionIdMap = {};
+        deleteModalStatus.pages.forEach((p) => { pageIdToRevisionIdMap[p.pageId] = p.revisionId });
+
+        const { data } = await apiv3Post<IDeleteManyPageApiv3Result>('/pages/delete', {
+          pageIdToRevisionIdMap,
+          isRecursively,
+          isCompletely,
+        });
+
+        if (pageDeleteModalOpened != null && pageDeleteModalOpened.onDeleted != null) {
+          pageDeleteModalOpened.onDeleted(data.paths, data.isRecursively, data.isCompletely);
+        }
+      }
+      catch (err) {
+        setErrs([err]);
+      }
+    }
+    /*
+     * When single page
+     */
+    else {
+      try {
+        const recursively = isDeleteRecursively === true ? true : undefined;
+        const completely = isDeleteCompletely === true ? true : undefined;
+
+        const page = deleteModalStatus.pages[0];
+
+        const { path, isRecursively, isCompletely } = await apiPost('/pages.remove', {
+          page_id: page.pageId,
+          revision_id: page.revisionId,
+          recursively,
+          completely,
+        }) as IDeleteSinglePageApiv1Result;
+
+        if (pageDeleteModalOpened != null && pageDeleteModalOpened.onDeleted != null) {
+          pageDeleteModalOpened.onDeleted(path, isRecursively, isCompletely);
+        }
+      }
+      catch (err) {
+        setErrs([err]);
+      }
+    }
   }
 
   async function deleteButtonHandler() {
-    deletePage();
+    await closeDeleteModal();
+    await deletePage();
   }
 
   function renderDeleteRecursivelyForm() {
@@ -96,10 +130,9 @@ const PageDeleteModal: FC<Props> = (props: Props) => {
           className="custom-control-input"
           id="deleteRecursively"
           type="checkbox"
-          // checked={isDeleteRecursively}
-          checked={false}
+          checked={isDeleteRecursively}
           onChange={changeIsDeleteRecursivelyHandler}
-          disabled // Todo: enable this at https://redmine.weseek.co.jp/issues/82222
+          // disabled // Todo: enable this at https://redmine.weseek.co.jp/issues/82222
         />
         <label className="custom-control-label" htmlFor="deleteRecursively">
           { t('modal_delete.delete_recursively') }
@@ -123,7 +156,7 @@ const PageDeleteModal: FC<Props> = (props: Props) => {
           id="deleteCompletely"
           type="checkbox"
           // disabled={!isAbleToDeleteCompletely}
-          disabled // Todo: will be implemented at https://redmine.weseek.co.jp/issues/82222
+          // disabled // Todo: will be implemented at https://redmine.weseek.co.jp/issues/82222
           checked={isDeleteCompletely}
           onChange={changeIsDeleteCompletelyHandler}
         />
@@ -144,8 +177,8 @@ const PageDeleteModal: FC<Props> = (props: Props) => {
   }
 
   const renderPagePathsToDelete = () => {
-    if (pagesDataToDelete != null && pagesDataToDelete.pages != null) {
-      return pagesDataToDelete.pages.map(page => <div key={page.pageId}><code>{ page.path }</code></div>);
+    if (deleteModalStatus != null && deleteModalStatus.pages != null) {
+      return deleteModalStatus.pages.map(page => <div key={page.pageId}><code>{ page.path }</code></div>);
     }
     return <></>;
   };

+ 1 - 1
packages/app/src/components/SearchPage.jsx

@@ -352,7 +352,7 @@ class SearchPage extends React.Component {
           activePage={this.state.activePage}
         >
         </SearchPageLayout>
-        {/* TODO: show PageDeleteModal with usePageDeleteModalStatus by 87569  */}
+        {/* TODO: show PageDeleteModal with usePageDeleteModal by 87569  */}
         <PageDeleteModal
           isOpen={this.state.isDeleteConfirmModalShown}
           onClose={this.closeDeleteConfirmModalHandler}

+ 2 - 2
packages/app/src/components/SearchPage/SearchResultContent.tsx

@@ -14,7 +14,7 @@ import { GrowiSubNavigation } from '../Navbar/GrowiSubNavigation';
 import { SubNavButtons } from '../Navbar/SubNavButtons';
 import { AdditionalMenuItemsRendererProps } from '../Common/Dropdown/PageItemControl';
 
-import { usePageDuplicateModalStatus, usePageRenameModalStatus, usePageDeleteModalStatus } from '~/stores/ui';
+import { usePageDuplicateModalStatus, usePageRenameModalStatus, usePageDeleteModal } from '~/stores/ui';
 
 
 type AdditionalMenuItemsProps = AdditionalMenuItemsRendererProps & {
@@ -57,7 +57,7 @@ const SearchResultContent: FC<Props> = (props: Props) => {
 
   const { open: openDuplicateModal } = usePageDuplicateModalStatus();
   const { open: openRenameModal } = usePageRenameModalStatus();
-  const { open: openDeleteModal } = usePageDeleteModalStatus();
+  const { open: openDeleteModal } = usePageDeleteModal();
 
   const page = focusedSearchResultData?.pageData;
 

+ 94 - 20
packages/app/src/components/Sidebar.tsx

@@ -20,12 +20,18 @@ import StickyStretchableScroller from './StickyStretchableScroller';
 
 const sidebarMinWidth = 240;
 const sidebarMinimizeWidth = 20;
+const sidebarFixedWidthInDrawerMode = 320;
+
 
 const GlobalNavigation = () => {
+  const { data: isDrawerMode } = useDrawerMode();
   const { data: currentContents } = useCurrentSidebarContents();
   const { data: isCollapsed, mutate: mutateSidebarCollapsed } = useSidebarCollapsed();
 
   const itemSelectedHandler = useCallback((selectedContents) => {
+    if (isDrawerMode) {
+      return;
+    }
 
     let newValue = false;
 
@@ -38,7 +44,7 @@ const GlobalNavigation = () => {
     mutateSidebarCollapsed(newValue, false);
     scheduleToPutUserUISettings({ isSidebarCollapsed: newValue });
 
-  }, [currentContents, isCollapsed, mutateSidebarCollapsed]);
+  }, [currentContents, isCollapsed, isDrawerMode, mutateSidebarCollapsed]);
 
   return <SidebarNav onItemSelected={itemSelectedHandler} />;
 };
@@ -90,8 +96,13 @@ const Sidebar: FC<Props> = (props: Props) => {
   const [isTransitionEnabled, setTransitionEnabled] = useState(false);
 
   const [isHover, setHover] = useState(false);
+  const [isHoverOnResizableContainer, setHoverOnResizableContainer] = useState(false);
   const [isDragging, setDrag] = useState(false);
 
+  const resizableContainer = useRef<HTMLDivElement>(null);
+
+  const timeoutIdRef = useRef<NodeJS.Timeout>();
+
   const isResizableByDrag = !isResizeDisabled && !isDrawerMode && (!isCollapsed || isHover);
 
   const toggleDrawerMode = useCallback((bool) => {
@@ -116,32 +127,21 @@ const Sidebar: FC<Props> = (props: Props) => {
     mutateDrawerOpened(false, false);
   }, [mutateDrawerOpened]);
 
-  useEffect(() => {
-    setTimeout(() => {
-      setTransitionEnabled(true);
-    }, 1000);
-  }, []);
 
-  useEffect(() => {
-    toggleDrawerMode(isDrawerMode);
-  }, [isDrawerMode, toggleDrawerMode]);
-
-  const resizableContainer = useRef<HTMLDivElement>(null);
-  const setContentWidth = useCallback((newWidth) => {
+  const setContentWidth = useCallback((newWidth: number) => {
     if (resizableContainer.current == null) {
       return;
     }
     resizableContainer.current.style.width = `${newWidth}px`;
   }, []);
 
-  const hoverOnResizableContainerHandler = useCallback(() => {
+  const hoverOnHandler = useCallback(() => {
     if (!isCollapsed || isDrawerMode || isDragging) {
       return;
     }
 
     setHover(true);
-    setContentWidth(currentProductNavWidth);
-  }, [isCollapsed, isDrawerMode, isDragging, setContentWidth, currentProductNavWidth]);
+  }, [isCollapsed, isDragging, isDrawerMode]);
 
   const hoverOutHandler = useCallback(() => {
     if (!isCollapsed || isDrawerMode || isDragging) {
@@ -149,8 +149,23 @@ const Sidebar: FC<Props> = (props: Props) => {
     }
 
     setHover(false);
-    setContentWidth(sidebarMinimizeWidth);
-  }, [isCollapsed, isDragging, isDrawerMode, setContentWidth]);
+  }, [isCollapsed, isDragging, isDrawerMode]);
+
+  const hoverOnResizableContainerHandler = useCallback(() => {
+    if (!isCollapsed || isDrawerMode || isDragging) {
+      return;
+    }
+
+    setHoverOnResizableContainer(true);
+  }, [isCollapsed, isDrawerMode, isDragging]);
+
+  const hoverOutResizableContainerHandler = useCallback(() => {
+    if (!isCollapsed || isDrawerMode || isDragging) {
+      return;
+    }
+
+    setHoverOnResizableContainer(false);
+  }, [isCollapsed, isDrawerMode, isDragging]);
 
   const toggleNavigationBtnClickHandler = useCallback(() => {
     const newValue = !isCollapsed;
@@ -163,7 +178,8 @@ const Sidebar: FC<Props> = (props: Props) => {
       setContentWidth(sidebarMinimizeWidth);
     }
     else {
-      setContentWidth(currentProductNavWidth);
+      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+      setContentWidth(currentProductNavWidth!);
     }
   }, [currentProductNavWidth, isCollapsed, setContentWidth]);
 
@@ -222,11 +238,68 @@ const Sidebar: FC<Props> = (props: Props) => {
 
   }, [dragableAreaMouseUpHandler, draggableAreaMoveHandler, isResizableByDrag]);
 
+  useEffect(() => {
+    setTimeout(() => {
+      setTransitionEnabled(true);
+    }, 1000);
+  }, []);
+
+  useEffect(() => {
+    toggleDrawerMode(isDrawerMode);
+  }, [isDrawerMode, toggleDrawerMode]);
+
+  // open/close resizable container
+  useEffect(() => {
+    if (!isCollapsed) {
+      return;
+    }
+
+    if (isHoverOnResizableContainer) {
+      // schedule to open
+      timeoutIdRef.current = setTimeout(() => {
+        // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+        setContentWidth(currentProductNavWidth!);
+      }, 70);
+    }
+    else if (timeoutIdRef.current != null) {
+      // cancel schedule to open
+      clearTimeout(timeoutIdRef.current);
+      timeoutIdRef.current = undefined;
+    }
+
+    // close
+    if (!isHover) {
+      setContentWidth(sidebarMinimizeWidth);
+      timeoutIdRef.current = undefined;
+    }
+  }, [isCollapsed, isHover, isHoverOnResizableContainer, currentProductNavWidth, setContentWidth]);
+
+  // open/close resizable container when drawer mode
+  useEffect(() => {
+    if (isDrawerMode) {
+      setContentWidth(sidebarFixedWidthInDrawerMode);
+    }
+    else if (isCollapsed) {
+      setContentWidth(sidebarMinimizeWidth);
+    }
+    else {
+      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+      setContentWidth(currentProductNavWidth!);
+    }
+  }, [currentProductNavWidth, isCollapsed, isDrawerMode, setContentWidth]);
+
+
+  const showContents = isDrawerMode || isHover || !isCollapsed;
+
   return (
     <>
       <div className={`grw-sidebar d-print-none ${isDrawerMode ? 'grw-sidebar-drawer' : ''} ${isDrawerOpened ? 'open' : ''}`}>
         <div className="data-layout-container">
-          <div className={`navigation ${isTransitionEnabled ? 'transition-enabled' : ''}`} onMouseLeave={hoverOutHandler}>
+          <div
+            className={`navigation ${isTransitionEnabled ? 'transition-enabled' : ''}`}
+            onMouseEnter={hoverOnHandler}
+            onMouseLeave={hoverOutHandler}
+          >
             <div className="grw-navigation-wrap">
               <div className="grw-global-navigation">
                 <GlobalNavigation></GlobalNavigation>
@@ -235,10 +308,11 @@ const Sidebar: FC<Props> = (props: Props) => {
                 ref={resizableContainer}
                 className="grw-contextual-navigation"
                 onMouseEnter={hoverOnResizableContainerHandler}
+                onMouseLeave={hoverOutResizableContainerHandler}
                 style={{ width: isCollapsed ? sidebarMinimizeWidth : currentProductNavWidth }}
               >
                 <div className="grw-contextual-navigation-child">
-                  <div role="group" className={`grw-contextual-navigation-sub ${!isHover && isCollapsed ? 'collapsed' : ''}`}>
+                  <div role="group" className={`grw-contextual-navigation-sub ${showContents ? '' : 'd-none'}`}>
                     <SidebarContentsWrapper></SidebarContentsWrapper>
                   </div>
                 </div>

+ 15 - 17
packages/app/src/components/Sidebar/PageTree/Item.tsx

@@ -7,6 +7,9 @@ import { useTranslation } from 'react-i18next';
 import { useDrag, useDrop } from 'react-dnd';
 
 import nodePath from 'path';
+
+import { pathUtils } from '@growi/core';
+
 import { toastWarning, toastError } from '~/client/util/apiNotification';
 
 import { useSWRxPageChildren } from '~/stores/page-listing';
@@ -19,7 +22,6 @@ import ClosableTextInput, { AlertInfo, AlertType } from '../../Common/ClosableTe
 import { AsyncPageItemControl } from '../../Common/Dropdown/PageItemControl';
 import { ItemNode } from './ItemNode';
 
-
 interface ItemProps {
   isEnableActions: boolean
   itemNode: ItemNode
@@ -27,7 +29,7 @@ interface ItemProps {
   isOpen?: boolean
   onClickDuplicateMenuItem?(pageId: string, path: string): void
   onClickRenameMenuItem?(pageId: string, revisionId: string, path: string): void
-  onClickDeleteByPage?(pageToDelete: IPageForPageDeleteModal | null): void
+  onClickDeleteMenuItem?(pageToDelete: IPageForPageDeleteModal | null): void
 }
 
 // Utility to mark target
@@ -68,7 +70,7 @@ const ItemCount: FC<ItemCountProps> = (props:ItemCountProps) => {
 const Item: FC<ItemProps> = (props: ItemProps) => {
   const { t } = useTranslation();
   const {
-    itemNode, targetPathOrId, isOpen: _isOpen = false, onClickDuplicateMenuItem, onClickRenameMenuItem, onClickDeleteByPage, isEnableActions,
+    itemNode, targetPathOrId, isOpen: _isOpen = false, onClickDuplicateMenuItem, onClickRenameMenuItem, onClickDeleteMenuItem, isEnableActions,
   } = props;
 
   const { page, children } = itemNode;
@@ -151,11 +153,7 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
   // }, []);
 
   // const onPressEnterForRenameHandler = async(inputText: string) => {
-  //   if (inputText == null || inputText === '' || inputText.trim() === '' || inputText.includes('/')) {
-  //     return;
-  //   }
-
-  //   const parentPath = nodePath.dirname(page.path as string);
+  //   const parentPath = getParentPagePath(page.path as string)
   //   const newPagePath = `${parentPath}/${inputText}`;
 
   //   try {
@@ -185,9 +183,8 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
     onClickRenameMenuItem(pageId, revisionId as string, path);
   }, [onClickRenameMenuItem, page]);
 
-
   const onClickDeleteButton = useCallback(async(_pageId: string): Promise<void> => {
-    if (onClickDeleteByPage == null) {
+    if (onClickDeleteMenuItem == null) {
       return;
     }
 
@@ -203,14 +200,15 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
       path,
     };
 
-    onClickDeleteByPage(pageToDelete);
-  }, [page, onClickDeleteByPage]);
-
+    onClickDeleteMenuItem(pageToDelete);
+  }, [page, onClickDeleteMenuItem]);
 
-  // TODO: go to create page page
-  const onPressEnterForCreateHandler = () => {
-    toastWarning(t('search_result.currently_not_implemented'));
+  const onPressEnterForCreateHandler = (inputText: string) => {
     setNewPageInputShown(false);
+    const parentPath = pathUtils.addTrailingSlash(page.path as string);
+    const newPagePath = `${parentPath}${inputText}`;
+    console.log(newPagePath);
+    // TODO: https://redmine.weseek.co.jp/issues/87943
   };
 
   const inputValidator = (title: string | null): AlertInfo | null => {
@@ -340,7 +338,7 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
               targetPathOrId={targetPathOrId}
               onClickDuplicateMenuItem={onClickDuplicateMenuItem}
               onClickRenameMenuItem={onClickRenameMenuItem}
-              onClickDeleteByPage={onClickDeleteByPage}
+              onClickDeleteMenuItem={onClickDeleteMenuItem}
             />
           </div>
         ))

+ 50 - 10
packages/app/src/components/Sidebar/PageTree/ItemsTree.tsx

@@ -1,14 +1,17 @@
-import React, { FC } from 'react';
+import React, { FC, useEffect } from 'react';
+import { useTranslation } from 'react-i18next';
 
 import { IPageHasId } from '../../../interfaces/page';
 import { ItemNode } from './ItemNode';
 import Item from './Item';
 import { useSWRxPageAncestorsChildren, useSWRxRootPage } from '../../../stores/page-listing';
 import { TargetAndAncestors } from '~/interfaces/page-listing-results';
-import { toastError } from '~/client/util/apiNotification';
+import { toastError, toastSuccess } from '~/client/util/apiNotification';
 import {
-  IPageForPageDeleteModal, usePageDuplicateModalStatus, usePageRenameModalStatus, usePageDeleteModalStatus,
+  IPageForPageDeleteModal, usePageDuplicateModalStatus, usePageRenameModalStatus, usePageDeleteModal,
+  OnDeletedFunction,
 } from '~/stores/ui';
+import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
 
 /*
  * Utility to generate initial node
@@ -62,7 +65,7 @@ const renderByInitialNode = (
     targetPathOrId?: string,
     onClickDuplicateMenuItem?: (pageId: string, path: string) => void,
     onClickRenameMenuItem?: (pageId: string, revisionId: string, path: string) => void,
-    onClickDeleteByPage?: (pageToDelete: IPageForPageDeleteModal | null) => void,
+    onClickDeleteMenuItem?: (pageToDelete: IPageForPageDeleteModal | null) => void,
 ): JSX.Element => {
 
   return (
@@ -75,7 +78,7 @@ const renderByInitialNode = (
         isEnableActions={isEnableActions}
         onClickDuplicateMenuItem={onClickDuplicateMenuItem}
         onClickRenameMenuItem={onClickRenameMenuItem}
-        onClickDeleteByPage={onClickDeleteByPage}
+        onClickDeleteMenuItem={onClickDeleteMenuItem}
       />
     </ul>
   );
@@ -90,11 +93,22 @@ const ItemsTree: FC<ItemsTreeProps> = (props: ItemsTreeProps) => {
     targetPath, targetPathOrId, targetAndAncestorsData, isEnableActions,
   } = props;
 
+  const { t } = useTranslation();
+
   const { data: ancestorsChildrenData, error: error1 } = useSWRxPageAncestorsChildren(targetPath);
   const { data: rootPageData, error: error2 } = useSWRxRootPage();
   const { open: openDuplicateModal } = usePageDuplicateModalStatus();
   const { open: openRenameModal } = usePageRenameModalStatus();
-  const { open: openDeleteModal } = usePageDeleteModalStatus();
+  const { open: openDeleteModal } = usePageDeleteModal();
+
+  useEffect(() => {
+    const startFrom = document.getElementById('grw-sidebar-contents-scroll-target');
+    const targetElem = document.getElementsByClassName('grw-pagetree-is-target');
+    //  targetElem is HTML collection but only one HTML element in it all the time
+    if (targetElem[0] != null && startFrom != null) {
+      smoothScrollIntoView(targetElem[0] as HTMLElement, 0, startFrom);
+    }
+  }, [ancestorsChildrenData]);
 
   const onClickDuplicateMenuItem = (pageId: string, path: string) => {
     openDuplicateModal(pageId, path);
@@ -104,8 +118,34 @@ const ItemsTree: FC<ItemsTreeProps> = (props: ItemsTreeProps) => {
     openRenameModal(pageId, revisionId, path);
   };
 
-  const onClickDeleteByPage = (pageToDelete: IPageForPageDeleteModal) => {
-    openDeleteModal([pageToDelete]);
+  const onDeletedHandler: OnDeletedFunction = (pathOrPathsToDelete, isRecursively, isCompletely) => {
+    if (typeof pathOrPathsToDelete !== 'string') {
+      return;
+    }
+
+    const path = pathOrPathsToDelete;
+
+    if (isRecursively) {
+      if (isCompletely) {
+        toastSuccess(t('deleted_single_page_recursively_completely', { path }));
+      }
+      else {
+        toastSuccess(t('deleted_single_page_recursively', { path }));
+      }
+    }
+    else {
+      // eslint-disable-next-line no-lonely-if
+      if (isCompletely) {
+        toastSuccess(t('deleted_single_page_completely', { path }));
+      }
+      else {
+        toastSuccess(t('deleted_single_page', { path }));
+      }
+    }
+  };
+
+  const onClickDeleteMenuItem = (pageToDelete: IPageForPageDeleteModal) => {
+    openDeleteModal([pageToDelete], onDeletedHandler);
   };
 
   if (error1 != null || error2 != null) {
@@ -119,7 +159,7 @@ const ItemsTree: FC<ItemsTreeProps> = (props: ItemsTreeProps) => {
    */
   if (ancestorsChildrenData != null && rootPageData != null) {
     const initialNode = generateInitialNodeAfterResponse(ancestorsChildrenData.ancestorsChildren, new ItemNode(rootPageData.rootPage));
-    return renderByInitialNode(initialNode, isEnableActions, targetPathOrId, onClickDuplicateMenuItem, onClickRenameMenuItem, onClickDeleteByPage);
+    return renderByInitialNode(initialNode, isEnableActions, targetPathOrId, onClickDuplicateMenuItem, onClickRenameMenuItem, onClickDeleteMenuItem);
   }
 
   /*
@@ -127,7 +167,7 @@ const ItemsTree: FC<ItemsTreeProps> = (props: ItemsTreeProps) => {
    */
   if (targetAndAncestorsData != null) {
     const initialNode = generateInitialNodeBeforeResponse(targetAndAncestorsData.targetAndAncestors);
-    return renderByInitialNode(initialNode, isEnableActions, targetPathOrId, onClickDuplicateMenuItem, onClickRenameMenuItem, onClickDeleteByPage);
+    return renderByInitialNode(initialNode, isEnableActions, targetPathOrId, onClickDuplicateMenuItem, onClickRenameMenuItem, onClickDeleteMenuItem);
   }
 
   return null;

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

@@ -34,8 +34,8 @@ const TableOfContents = (props) => {
     const containerComputedStyle = getComputedStyle(containerElem);
     const containerPaddingTop = parseFloat(containerComputedStyle['padding-top']);
 
-    // get smaller bottom line of window height - the height of ContentLinkButtons and .system-version height) and containerTop
-    let bottom = Math.min(window.innerHeight - 41 - 20, parentBottom);
+    // get smaller bottom line of window height - .system-version height - margin 5px) and containerTop
+    let bottom = Math.min(window.innerHeight - 20 - 5, parentBottom);
 
     if (isUserPage) {
       // raise the bottom line by the height and margin-top of UserContentLinks

+ 3 - 3
packages/app/src/components/User/SeenUserInfo.tsx

@@ -1,6 +1,6 @@
 import React, { FC, useState } from 'react';
 
-import { Button, Popover, PopoverBody } from 'reactstrap';
+import { Popover, PopoverBody } from 'reactstrap';
 import { FootstampIcon } from '@growi/ui';
 
 import { IUser } from '~/interfaces/user';
@@ -21,12 +21,12 @@ const SeenUserInfo: FC<Props> = (props: Props) => {
 
   return (
     <div className="grw-seen-user-info">
-      <Button id="btn-seen-user" color="link" className="btn-seen-user">
+      <button type="button" id="btn-seen-user" className="btn btn-seen-user border-0">
         <span className="mr-1 footstamp-icon">
           <FootstampIcon />
         </span>
         <span className="seen-user-count">{seenUsers.length}</span>
-      </Button>
+      </button>
       <Popover placement="bottom" isOpen={isPopoverOpen} target="btn-seen-user" toggle={togglePopover} trigger="legacy" disabled={disabled}>
         <PopoverBody className="user-list-popover">
           <div className="px-2 text-right user-list-content text-truncate text-muted">

+ 2 - 0
packages/app/src/interfaces/common.ts

@@ -7,3 +7,5 @@ import { HasObjectId } from './has-object-id';
 
 // Foreign key field
 export type Ref<T> = string | T & HasObjectId;
+
+export type Nullable<T> = T | null | undefined;

+ 14 - 1
packages/app/src/interfaces/page.ts

@@ -1,4 +1,4 @@
-import { Ref } from './common';
+import { Ref, Nullable } from './common';
 import { IUser } from './user';
 import { IRevision, HasRevisionShortbody } from './revision';
 import { ITag } from './tag';
@@ -97,3 +97,16 @@ export type IPageWithMeta<M = IPageInfoAll> = {
   pageData: IPageHasId,
   pageMeta?: M,
 };
+
+export type IDeleteSinglePageApiv1Result = {
+  ok: boolean
+  path: string,
+  isRecursively: Nullable<true>,
+  isCompletely: Nullable<true>,
+};
+
+export type IDeleteManyPageApiv3Result = {
+  paths: string[],
+  isRecursively: Nullable<true>,
+  isCompletely: Nullable<true>,
+};

+ 11 - 0
packages/app/src/interfaces/ui.ts

@@ -6,3 +6,14 @@ export const SidebarContentsType = {
 } as const;
 export const AllSidebarContentsType = Object.values(SidebarContentsType);
 export type SidebarContentsType = typeof SidebarContentsType[keyof typeof SidebarContentsType];
+
+
+export type ICustomTabContent = {
+  Content: () => JSX.Element,
+  i18n: string,
+  Icon?: () => JSX.Element,
+  index?: number,
+  isLinkEnabled?: boolean | ((content: ICustomTabContent) => boolean),
+};
+
+export type ICustomNavTabMappings = { [key: string]: ICustomTabContent };

+ 9 - 0
packages/app/src/interfaces/user-group-response.ts

@@ -1,4 +1,5 @@
 import { IUserGroupHasId, IUserGroupRelationHasId } from './user';
+import { IPageHasId } from './page';
 
 export type UserGroupListResult = {
   userGroups: IUserGroupHasId[],
@@ -11,3 +12,11 @@ export type ChildUserGroupListResult = {
 export type UserGroupRelationListResult = {
   userGroupRelations: IUserGroupRelationHasId[],
 };
+
+export type UserGroupPagesResult = {
+  pages: IPageHasId[],
+}
+
+export type SelectableUserGroupsResult = {
+  selectableUserGroups: IUserGroupHasId[],
+}

+ 2 - 2
packages/app/src/server/models/obsolete-page.js

@@ -738,11 +738,11 @@ export const getPageSchema = (crowi) => {
     return await findListFromBuilderAndViewer(builder, currentUser, showAnyoneKnowsLink, opt);
   };
 
-  pageSchema.statics.findListByPageIds = async function(ids, option, excludeRedirect = true) {
+  pageSchema.statics.findListByPageIds = async function(ids, option = {}, shouldIncludeEmpty = false) {
     const User = crowi.model('User');
 
     const opt = Object.assign({}, option);
-    const builder = new PageQueryBuilder(this.find({ _id: { $in: ids } }));
+    const builder = new PageQueryBuilder(this.find({ _id: { $in: ids } }), shouldIncludeEmpty);
 
     builder.addConditionToPagenate(opt.offset, opt.limit);
 

+ 6 - 0
packages/app/src/server/models/page-redirect.ts

@@ -26,4 +26,10 @@ const schema = new Schema<PageRedirectDocument, PageRedirectModel>({
   toPath: { type: String, required: true },
 });
 
+schema.statics.removePageRedirectByToPath = async function(toPath: string): Promise<void> {
+  await this.deleteMany({ toPath });
+
+  return;
+};
+
 export default getOrCreateModel<PageRedirectDocument, PageRedirectModel>('PageRedirect', schema);

+ 66 - 4
packages/app/src/server/models/page.ts

@@ -459,8 +459,10 @@ schema.statics.incrementDescendantCountOfPageIds = async function(pageIds: Objec
   await this.updateMany({ _id: { $in: pageIds } }, { $inc: { descendantCount: increment } });
 };
 
-// update descendantCount of a page with provided id
-schema.statics.recountDescendantCountOfSelfAndDescendants = async function(id: ObjectIdLike):Promise<void> {
+/**
+ * recount descendantCount of a page with the provided id and return it
+ */
+schema.statics.recountDescendantCount = async function(id: ObjectIdLike):Promise<number> {
   const res = await this.aggregate(
     [
       {
@@ -498,8 +500,7 @@ schema.statics.recountDescendantCountOfSelfAndDescendants = async function(id: O
     ],
   );
 
-  const query = { descendantCount: res.length === 0 ? 0 : res[0].descendantCount };
-  await this.findByIdAndUpdate(id, query);
+  return res.length === 0 ? 0 : res[0].descendantCount;
 };
 
 schema.statics.findAncestorsUsingParentRecursively = async function(pageId: ObjectIdLike, shouldIncludeTarget: boolean) {
@@ -518,6 +519,64 @@ schema.statics.findAncestorsUsingParentRecursively = async function(pageId: Obje
   return findAncestorsRecursively(target);
 };
 
+// TODO: write test code
+/**
+ * Recursively removes empty pages at leaf position.
+ * @param pageId ObjectIdLike
+ * @returns Promise<void>
+ */
+schema.statics.removeLeafEmptyPagesById = async function(pageId: ObjectIdLike): Promise<void> {
+  const self = this;
+
+  const initialLeafPage = await this.findById(pageId);
+
+  if (initialLeafPage == null) {
+    return;
+  }
+
+  if (!initialLeafPage.isEmpty) {
+    return;
+  }
+
+  async function generatePageIdsToRemove(page, pageIds: ObjectIdLike[]): Promise<ObjectIdLike[]> {
+    const nextPage = await self.findById(page.parent);
+
+    if (nextPage == null) {
+      return pageIds;
+    }
+
+    // delete leaf empty pages
+    const isNextPageEmpty = nextPage.isEmpty;
+
+    if (!isNextPageEmpty) {
+      return pageIds;
+    }
+
+    const isSiblingsExist = await self.exists({ parent: nextPage.parent, _id: { $ne: nextPage._id } });
+    if (isSiblingsExist) {
+      return pageIds;
+    }
+
+    return generatePageIdsToRemove(nextPage, [...pageIds, nextPage._id]);
+  }
+
+  const initialPageIdsToRemove = [initialLeafPage._id];
+
+  const pageIdsToRemove = await generatePageIdsToRemove(initialLeafPage, initialPageIdsToRemove);
+
+  await this.deleteMany({ _id: { $in: pageIdsToRemove } });
+};
+
+schema.statics.findByPageIdsToEdit = async function(ids, user, shouldIncludeEmpty = false) {
+  const builder = new PageQueryBuilder(this.find({ _id: { $in: ids } }), shouldIncludeEmpty);
+
+  await this.addConditionToFilteringByViewerToEdit(builder, user);
+
+  const pages = await builder.query.lean().exec();
+
+  return pages;
+};
+
 export type PageCreateOptions = {
   format?: string
   grantUserGroupId?: ObjectIdLike
@@ -593,6 +652,9 @@ export default (crowi: Crowi): any => {
     let page;
     if (emptyPage != null) {
       page = emptyPage;
+      const descendantCount = await this.recountDescendantCount(page._id);
+
+      page.descendantCount = descendantCount;
       page.isEmpty = false;
     }
     else {

+ 14 - 1
packages/app/src/server/routes/apiv3/import.js

@@ -1,3 +1,5 @@
+import mongoose from 'mongoose';
+
 import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:routes:apiv3:import'); // eslint-disable-line no-unused-vars
@@ -204,8 +206,19 @@ module.exports = (crowi) => {
    */
   router.post('/', accessTokenParser, loginRequired, adminRequired, csrf, async(req, res) => {
     // TODO: add express validator
-
     const { fileName, collections, optionsMap } = req.body;
+
+    // pages collection can only be imported by upsert if isV5Compatible is true
+    const isV5Compatible = crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
+    const isImportPagesCollection = collections.includes('pages');
+    if (isV5Compatible && isImportPagesCollection) {
+      const option = new GrowiArchiveImportOption(null, optionsMap.pages);
+      if (option.mode !== 'upsert') {
+        return res.apiv3Err(new ErrorV3('Upsert is only available for importing pages collection.', 'only_upsert_available'));
+      }
+    }
+
+
     const zipFile = importService.getFile(fileName);
 
     // return response first

+ 8 - 0
packages/app/src/server/routes/apiv3/overwrite-params/pages.js

@@ -1,4 +1,8 @@
 const mongoose = require('mongoose');
+const { format } = require('date-fns');
+const { pagePathUtils } = require('@growi/core');
+
+const { isTopPage } = pagePathUtils;
 
 // eslint-disable-next-line no-unused-vars
 const ImportOptionForPages = require('~/models/admin/import-option-for-pages');
@@ -45,6 +49,10 @@ class PageOverwriteParamsFactory {
       return null;
     };
 
+    params.descendantCount = (value, { document, schema, propertyName }) => {
+      return 0;
+    };
+
     if (option.initPageMetadatas) {
       params.liker = [];
       params.seenUsers = [];

+ 13 - 3
packages/app/src/server/routes/apiv3/page.js

@@ -159,6 +159,7 @@ module.exports = (crowi) => {
   const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
   const csrf = require('../../middlewares/csrf')(crowi);
   const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
+  const certifySharedPage = require('../../middlewares/certify-shared-page')(crowi);
 
   const globalNotificationService = crowi.getGlobalNotificationService();
   const socketIoService = crowi.socketIoService;
@@ -235,7 +236,7 @@ module.exports = (crowi) => {
     const { pageId, path } = req.query;
 
     if (pageId == null && path == null) {
-      return res.apiv3Err(new ErrorV3('Parameter pagePath or pageId is required.', 'invalid-request'));
+      return res.apiv3Err(new ErrorV3('Parameter path or pageId is required.', 'invalid-request'));
     }
 
     let result = {};
@@ -354,8 +355,8 @@ module.exports = (crowi) => {
    *          500:
    *            description: Internal server error.
    */
-  router.get('/info', loginRequired, validator.info, apiV3FormValidator, async(req, res) => {
-    const { user } = req;
+  router.get('/info', certifySharedPage, loginRequired, validator.info, apiV3FormValidator, async(req, res) => {
+    const { user, isSharedPage } = req;
     const { pageId } = req.query;
 
     try {
@@ -365,6 +366,15 @@ module.exports = (crowi) => {
         return res.apiv3Err(`Page '${pageId}' is not found or forbidden`);
       }
 
+      if (isSharedPage) {
+        return {
+          isEmpty: page.isEmpty,
+          isMovable: false,
+          isDeletable: false,
+          isAbleToDeleteCompletely: false,
+        };
+      }
+
       const isGuestUser = !req.user;
       const pageInfo = pageService.constructBasicPageInfo(page, isGuestUser);
 

+ 65 - 4
packages/app/src/server/routes/apiv3/pages.js

@@ -18,6 +18,7 @@ const { isCreatablePage } = pagePathUtils;
 const router = express.Router();
 
 const LIMIT_FOR_LIST = 10;
+const LIMIT_FOR_MULTIPLE_PAGE_OP = 20;
 
 /**
  * @swagger
@@ -180,6 +181,17 @@ module.exports = (crowi) => {
       body('pageNameInput').trim().isLength({ min: 1 }).withMessage('pageNameInput is required'),
       body('isRecursively').if(value => value != null).isBoolean().withMessage('isRecursively must be boolean'),
     ],
+    deletePages: [
+      body('pageIdToRevisionIdMap')
+        .exists()
+        .withMessage('The body property "pageIdToRevisionIdMap" must be an json map with pageId as key and revisionId as value.'),
+      body('isCompletely')
+        .custom(v => v === 'true' || v === true || v == null)
+        .withMessage('The body property "isCompletely" must be "true" or true. (Omit param for false)'),
+      body('isRecursively')
+        .custom(v => v === 'true' || v === true || v == null)
+        .withMessage('The body property "isRecursively" must be "true" or true. (Omit param for false)'),
+    ],
     legacyPagesMigration: [
       body('pageIds').isArray().withMessage('pageIds is required'),
       body('isRecursively').isBoolean().withMessage('isRecursively is required'),
@@ -708,14 +720,58 @@ module.exports = (crowi) => {
 
   });
 
+  router.post('/delete', accessTokenParser, loginRequiredStrictly, csrf, validator.deletePages, apiV3FormValidator, async(req, res) => {
+    const { pageIdToRevisionIdMap, isCompletely, isRecursively } = req.body;
+    const pageIds = Object.keys(pageIdToRevisionIdMap);
+
+    if (pageIds.length === 0) {
+      return res.apiv3Err(new ErrorV3('Select pages to delete.', 'no_page_selected'), 400);
+    }
+    if (pageIds.length > LIMIT_FOR_MULTIPLE_PAGE_OP) {
+      return res.apiv3Err(new ErrorV3(`The maximum number of pages you can select is ${LIMIT_FOR_MULTIPLE_PAGE_OP}.`, 'exceeded_maximum_number'), 400);
+    }
+
+    let pagesToDelete;
+    try {
+      pagesToDelete = await Page.findByPageIdsToEdit(pageIds, req.user, true);
+    }
+    catch (err) {
+      logger.error('Failed to find pages to delete.', err);
+      return res.apiv3Err(new ErrorV3('Failed to find pages to delete.'));
+    }
+
+    let pagesCanBeDeleted;
+    /*
+     * Delete Completely
+     */
+    if (isCompletely) {
+      pagesCanBeDeleted = crowi.pageService.filterPagesByCanDeleteCompletely(pagesToDelete, req.user);
+    }
+    /*
+     * Trash
+     */
+    else {
+      pagesCanBeDeleted = pagesToDelete.filter(p => p.isEmpty || p.isUpdatable(pageIdToRevisionIdMap[p._id].toString()));
+    }
+
+    if (pagesCanBeDeleted.length === 0) {
+      const msg = 'No pages can be deleted.';
+      return res.apiv3Err(new ErrorV3(msg), 500);
+    }
+
+    // run delete
+    crowi.pageService.deleteMultiplePages(pagesCanBeDeleted, req.user, isCompletely, isRecursively);
+
+    return res.apiv3({ paths: pagesCanBeDeleted.map(p => p.path), isRecursively, isCompletely });
+  });
+
   router.post('/v5-schema-migration', accessTokenParser, loginRequired, adminRequired, csrf, async(req, res) => {
     const isV5Compatible = crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
-    const Page = crowi.model('Page');
 
     try {
       if (!isV5Compatible) {
         // this method throws and emit socketIo event when error occurs
-        crowi.pageService.v5InitialMigration(Page.GRANT_PUBLIC); // not await
+        crowi.pageService.normalizeAllPublicPages(); // not await
       }
     }
     catch (err) {
@@ -727,11 +783,16 @@ module.exports = (crowi) => {
 
   // eslint-disable-next-line max-len
   router.post('/legacy-pages-migration', accessTokenParser, loginRequired, adminRequired, csrf, validator.legacyPagesMigration, apiV3FormValidator, async(req, res) => {
-    const { pageIds, isRecursively } = req.body;
+    const { pageIds: _pageIds, isRecursively } = req.body;
+    const pageIds = _pageIds == null ? [] : _pageIds;
+
+    if (pageIds.length > LIMIT_FOR_MULTIPLE_PAGE_OP) {
+      return res.apiv3Err(new ErrorV3(`The maximum number of pages you can select is ${LIMIT_FOR_MULTIPLE_PAGE_OP}.`, 'exceeded_maximum_number'), 400);
+    }
 
     if (isRecursively) {
       // this method innerly uses socket to send message
-      crowi.pageService.normalizeParentRecursivelyByPageIds(pageIds);
+      crowi.pageService.normalizeParentRecursivelyByPageIds(pageIds, req.user);
     }
     else {
       try {

+ 94 - 41
packages/app/src/server/routes/apiv3/user-group.js

@@ -18,8 +18,6 @@ const ErrorV3 = require('../../models/vo/error-apiv3');
 const { serializeUserSecurely } = require('../../models/serializers/user-serializer');
 const { toPagingLimit, toPagingOffset } = require('../../util/express-validator/sanitizer');
 
-const validator = {};
-
 const { ObjectId } = mongoose.Types;
 
 
@@ -41,10 +39,48 @@ module.exports = (crowi) => {
     Page,
   } = crowi.models;
 
-  validator.listChildren = [
-    query('parentIds', 'parentIds must be an array').optional().isArray(),
-    query('includeGrandChildren', 'parentIds must be boolean').optional().isBoolean(),
-  ];
+  const validator = {
+    create: [
+      body('name', 'Group name is required').trim().exists({ checkFalsy: true }),
+      body('description', 'Description must be a string').optional().isString(),
+      body('parentId', 'ParentId must be a string').optional().isString(),
+    ],
+    update: [
+      body('name', 'Group name is required').trim().exists({ checkFalsy: true }),
+      body('description', 'Group description must be a string').optional().isString(),
+      body('parentId', 'parentId must be a string').optional().isString(),
+      body('forceUpdateParents', 'forceUpdateParents must be a boolean').optional().isBoolean(),
+    ],
+    delete: [
+      param('id').trim().exists({ checkFalsy: true }),
+      query('actionName').trim().exists({ checkFalsy: true }),
+      query('transferToUserGroupId').trim(),
+    ],
+    listChildren: [
+      query('parentIds', 'parentIds must be an array').optional().isArray(),
+      query('includeGrandChildren', 'parentIds must be boolean').optional().isBoolean(),
+    ],
+    selectableGroups: [
+      query('groupId', 'groupId must be a string').optional().isString(),
+    ],
+    users: {
+      post: [
+        param('id').trim().exists({ checkFalsy: true }),
+        param('username').trim().exists({ checkFalsy: true }),
+      ],
+      delete: [
+        param('id').trim().exists({ checkFalsy: true }),
+        param('username').trim().exists({ checkFalsy: true }),
+      ],
+    },
+    pages: {
+      get: [
+        param('id').trim().exists({ checkFalsy: true }),
+        sanitizeQuery('limit').customSanitizer(toPagingLimit),
+        sanitizeQuery('offset').customSanitizer(toPagingOffset),
+      ],
+    },
+  };
 
   /**
    * @swagger
@@ -108,11 +144,6 @@ module.exports = (crowi) => {
     }
   });
 
-  validator.create = [
-    body('name', 'Group name is required').trim().exists({ checkFalsy: true }),
-    body('description', 'Description must be a string').optional().isString(),
-    body('parentId', 'ParentId must be a string').optional().isString(),
-  ];
 
   /**
    * @swagger
@@ -161,11 +192,57 @@ module.exports = (crowi) => {
     }
   });
 
-  validator.delete = [
-    param('id').trim().exists({ checkFalsy: true }),
-    query('actionName').trim().exists({ checkFalsy: true }),
-    query('transferToUserGroupId').trim(),
-  ];
+  /**
+   * @swagger
+   *
+   *  paths:
+   *    /selectable-groups:
+   *      get:
+   *        tags: [UserGroup]
+   *        operationId: getSelectableGroups
+   *        summary: /selectable-groups
+   *        description: Get selectable user groups.
+   *        parameters:
+   *          - name: groupId
+   *            in: query
+   *            required: true
+   *            description: id of userGroup
+   *            schema:
+   *              type: string
+   *        responses:
+   *          200:
+   *            description: userGroups are fetched
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    userGroups:
+   *                      type: array
+   *                      items:
+   *                        type: object
+   *                      description: userGroup objects
+   */
+  router.get('/selectable-groups', loginRequiredStrictly, adminRequired, validator.selectableGroups, async(req, res) => {
+    const { groupId } = req.query;
+
+    try {
+      const userGroup = await UserGroup.findById(groupId);
+
+      const [ancestorGroups, descendantGroups] = await Promise.all([
+        UserGroup.findGroupsWithAncestorsRecursively(userGroup, []),
+        UserGroup.findGroupsWithDescendantsRecursively([userGroup], []),
+      ]);
+
+      const excludeUserGroupIds = [userGroup, ...ancestorGroups, ...descendantGroups].map(userGroups => userGroups._id.toString());
+      const selectableUserGroups = await UserGroup.find({ _id: { $nin: excludeUserGroupIds } });
+      return res.apiv3({ selectableUserGroups });
+    }
+    catch (err) {
+      const msg = 'Error occurred while searching user groups';
+      logger.error(msg, err);
+      return res.apiv3Err(new ErrorV3(msg, 'user-groups-search-failed'));
+    }
+  });
 
   /**
    * @swagger
@@ -221,13 +298,6 @@ module.exports = (crowi) => {
     }
   });
 
-  validator.update = [
-    body('name', 'Group name is required').trim().exists({ checkFalsy: true }),
-    body('description', 'Group description must be a string').optional().isString(),
-    body('parentId', 'parentId must be a string').optional().isString(),
-    body('forceUpdateParents', 'forceUpdateParents must be a boolean').optional().isBoolean(),
-  ];
-
   /**
    * @swagger
    *
@@ -265,7 +335,7 @@ module.exports = (crowi) => {
     try {
       const userGroup = await crowi.userGroupService.updateGroup(id, name, description, parentId, forceUpdateParents);
 
-      res.apiv3({ userGroup });
+      return res.apiv3({ userGroup });
     }
     catch (err) {
       const msg = 'Error occurred in updating a user group name';
@@ -274,7 +344,6 @@ module.exports = (crowi) => {
     }
   });
 
-  validator.users = {};
 
   /**
    * @swagger
@@ -387,10 +456,6 @@ module.exports = (crowi) => {
     }
   });
 
-  validator.users.post = [
-    param('id').trim().exists({ checkFalsy: true }),
-    param('username').trim().exists({ checkFalsy: true }),
-  ];
 
   /**
    * @swagger
@@ -457,10 +522,6 @@ module.exports = (crowi) => {
     }
   });
 
-  validator.users.delete = [
-    param('id').trim().exists({ checkFalsy: true }),
-    param('username').trim().exists({ checkFalsy: true }),
-  ];
 
   /**
    * @swagger
@@ -521,7 +582,6 @@ module.exports = (crowi) => {
     }
   });
 
-  validator.userGroupRelations = {};
 
   /**
    * @swagger
@@ -569,13 +629,6 @@ module.exports = (crowi) => {
     }
   });
 
-  validator.pages = {};
-
-  validator.pages.get = [
-    param('id').trim().exists({ checkFalsy: true }),
-    sanitizeQuery('limit').customSanitizer(toPagingLimit),
-    sanitizeQuery('offset').customSanitizer(toPagingOffset),
-  ];
 
   /**
    * @swagger

+ 17 - 10
packages/app/src/server/routes/page.js

@@ -338,6 +338,7 @@ module.exports = function(crowi, app) {
     const offset = parseInt(req.query.offset) || 0;
     await addRenderVarsForDescendants(renderVars, path, req.user, offset, limit, true);
     await addRenderVarsForPageTree(renderVars, pathOrId, req.user);
+
     addRenderVarsWhenNotFound(renderVars, pathOrId);
 
     return res.render(view, renderVars);
@@ -1159,8 +1160,12 @@ module.exports = function(crowi, app) {
   };
 
   validator.remove = [
-    body('completely').optional().custom(v => v === 'true' || v === true).withMessage('The body property "completely" must be "true" or true.'),
-    body('recursively').optional().custom(v => v === 'true' || v === true).withMessage('The body property "recursively" must be "true" or true.'),
+    body('completely')
+      .custom(v => v === 'true' || v === true || v == null)
+      .withMessage('The body property "completely" must be "true" or true. (Omit param for false)'),
+    body('recursively')
+      .custom(v => v === 'true' || v === true || v == null)
+      .withMessage('The body property "recursively" must be "true" or true. (Omit param for false)'),
   ];
 
   /**
@@ -1175,10 +1180,7 @@ module.exports = function(crowi, app) {
     const pageId = req.body.page_id;
     const previousRevision = req.body.revision_id || null;
 
-    // get completely flag
-    const isCompletely = req.body.completely;
-    // get recursively flag
-    const isRecursively = req.body.recursively;
+    const { recursively: isRecursively, completely: isCompletely } = req.body;
 
     const options = {};
 
@@ -1198,12 +1200,13 @@ module.exports = function(crowi, app) {
         await crowi.pageService.deleteCompletely(page, req.user, options, isRecursively);
       }
       else {
+        // behave like not found
         const notRecursivelyAndEmpty = page.isEmpty && !isRecursively;
         if (notRecursivelyAndEmpty) {
           return res.json(ApiResponse.error(`Page '${pageId}' is not found.`, 'notfound'));
         }
 
-        if (!page.isUpdatable(previousRevision)) {
+        if (!page.isEmpty && !page.isUpdatable(previousRevision)) {
           return res.json(ApiResponse.error('Someone could update this page, so couldn\'t delete.', 'outdated'));
         }
 
@@ -1217,7 +1220,9 @@ module.exports = function(crowi, app) {
 
     debug('Page deleted', page.path);
     const result = {};
-    result.page = page; // TODO consider to use serializePageSecurely method -- 2018.08.06 Yuki Takei
+    result.path = page.path;
+    result.isRecursively = isRecursively;
+    result.isCompletely = isCompletely;
 
     res.json(ApiResponse.success(result));
 
@@ -1231,7 +1236,9 @@ module.exports = function(crowi, app) {
   };
 
   validator.revertRemove = [
-    body('recursively').optional().custom(v => v === 'true' || v === true).withMessage('The body property "recursively" must be "true" or true.'),
+    body('recursively')
+      .custom(v => v === 'true' || v === true || null)
+      .withMessage('The body property "recursively" must be "true" or true. (Omit param for false)'),
   ];
 
   /**
@@ -1350,7 +1357,7 @@ module.exports = function(crowi, app) {
     const path = req.body.path;
 
     try {
-      await Page.removeRedirectOriginPageByPath(path);
+      await PageRedirect.removePageRedirectByToPath(path);
       logger.debug('Redirect Page deleted', path);
     }
     catch (err) {

+ 1 - 1
packages/app/src/server/service/config-loader.ts

@@ -314,7 +314,7 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     ns:      'crowi',
     key:     'app:useElasticsearchV6',
     type:    ValueType.BOOLEAN,
-    default: false,
+    default: true,
   },
   MONGO_GRIDFS_TOTAL_LIMIT: {
     ns:      'crowi',

+ 12 - 0
packages/app/src/server/service/import.js

@@ -182,6 +182,13 @@ class ImportService {
     // init status object
     this.currentProgressingStatus = new CollectionProgressingStatus(collections);
 
+    const isV5Compatible = this.crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
+    const isImportPagesCollection = collections.includes('pages');
+    const shouldNormalizePages = isV5Compatible && isImportPagesCollection;
+
+    // set isV5Compatible to false
+    if (shouldNormalizePages) await this.crowi.configManager.updateConfigsInTheSameNamespace('crowi', { 'app:isV5Compatible': false });
+
     // process serially so as not to waste memory
     const promises = collections.map((collectionName) => {
       const importSettings = importSettingsMap[collectionName];
@@ -199,6 +206,9 @@ class ImportService {
       }
     }
 
+    // run normalizeAllPublicPages
+    if (shouldNormalizePages) await this.crowi.pageService.normalizeAllPublicPages();
+
     this.currentProgressingStatus = null;
     this.emitTerminateEvent();
   }
@@ -333,6 +343,8 @@ class ImportService {
 
     // upsert
     switch (collectionName) {
+      case 'pages':
+        return bulk.find({ path: document.path }).upsert().replaceOne(document);
       default:
         return bulk.find({ _id: document._id }).upsert().replaceOne(document);
     }

+ 1 - 0
packages/app/src/server/service/installer.ts

@@ -115,6 +115,7 @@ export class InstallerService {
     const rootPage = await Page.findOne({ path: '/' });
     const rootRevision = await Revision.findOne({ path: '/' });
     rootPage.creator = adminUser._id;
+    rootPage.lastUpdateUser = adminUser._id;
     rootRevision.creator = adminUser._id;
     await Promise.all([rootPage.save(), rootRevision.save()]);
 

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

@@ -26,10 +26,11 @@ const debug = require('debug')('growi:services:page');
 
 const logger = loggerFactory('growi:services:page');
 const {
-  isCreatablePage, isTrashPage, collectAncestorPaths, isTopPage,
+  isCreatablePage, isTrashPage, isTopPage, isDeletablePage, omitDuplicateAreaPathFromPaths, omitDuplicateAreaPageFromPages,
 } = pagePathUtils;
 
 const BULK_REINDEX_SIZE = 100;
+const LIMIT_FOR_MULTIPLE_PAGE_OP = 20;
 
 // TODO: improve type
 class PageCursorsForDescendantsFactory {
@@ -208,25 +209,32 @@ class PageService {
     return false;
   }
 
+  filterPagesByCanDeleteCompletely(pages, user) {
+    return pages.filter(p => p.isEmpty || this.canDeleteCompletely(p.creator, user));
+  }
+
   async findPageAndMetaDataByViewer({ pageId, path, user }) {
 
     const Page = this.crowi.model('Page');
 
+    let pagePath = path;
+
     let page;
     if (pageId != null) { // prioritized
       page = await Page.findByIdAndViewer(pageId, user);
+      pagePath = page.path;
     }
     else {
-      page = await Page.findByPathAndViewer(path, user);
+      page = await Page.findByPathAndViewer(pagePath, user);
     }
 
     const result: any = {};
 
     if (page == null) {
-      const isExist = await Page.count({ $or: [{ _id: pageId }, { path }] }) > 0;
+      const isExist = await Page.count({ $or: [{ _id: pageId }, { pat: pagePath }] }) > 0;
       result.isForbidden = isExist;
       result.isNotFound = !isExist;
-      result.isCreatable = isCreatablePage(path);
+      result.isCreatable = isCreatablePage(pagePath);
       result.page = page;
 
       return result;
@@ -236,6 +244,7 @@ class PageService {
     result.isForbidden = false;
     result.isNotFound = false;
     result.isCreatable = false;
+    result.isDeletable = isDeletablePage(pagePath);
     result.isDeleted = page.isDeleted();
 
     return result;
@@ -268,6 +277,20 @@ class PageService {
     return page.grant !== Page.GRANT_RESTRICTED && page.grant !== Page.GRANT_SPECIFIED;
   }
 
+  /**
+   * Remove all empty pages at leaf position by page whose parent will change or which will be deleted.
+   * @param page Page whose parent will change or which will be deleted
+   */
+  async removeLeafEmptyPages(page): Promise<void> {
+    const Page = mongoose.model('Page') as unknown as PageModel;
+
+    // delete leaf empty pages
+    const shouldDeleteLeafEmptyPages = !(await Page.exists({ parent: page.parent, _id: { $ne: page._id } }));
+    if (shouldDeleteLeafEmptyPages) {
+      await Page.removeLeafEmptyPagesById(page.parent);
+    }
+  }
+
   /**
    * Generate read stream to operate descendants of the specified page path
    * @param {string} targetPagePath
@@ -1048,10 +1071,8 @@ class PageService {
       // update descendantCount of ancestors'
       await this.updateDescendantCountOfAncestors(page.parent, -1, true);
 
-      const shouldDeleteLeafEmptyPages = !shouldReplace;
-      if (shouldDeleteLeafEmptyPages) {
-        // TODO https://redmine.weseek.co.jp/issues/87667 : delete leaf empty pages here
-      }
+      // delete leaf empty pages
+      await this.removeLeafEmptyPages(page);
     }
 
     let deletedPage;
@@ -1084,7 +1105,8 @@ class PageService {
         if (page.parent != null) {
           await this.updateDescendantCountOfAncestors(page.parent, (deletedDescendantCount + 1) * -1, true);
 
-          // TODO https://redmine.weseek.co.jp/issues/87667 : delete leaf empty pages here
+          // delete leaf empty pages
+          await this.removeLeafEmptyPages(page);
         }
       })();
     }
@@ -1315,10 +1337,11 @@ class PageService {
 
     if (!isRecursively) {
       await this.updateDescendantCountOfAncestors(page.parent, -1, true);
-
-      // TODO https://redmine.weseek.co.jp/issues/87667 : delete leaf empty pages here
     }
 
+    // delete leaf empty pages
+    await this.removeLeafEmptyPages(page);
+
     if (!page.isEmpty && !preventEmitting) {
       this.pageEvent.emit('deleteCompletely', page, user);
     }
@@ -1333,8 +1356,6 @@ class PageService {
         if (page.parent != null) {
           await this.updateDescendantCountOfAncestors(page.parent, (deletedDescendantCount + 1) * -1, true);
         }
-
-        // TODO https://redmine.weseek.co.jp/issues/87667 : delete leaf empty pages here
       })();
     }
 
@@ -1414,6 +1435,28 @@ class PageService {
     return nDeletedNonEmptyPages;
   }
 
+  async deleteMultiplePages(pagesToDelete, user, isCompletely: boolean, isRecursively: boolean): Promise<void> {
+    if (pagesToDelete.length > LIMIT_FOR_MULTIPLE_PAGE_OP) {
+      throw Error(`The maximum number of pages is ${LIMIT_FOR_MULTIPLE_PAGE_OP}.`);
+    }
+
+    // omit duplicate paths if isRecursively true, omit empty pages if isRecursively false
+    const pages = isRecursively ? omitDuplicateAreaPageFromPages(pagesToDelete) : pagesToDelete.filter(p => !p.isEmpty);
+
+    // TODO: insertMany PageOperationBlock if isRecursively true
+
+    if (isCompletely) {
+      for await (const page of pages) {
+        await this.deleteCompletely(page, user, {}, isRecursively);
+      }
+    }
+    else {
+      for await (const page of pages) {
+        await this.deletePage(page, user, {}, isRecursively);
+      }
+    }
+  }
+
   // use the same process in both v4 and v5
   private async revertDeletedDescendants(pages, user) {
     const Page = this.crowi.model('Page');
@@ -1494,7 +1537,8 @@ class PageService {
         if (page.parent != null) {
           await this.updateDescendantCountOfAncestors(page.parent, revertedDescendantCount + 1, true);
 
-          // TODO https://redmine.weseek.co.jp/issues/87667 : delete leaf empty pages here
+          // delete leaf empty pages
+          await this.removeLeafEmptyPages(page);
         }
       })();
     }
@@ -1814,7 +1858,7 @@ class PageService {
     return Page.updateOne({ _id: pageId }, { parent: parent._id });
   }
 
-  async normalizeParentRecursivelyByPageIds(pageIds) {
+  async normalizeParentRecursivelyByPageIds(pageIds, user) {
     if (pageIds == null || pageIds.length === 0) {
       logger.error('pageIds is null or 0 length.');
       return;
@@ -1832,8 +1876,27 @@ class PageService {
       // socket.emit('normalizeParentRecursivelyByPageIds', { error: err.message }); TODO: use socket to tell user
     }
 
-    // generate regexps
-    const regexps = await this._generateRegExpsByPageIds(normalizedIds);
+    /*
+     * generate regexps
+     */
+    const Page = mongoose.model('Page') as unknown as PageModel;
+
+    let pages;
+    try {
+      pages = await Page.findByPageIdsToEdit(pageIds, user, false);
+    }
+    catch (err) {
+      logger.error('Failed to find pages by ids', err);
+      throw err;
+    }
+
+    // prepare no duplicated area paths
+    let paths = pages.map(p => p.path);
+    paths = omitDuplicateAreaPathFromPaths(paths);
+
+    const regexps = paths.map(path => new RegExp(`^${escapeStringRegexp(path)}`));
+
+    // TODO: insertMany PageOperationBlock
 
     // migrate recursively
     try {
@@ -1878,7 +1941,7 @@ class PageService {
   }
 
   // TODO: use socket to send status to the client
-  async v5InitialMigration(grant) {
+  async normalizeAllPublicPages() {
     // const socket = this.crowi.socketIoService.getAdminSocket();
 
     let isUnique;
@@ -1904,7 +1967,8 @@ class PageService {
 
     // then migrate
     try {
-      await this.normalizeParentRecursively(grant, null, true);
+      const Page = mongoose.model('Page') as unknown as PageModel;
+      await this.normalizeParentRecursively(Page.GRANT_PUBLIC, null, true);
     }
     catch (err) {
       logger.error('V5 initial miration failed.', err);
@@ -1926,27 +1990,6 @@ class PageService {
     await this._setIsV5CompatibleTrue();
   }
 
-  /*
-   * returns an array of js RegExp instance instead of RE2 instance for mongo filter
-   */
-  private async _generateRegExpsByPageIds(pageIds) {
-    const Page = mongoose.model('Page') as unknown as PageModel;
-
-    let result;
-    try {
-      result = await Page.findListByPageIds(pageIds, null, false);
-    }
-    catch (err) {
-      logger.error('Failed to find pages by ids', err);
-      throw err;
-    }
-
-    const { pages } = result;
-    const regexps = pages.map(page => new RegExp(`^${escapeStringRegexp(page.path)}`));
-
-    return regexps;
-  }
-
   private async _setIsV5CompatibleTrue() {
     try {
       await this.crowi.configManager.updateConfigsInTheSameNamespace('crowi', {
@@ -2168,7 +2211,8 @@ class PageService {
       objectMode: true,
       async write(pageDocuments, encoding, callback) {
         for await (const document of pageDocuments) {
-          await Page.recountDescendantCountOfSelfAndDescendants(document._id);
+          const descendantCount = await Page.recountDescendantCount(document._id);
+          await Page.findByIdAndUpdate(document._id, { descendantCount });
         }
         callback();
       },

+ 1 - 1
packages/app/src/server/service/user-group.ts

@@ -55,7 +55,7 @@ class UserGroupService {
     const parent = await UserGroup.findById(parentId);
 
     if (parent == null) { // it should not be null
-      throw Error('parent does not exist.');
+      throw Error('Parent group does not exist.');
     }
 
 

+ 16 - 18
packages/app/src/server/util/importer.js

@@ -1,9 +1,8 @@
+import Esa from 'esa-node';
 import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:util:importer');
 
-const esa = require('esa-nodejs');
-
 /**
  * importer
  */
@@ -17,16 +16,15 @@ module.exports = (crowi) => {
   const configManager = crowi.configManager;
 
   const importer = {};
-  let esaClient = {};
+  let esaClient = () => {};
 
   /**
    * Initialize importer
    */
   importer.initializeEsaClient = () => {
-    esaClient = esa({
-      team:        configManager.getConfig('crowi', 'importer:esa:team_name'),
-      accessToken: configManager.getConfig('crowi', 'importer:esa:access_token'),
-    });
+    const team = configManager.getConfig('crowi', 'importer:esa:team_name');
+    const accessToken = configManager.getConfig('crowi', 'importer:esa:access_token');
+    esaClient = new Esa(accessToken, team);
     logger.debug('initialize esa importer');
   };
 
@@ -56,13 +54,9 @@ module.exports = (crowi) => {
    */
   const importPostsFromEsa = (pageNum, user, errors) => {
     return new Promise((resolve, reject) => {
-      esaClient.api.posts({ page: pageNum, per_page: 100 }, async(err, res) => {
-        const nextPage = res.body.next_page;
-        const postsReceived = res.body.posts;
-
-        if (err) {
-          reject(new Error(`error in page ${pageNum}: ${err}`));
-        }
+      esaClient.posts({ page: pageNum, per_page: 100 }).then(async(res) => {
+        const nextPage = res.next_page;
+        const postsReceived = res.posts;
 
         const data = convertEsaDataForGrowi(postsReceived, user);
         const newErrors = await createGrowiPages(data);
@@ -72,6 +66,9 @@ module.exports = (crowi) => {
         }
 
         resolve(errors.concat(newErrors));
+
+      }).catch((err) => {
+        reject(new Error(`error in page ${pageNum}: ${err}`));
       });
     });
   };
@@ -174,12 +171,13 @@ module.exports = (crowi) => {
    */
   const getTeamNameFromEsa = () => {
     return new Promise((resolve, reject) => {
-      esaClient.api.team((err, res) => {
-        if (err) {
-          return reject(err);
-        }
+      const team = configManager.getConfig('crowi', 'importer:esa:team_name');
+      esaClient.team(team).then((res) => {
         resolve(res);
+      }).catch((err) => {
+        return reject(err);
       });
+
     });
   };
 

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

@@ -107,6 +107,9 @@
 <div id="page-delete-modal"></div>
 <div id="page-duplicate-modal"></div>
 <div id="page-rename-modal"></div>
+<div id="page-accessories-modal"></div>
+<div id="descendants-page-list-modal"></div>
+
 {% include '../modal/shortcuts.html' %}
 
 {% block body_end %}

+ 14 - 9
packages/app/src/stores/page.tsx

@@ -32,13 +32,14 @@ export const useSWRxRecentlyUpdated = (): SWRResponse<(IPageHasId)[], Error> =>
 };
 
 // eslint-disable-next-line @typescript-eslint/no-unused-vars
-export const useSWRxPageList = (
-    path: string,
-    pageNumber?: number,
-): SWRResponse<IPagingResult<IPageHasId>, Error> => {
-  const page = pageNumber || 1;
+export const useSWRxPageList = (path: string | null, pageNumber?: number): SWRResponse<IPagingResult<IPageHasId>, Error> => {
+
+  const key = path != null
+    ? `/pages/list?path=${path}&page=${pageNumber ?? 1}`
+    : null;
+
   return useSWR(
-    `/pages/list?path=${path}&page=${page}`,
+    key,
     endpoint => apiv3Get<{pages: IPageHasId[], totalCount: number, limit: number}>(endpoint).then((response) => {
       return {
         items: response.data.pages,
@@ -59,10 +60,14 @@ export const useSWRTagsInfo = (pageId: string | null | undefined): SWRResponse<I
   }));
 };
 
-export const useSWRxPageInfo = (pageId: string | null | undefined): SWRResponse<IPageInfo | IPageInfoForOperation, Error> => {
+export const useSWRxPageInfo = (
+    pageId: string | null | undefined,
+    shareLinkId?: string | null,
+): SWRResponse<IPageInfo | IPageInfoForOperation, Error> => {
+
   return useSWRImmutable(
-    pageId != null ? ['/page/info', pageId] : null,
-    (endpoint, pageId) => apiv3Get(endpoint, { pageId }).then(response => response.data),
+    pageId != null ? ['/page/info', pageId, shareLinkId] : null,
+    (endpoint, pageId, shareLinkId) => apiv3Get(endpoint, { pageId, shareLinkId }).then(response => response.data),
   );
 };
 

+ 90 - 11
packages/app/src/stores/ui.tsx

@@ -4,6 +4,7 @@ import useSWR, {
 import useSWRImmutable from 'swr/immutable';
 
 import { Breakpoint, addBreakpointListener } from '@growi/ui';
+import { pagePathUtils } from '@growi/core';
 
 import { RefObject } from 'react';
 import { SidebarContentsType } from '~/interfaces/ui';
@@ -15,14 +16,14 @@ import {
   useIsNotCreatable, useIsSharedUser, useNotFoundTargetPathOrId, useIsForbidden, useIsIdenticalPath,
 } from './context';
 import { IFocusable } from '~/client/interfaces/focusable';
-import { isSharedPage } from '^/../core/src/utils/page-path-utils';
+import { Nullable } from '~/interfaces/common';
+
+const { isSharedPage } = pagePathUtils;
 
 const logger = loggerFactory('growi:stores:ui');
 
 const isServer = typeof window === 'undefined';
 
-type Nullable<T> = T | null;
-
 
 /** **********************************************************
  *                          Unions
@@ -299,37 +300,45 @@ export const useCreateModalPath = (): SWRResponse<string | null | undefined, Err
 // PageDeleteModal
 export type IPageForPageDeleteModal = {
   pageId: string,
-  revisionId: string,
+  revisionId?: string,
   path: string
 }
 
+export type OnDeletedFunction = (pathOrPaths: string | string[], isRecursively: Nullable<true>, isCompletely: Nullable<true>) => void;
+
 type DeleteModalStatus = {
   isOpened: boolean,
   pages?: IPageForPageDeleteModal[],
+  onDeleted?: OnDeletedFunction,
+}
+
+type DeleteModalOpened = {
+  isOpend: boolean,
+  onDeleted?: OnDeletedFunction,
 }
 
 type DeleteModalStatusUtils = {
-  open(pages?: IPageForPageDeleteModal[]): Promise<DeleteModalStatus | undefined>
-  close(): Promise<DeleteModalStatus | undefined>
+  open(pages?: IPageForPageDeleteModal[], onDeleted?: OnDeletedFunction): Promise<DeleteModalStatus | undefined>,
+  close(): Promise<DeleteModalStatus | undefined>,
 }
 
-export const usePageDeleteModalStatus = (status?: DeleteModalStatus): SWRResponse<DeleteModalStatus, Error> & DeleteModalStatusUtils => {
+export const usePageDeleteModal = (status?: DeleteModalStatus): SWRResponse<DeleteModalStatus, Error> & DeleteModalStatusUtils => {
   const initialData: DeleteModalStatus = { isOpened: false };
   const swrResponse = useStaticSWR<DeleteModalStatus, Error>('deleteModalStatus', status, { fallbackData: initialData });
 
   return {
     ...swrResponse,
-    open: (pages?: IPageForPageDeleteModal[]) => swrResponse.mutate({ isOpened: true, pages }),
+    open: (pages?: IPageForPageDeleteModal[], onDeleted?: OnDeletedFunction) => swrResponse.mutate({ isOpened: true, pages, onDeleted }),
     close: () => swrResponse.mutate({ isOpened: false }),
   };
 };
 
-export const usePageDeleteModalOpened = (): SWRResponse<boolean, Error> => {
-  const { data } = usePageDeleteModalStatus();
+export const usePageDeleteModalOpened = (): SWRResponse<(DeleteModalOpened | null), Error> => {
+  const { data } = usePageDeleteModal();
   return useSWRImmutable(
     data != null ? ['isDeleteModalOpened', data] : null,
     () => {
-      return data != null ? data.isOpened : false;
+      return data != null ? { isOpend: data.isOpened, onDeleted: data?.onDeleted } : null;
     },
   );
 };
@@ -416,6 +425,76 @@ export const usePageRenameModalOpened = (): SWRResponse<boolean, Error> => {
   );
 };
 
+
+type DescendantsPageListModalStatus = {
+  isOpened: boolean,
+  path?: string,
+}
+
+type DescendantsPageListUtils = {
+  open(path: string): Promise<DescendantsPageListModalStatus | undefined>
+  close(): Promise<DuplicateModalStatus | undefined>
+}
+
+export const useDescendantsPageListModal = (
+    status?: DescendantsPageListModalStatus,
+): SWRResponse<DescendantsPageListModalStatus, Error> & DescendantsPageListUtils => {
+
+  const initialData: DescendantsPageListModalStatus = { isOpened: false };
+  const swrResponse = useStaticSWR<DescendantsPageListModalStatus, Error>('descendantsPageListModalStatus', status, { fallbackData: initialData });
+
+  return {
+    ...swrResponse,
+    open: (path: string) => swrResponse.mutate({ isOpened: true, path }),
+    close: () => swrResponse.mutate({ isOpened: false }),
+  };
+};
+
+
+export const PageAccessoriesModalContents = {
+  PageHistory: 'PageHistory',
+  Attachment: 'Attachment',
+  ShareLink: 'ShareLink',
+} as const;
+export type PageAccessoriesModalContents = typeof PageAccessoriesModalContents[keyof typeof PageAccessoriesModalContents];
+
+type PageAccessoriesModalStatus = {
+  isOpened: boolean,
+  onOpened?: (initialActivatedContents: PageAccessoriesModalContents) => void,
+}
+
+type PageAccessoriesModalUtils = {
+  open(activatedContents: PageAccessoriesModalContents): void
+  close(): void
+}
+
+export const usePageAccessoriesModal = (): SWRResponse<PageAccessoriesModalStatus, Error> & PageAccessoriesModalUtils => {
+
+  const initialStatus = { isOpened: false };
+  const swrResponse = useStaticSWR<PageAccessoriesModalStatus, Error>('pageAccessoriesModalStatus', undefined, { fallbackData: initialStatus });
+
+  return {
+    ...swrResponse,
+    open: (activatedContents: PageAccessoriesModalContents) => {
+      if (swrResponse.data == null) {
+        return;
+      }
+      swrResponse.mutate({ isOpened: true });
+
+      if (swrResponse.data.onOpened != null) {
+        swrResponse.data.onOpened(activatedContents);
+      }
+    },
+    close: () => {
+      if (swrResponse.data == null) {
+        return;
+      }
+      swrResponse.mutate({ isOpened: false });
+    },
+  };
+};
+
+
 export const useSelectedGrant = (initialData?: Nullable<number>): SWRResponse<Nullable<number>, Error> => {
   return useStaticSWR<Nullable<number>, Error>('grant', initialData);
 };

+ 29 - 3
packages/app/src/stores/user-group.tsx

@@ -2,8 +2,12 @@ import { SWRResponse } from 'swr';
 import useSWRImmutable from 'swr/immutable';
 
 import { apiv3Get } from '~/client/util/apiv3-client';
+
+import { IPageHasId } from '~/interfaces/page';
 import { IUserGroupHasId, IUserGroupRelationHasId } from '~/interfaces/user';
-import { UserGroupListResult, ChildUserGroupListResult, UserGroupRelationListResult } from '~/interfaces/user-group-response';
+import {
+  UserGroupListResult, ChildUserGroupListResult, UserGroupRelationListResult, UserGroupPagesResult, SelectableUserGroupsResult,
+} from '~/interfaces/user-group-response';
 
 
 export const useSWRxUserGroupList = (initialData?: IUserGroupHasId[]): SWRResponse<IUserGroupHasId[], Error> => {
@@ -17,10 +21,11 @@ export const useSWRxUserGroupList = (initialData?: IUserGroupHasId[]): SWRRespon
 };
 
 export const useSWRxChildUserGroupList = (
-    parentIds: string[] | undefined, includeGrandChildren?: boolean, initialData?: IUserGroupHasId[],
+    parentIds?: string[], includeGrandChildren?: boolean, initialData?: IUserGroupHasId[],
 ): SWRResponse<IUserGroupHasId[], Error> => {
+  const shouldFetch = parentIds != null && parentIds.length > 0;
   return useSWRImmutable<IUserGroupHasId[], Error>(
-    parentIds != null ? ['/user-groups/children', parentIds, includeGrandChildren] : null,
+    shouldFetch ? ['/user-groups/children', parentIds, includeGrandChildren] : null,
     (endpoint, parentIds, includeGrandChildren) => apiv3Get<ChildUserGroupListResult>(
       endpoint, { parentIds, includeGrandChildren },
     ).then(result => result.data.childUserGroups),
@@ -30,6 +35,13 @@ export const useSWRxChildUserGroupList = (
   );
 };
 
+export const useSWRxUserGroupRelations = (groupId: string): SWRResponse<IUserGroupRelationHasId[], Error> => {
+  return useSWRImmutable(
+    groupId != null ? [`/user-groups/${groupId}/user-group-relations`] : null,
+    endpoint => apiv3Get<UserGroupRelationListResult>(endpoint).then(result => result.data.userGroupRelations),
+  );
+};
+
 export const useSWRxUserGroupRelationList = (
     groupIds: string[] | undefined, childGroupIds?: string[], initialData?: IUserGroupRelationHasId[],
 ): SWRResponse<IUserGroupRelationHasId[], Error> => {
@@ -43,3 +55,17 @@ export const useSWRxUserGroupRelationList = (
     },
   );
 };
+
+export const useSWRxUserGroupPages = (groupId: string | undefined, limit: number, offset: number): SWRResponse<IPageHasId[], Error> => {
+  return useSWRImmutable(
+    groupId != null ? [`/user-groups/${groupId}/pages`, limit, offset] : null,
+    endpoint => apiv3Get<UserGroupPagesResult>(endpoint, { limit, offset }).then(result => result.data.pages),
+  );
+};
+
+export const useSWRxSelectableUserGroups = (groupId: string | undefined): SWRResponse<IUserGroupHasId[], Error> => {
+  return useSWRImmutable(
+    groupId != null ? ['/user-groups/selectable-groups'] : null,
+    endpoint => apiv3Get<SelectableUserGroupsResult>(endpoint, { groupId }).then(result => result.data.selectableUserGroups),
+  );
+};

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

@@ -164,3 +164,62 @@
     animation: fadeout 1s ease-in 1.5s forwards;
   }
 }
+
+@mixin button-svg-icon-variant($background, $hover-background: darken($background, 7.5%), $active-background: darken($background, 10%)) {
+  svg {
+    fill: color-yiq($background);
+  }
+
+  @include hover() {
+    svg {
+      fill: color-yiq($hover-background);
+    }
+  }
+
+  &:focus,
+  &.focus {
+    svg {
+      fill: color-yiq($hover-background);
+    }
+  }
+
+  // Disabled comes first so active can properly restyle
+  &.disabled,
+  &:disabled {
+    svg {
+      fill: color-yiq($background);
+    }
+  }
+
+  &:not(:disabled):not(.disabled):active,
+  &:not(:disabled):not(.disabled).active,
+  .show > &.dropdown-toggle {
+    svg {
+      fill: color-yiq($active-background);
+    }
+  }
+}
+
+@mixin button-outline-svg-icon-variant($value, $color-hover: $value) {
+  svg {
+    fill: $value;
+  }
+  @include hover() {
+    svg {
+      fill: $value;
+    }
+  }
+  &.disabled,
+  &:disabled {
+    svg {
+      fill: $value;
+    }
+  }
+  &:not(:disabled):not(.disabled):active,
+  &:not(:disabled):not(.disabled).active,
+  .show > &.dropdown-toggle {
+    svg {
+      fill: $value;
+    }
+  }
+}

+ 2 - 9
packages/app/src/styles/_page-accessories-control.scss

@@ -1,18 +1,11 @@
 .grw-page-accessories-control {
-  line-height: 1.25;
-  border-bottom: 1px solid transparent;
-
   .grw-btn-page-accessories {
-    padding: 0.375rem;
+    padding-right: 1rem;
+    padding-left: 1rem;
 
     svg {
       width: 16px;
       height: 16px;
     }
   }
-
-  .grw-border-vr {
-    height: 25px;
-    border-left: solid 1px transparent;
-  }
 }

+ 0 - 3
packages/app/src/styles/_sidebar.scss

@@ -134,9 +134,6 @@
             width: 100%;
             height: 100%;
             overflow: hidden auto;
-            &.collapsed {
-              display: none;
-            }
           }
         }
       }

+ 11 - 0
packages/app/src/styles/_subnav.scss

@@ -117,6 +117,17 @@
       padding: 4px;
       font-size: 16px;
     }
+    .btn-seen-user {
+      width: 48px;
+      height: 32px;
+      padding: 4px;
+      font-size: 16px;
+
+      svg {
+        width: 16px;
+        height: 16px;
+      }
+    }
     .btn-page-item-control {
       width: 32px;
       height: 32px;

+ 0 - 1
packages/app/src/styles/_toc.scss

@@ -4,7 +4,6 @@
   padding: 5px;
   font-size: 0.9em;
 
-  border-top: 1px solid transparent;
   border-bottom: 1px solid transparent;
 
   .revision-toc-content {

+ 18 - 0
packages/app/src/styles/atoms/_buttons.scss

@@ -33,6 +33,24 @@
   }
 }
 
+.btn.btn-seen-user {
+  $color-seen-user: #549c79;
+
+  @include button-outline-variant($color-seen-user, $color-seen-user, rgba(lighten($color-seen-user, 10%), 0.15), rgba(lighten($color-seen-user, 10%), 0.5));
+  @include button-outline-svg-icon-variant($color-seen-user, $color-seen-user);
+  &:not(:disabled):not(.disabled):active,
+  &:not(:disabled):not(.disabled).active {
+    color: $color-seen-user;
+    svg {
+      fill: $color-seen-user;
+    }
+  }
+  &:not(:disabled):not(.disabled):not(:hover) {
+    background-color: transparent;
+  }
+  box-shadow: none !important;
+}
+
 .btn-copy,
 .btn-edit {
   &:not(:hover):not(:active) {

+ 0 - 15
packages/app/src/styles/theme/_apply-colors.scss

@@ -14,7 +14,6 @@ $bordercolor-nav-tabs: $gray-300 !default;
 $bordercolor-nav-tabs-hover: $gray-200 $gray-200 $bordercolor-nav-tabs !default;
 $color-nav-tabs-link-active: $gray-600 !default;
 $bordercolor-nav-tabs-active: $bordercolor-nav-tabs $bordercolor-nav-tabs $bgcolor-global !default;
-$color-seen-user: #549c79 !default;
 $color-btn-reload-in-sidebar: $gray-500;
 $bgcolor-keyword-highlighted: $grw-marker-yellow !default;
 $bgcolor-page-list-group-item-active: lighten($primary, 76%) !default;
@@ -382,20 +381,6 @@ ul.pagination {
   }
 }
 
-.grw-page-accessories-control {
-  .grw-seen-user-info {
-    .btn {
-      color: $color-seen-user;
-      &:active {
-        color: $color-seen-user;
-      }
-      .footstamp-icon {
-        fill: $color-seen-user;
-      }
-    }
-  }
-}
-
 .grw-custom-nav-tab {
   .nav-item {
     &:hover,

+ 0 - 59
packages/app/src/styles/theme/_reboot-bootstrap-theme-colors.scss

@@ -1,64 +1,5 @@
 $theme-colors: map-merge($theme-colors, $colors);
 
-@mixin button-svg-icon-variant($background, $hover-background: darken($background, 7.5%), $active-background: darken($background, 10%)) {
-  svg {
-    fill: color-yiq($background);
-  }
-
-  @include hover() {
-    svg {
-      fill: color-yiq($hover-background);
-    }
-  }
-
-  &:focus,
-  &.focus {
-    svg {
-      fill: color-yiq($hover-background);
-    }
-  }
-
-  // Disabled comes first so active can properly restyle
-  &.disabled,
-  &:disabled {
-    svg {
-      fill: color-yiq($background);
-    }
-  }
-
-  &:not(:disabled):not(.disabled):active,
-  &:not(:disabled):not(.disabled).active,
-  .show > &.dropdown-toggle {
-    svg {
-      fill: color-yiq($active-background);
-    }
-  }
-}
-
-@mixin button-outline-svg-icon-variant($value, $color-hover: $value) {
-  svg {
-    fill: $value;
-  }
-  @include hover() {
-    svg {
-      fill: $value;
-    }
-  }
-  &.disabled,
-  &:disabled {
-    svg {
-      fill: $value;
-    }
-  }
-  &:not(:disabled):not(.disabled):active,
-  &:not(:disabled):not(.disabled).active,
-  .show > &.dropdown-toggle {
-    svg {
-      fill: $value;
-    }
-  }
-}
-
 @each $color, $value in $theme-colors {
   @include bg-variant('.bg-#{$color}', $value);
 }

+ 3 - 3
packages/app/test/integration/service/v5-migration.test.js

@@ -59,7 +59,7 @@ describe('V5 page migration', () => {
 
       const pageIds = pages.map(page => page._id);
       // migrate
-      await crowi.pageService.normalizeParentRecursivelyByPageIds(pageIds);
+      await crowi.pageService.normalizeParentRecursivelyByPageIds(pageIds, testUser1);
 
       const migratedPages = await Page.find({
         path: {
@@ -75,7 +75,7 @@ describe('V5 page migration', () => {
 
   });
 
-  describe('v5InitialMigration()', () => {
+  describe('normalizeAllPublicPages()', () => {
     jest.setTimeout(60000);
     let createPagePaths;
     let allPossiblePagePaths;
@@ -132,7 +132,7 @@ describe('V5 page migration', () => {
       ]);
 
       // migrate
-      await crowi.pageService.v5InitialMigration(Page.GRANT_PUBLIC);
+      await crowi.pageService.normalizeAllPublicPages(Page.GRANT_PUBLIC);
       jest.setTimeout(30000);
     });
 

+ 33 - 3
packages/core/src/test/util/page-path-utils.js → packages/core/src/test/util/page-path-utils.test.js

@@ -1,6 +1,6 @@
-import { pagePathUtils } from '~/utils/page-path-utils';
-
-const { isTopPage, convertToNewAffiliationPath, isCreatablePage } = pagePathUtils;
+import {
+  isTopPage, convertToNewAffiliationPath, isCreatablePage, omitDuplicateAreaPathFromPaths,
+} from '~/utils/page-path-utils';
 
 describe('TopPage Path test', () => {
   test('Path is only "/"', () => {
@@ -105,4 +105,34 @@ describe('isCreatablePage test', () => {
       expect(isCreatablePage(`/${pn}/abc`)).toBeFalsy();
     }
   });
+
+  describe('Test omitDuplicateAreaPathFromPaths', () => {
+    test('Should not omit when all paths are at unique area', () => {
+      const paths = ['/A', '/B/A', '/C/B/A', '/D'];
+      const expectedPaths = paths;
+
+      expect(omitDuplicateAreaPathFromPaths(paths)).toStrictEqual(paths);
+    });
+
+    test('Should omit when some paths are at duplicated area', () => {
+      const paths = ['/A', '/A/A', '/A/B/A', '/B', '/B/A', '/AA'];
+      const expectedPaths = ['/A', '/B', '/AA'];
+
+      expect(omitDuplicateAreaPathFromPaths(paths)).toStrictEqual(expectedPaths);
+    });
+
+    test('Should omit when some long paths are at duplicated area', () => {
+      const paths = ['/A/B/C', '/A/B/C/D', '/A/B/C/D/E'];
+      const expectedPaths = ['/A/B/C'];
+
+      expect(omitDuplicateAreaPathFromPaths(paths)).toStrictEqual(expectedPaths);
+    });
+
+    test('Should omit when some long paths are at duplicated area [case insensitivity]', () => {
+      const paths = ['/a/B/C', '/A/b/C/D', '/A/B/c/D/E'];
+      const expectedPaths = ['/a/B/C'];
+
+      expect(omitDuplicateAreaPathFromPaths(paths)).toStrictEqual(expectedPaths);
+    });
+  });
 });

+ 27 - 0
packages/core/src/utils/page-path-utils.ts

@@ -161,3 +161,30 @@ export const collectAncestorPaths = (path: string, ancestorPaths: string[] = [])
   ancestorPaths.push(parentPath);
   return collectAncestorPaths(parentPath, ancestorPaths);
 };
+
+/**
+ * return paths without duplicate area of regexp /^${path}\/.+/i
+ * ex. expect(omitDuplicateAreaPathFromPaths(['/A', '/A/B', '/A/B/C'])).toStrictEqual(['/A'])
+ * @param paths paths to be tested
+ * @returns omitted paths
+ */
+export const omitDuplicateAreaPathFromPaths = (paths: string[]): string[] => {
+  return paths.filter((path) => {
+    const isDuplicate = paths.filter(p => (new RegExp(`^${p}\\/.+`, 'i')).test(path)).length > 0;
+
+    return !isDuplicate;
+  });
+};
+
+/**
+ * return pages with path without duplicate area of regexp /^${path}\/.+/i
+ * @param paths paths to be tested
+ * @returns omitted paths
+ */
+export const omitDuplicateAreaPageFromPages = (pages: any[]): any[] => {
+  return pages.filter((page) => {
+    const isDuplicate = pages.filter(p => (new RegExp(`^${p.path}\\/.+`, 'i')).test(page.path)).length > 0;
+
+    return !isDuplicate;
+  });
+};

+ 38 - 105
yarn.lock

@@ -584,7 +584,7 @@
   dependencies:
     regenerator-runtime "^0.13.4"
 
-"@babel/runtime@^7.14.6", "@babel/runtime@^7.15.4":
+"@babel/runtime@^7.14.6", "@babel/runtime@^7.15.4", "@babel/runtime@^7.9.2":
   version "7.16.7"
   resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.16.7.tgz#03ff99f64106588c9c403c6ecb8c3bafbbdff1fa"
   integrity sha512-9E9FJowqAsytyOY6LG+1KuueckRL+aQW+mKvXRXnuFGyRAyepJPmEo9vgMfXUA6O9u3IeEdv9MAkppFcaQwogQ==
@@ -611,13 +611,6 @@
   dependencies:
     regenerator-runtime "^0.13.2"
 
-"@babel/runtime@^7.9.2":
-  version "7.16.7"
-  resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.16.7.tgz#03ff99f64106588c9c403c6ecb8c3bafbbdff1fa"
-  integrity sha512-9E9FJowqAsytyOY6LG+1KuueckRL+aQW+mKvXRXnuFGyRAyepJPmEo9vgMfXUA6O9u3IeEdv9MAkppFcaQwogQ==
-  dependencies:
-    regenerator-runtime "^0.13.4"
-
 "@babel/template@^7.1.0":
   version "7.4.0"
   resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.4.0.tgz#12474e9c077bae585c5d835a95c0b0b790c25c8b"
@@ -4240,7 +4233,7 @@ async@0.9.x:
   version "0.9.2"
   resolved "https://registry.yarnpkg.com/async/-/async-0.9.2.tgz#aea74d5e61c1f899613bf64bda66d4c78f2fd17d"
 
-async@1.5.2, async@^1.4.0:
+async@1.5.2:
   version "1.5.2"
   resolved "https://registry.yarnpkg.com/async/-/async-1.5.2.tgz#ec6a61ae56480c0c3cb241c95618e20892f9672a"
 
@@ -4352,6 +4345,14 @@ axios@0.21.4, axios@^0.21.1:
   dependencies:
     follow-redirects "^1.14.0"
 
+axios@^0.18.0:
+  version "0.18.1"
+  resolved "https://registry.yarnpkg.com/axios/-/axios-0.18.1.tgz#ff3f0de2e7b5d180e757ad98000f1081b87bcea3"
+  integrity sha512-0BfJq4NSfQXd+SkFdrvFbG7addhYSBA2mQwISr46pD6E5iqkWg02RAs8vyTT/j0RTnoYmeXauBuSv1qKwR179g==
+  dependencies:
+    follow-redirects "1.5.10"
+    is-buffer "^2.0.2"
+
 axios@^0.24.0:
   version "0.24.0"
   resolved "https://registry.yarnpkg.com/axios/-/axios-0.24.0.tgz#804e6fa1e4b9c5288501dd9dff56a7a0940d20d6"
@@ -5901,12 +5902,6 @@ columnify@^1.5.4:
     strip-ansi "^3.0.0"
     wcwidth "^1.0.0"
 
-combined-stream@^1.0.5:
-  version "1.0.5"
-  resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.5.tgz#938370a57b4a51dea2c77c15d5c5fdf895164009"
-  dependencies:
-    delayed-stream "~1.0.0"
-
 combined-stream@^1.0.6, combined-stream@^1.0.8, combined-stream@~1.0.6:
   version "1.0.8"
   resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f"
@@ -5998,7 +5993,7 @@ component-bind@1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/component-bind/-/component-bind-1.0.0.tgz#00c608ab7dcd93897c0009651b1d3a8e1e73bbd1"
 
-component-emitter@1.2.1, component-emitter@^1.2.1, component-emitter@~1.2.0:
+component-emitter@1.2.1, component-emitter@^1.2.1:
   version "1.2.1"
   resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.2.1.tgz#137918d6d78283f7df7a6b7c5a63e140e69425e6"
 
@@ -6007,10 +6002,6 @@ component-emitter@~1.3.0:
   resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.3.0.tgz#16e4070fba8ae29b679f2215853ee181ab2eabc0"
   integrity sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==
 
-component-ie@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/component-ie/-/component-ie-1.0.0.tgz#0f9582ccb078a687592cc29eb46b3186e6fe637f"
-
 component-inherit@0.0.3:
   version "0.0.3"
   resolved "https://registry.yarnpkg.com/component-inherit/-/component-inherit-0.0.3.tgz#645fc4adf58b72b649d5cae65135619db26ff143"
@@ -6359,10 +6350,6 @@ cookie@~0.4.1:
   resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.1.tgz#afd713fe26ebd21ba95ceb61f9a8116e50a537d1"
   integrity sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==
 
-cookiejar@2.0.6:
-  version "2.0.6"
-  resolved "https://registry.yarnpkg.com/cookiejar/-/cookiejar-2.0.6.tgz#0abf356ad00d1c5a219d88d44518046dd026acfe"
-
 cookies@0.7.1:
   version "0.7.1"
   resolved "https://registry.yarnpkg.com/cookies/-/cookies-0.7.1.tgz#7c8a615f5481c61ab9f16c833731bcb8f663b99b"
@@ -6970,13 +6957,13 @@ debounce@^1.0.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/debounce/-/debounce-1.1.0.tgz#6a1a4ee2a9dc4b7c24bb012558dbcdb05b37f408"
 
-debug@2, debug@2.6.9, debug@^2.0.0, debug@^2.2.0, debug@^2.3.3, debug@^2.6.1, debug@^2.6.9:
+debug@2.6.9, debug@^2.0.0, debug@^2.2.0, debug@^2.3.3, debug@^2.6.1, debug@^2.6.9:
   version "2.6.9"
   resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"
   dependencies:
     ms "2.0.0"
 
-debug@3.1.0, debug@~3.1.0:
+debug@3.1.0, debug@=3.1.0, debug@~3.1.0:
   version "3.1.0"
   resolved "https://registry.yarnpkg.com/debug/-/debug-3.1.0.tgz#5bb5a0672628b64149566ba16819e61518c67261"
   integrity sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==
@@ -7952,12 +7939,12 @@ es6-promise@^4.2.6:
   resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-4.2.6.tgz#b685edd8258886365ea62b57d30de28fadcd974f"
   integrity sha512-aRVgGdnmW2OiySVPUC9e6m+plolMAJKjZnQlCwNSuK5yQ0JN61DZSO1X1Ufd1foqWRAlig0rhduTCHe7sVtK5Q==
 
-esa-nodejs@^0.0.7:
-  version "0.0.7"
-  resolved "https://registry.yarnpkg.com/esa-nodejs/-/esa-nodejs-0.0.7.tgz#c4749412605ad430d5da17aa4928291927561b42"
+esa-node@^0.2.2:
+  version "0.2.2"
+  resolved "https://registry.yarnpkg.com/esa-node/-/esa-node-0.2.2.tgz#8b0aed3f8dcb57b3d29a93c7e33f3535e3866902"
+  integrity sha512-QIwO62/WezCVKzKBY0chpPOreI//rqdeZyfPbg7bFLqaQKcVxYLoq84KhsXgjmfypOUtjUPXa2BE5cf3yqlnhQ==
   dependencies:
-    superagent "^1.2.0"
-    superagent-no-cache "^0.1.0"
+    axios "^0.18.0"
 
 escalade@^3.1.1:
   version "3.1.1"
@@ -8666,10 +8653,6 @@ extend-shallow@^3.0.0, extend-shallow@^3.0.2:
     assign-symbols "^1.0.0"
     is-extendable "^1.0.1"
 
-extend@3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.0.tgz#5a474353b9f3353ddd8176dfd37b91c83a46f1d4"
-
 extend@^3.0.0:
   version "3.0.1"
   resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.1.tgz#a755ea7bc1adfcc5a31ce7e762dbaadc5e636444"
@@ -9094,6 +9077,13 @@ folktale@2.3.2:
   resolved "https://registry.yarnpkg.com/folktale/-/folktale-2.3.2.tgz#38231b039e5ef36989920cbf805bf6b227bf4fd4"
   integrity sha512-+8GbtQBwEqutP0v3uajDDoN64K2ehmHd0cjlghhxh0WpcfPzAIjPA03e1VvHlxL02FVGR0A6lwXsNQKn3H1RNQ==
 
+follow-redirects@1.5.10:
+  version "1.5.10"
+  resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.5.10.tgz#7b7a9f9aea2fdff36786a94ff643ed07f4ff5e2a"
+  integrity sha512-0V5l4Cizzvqt5D44aTXbFZz+FtyXV1vrDN6qrelxtfYQKW0KO0W2T/hkE8xvGa/540LkZlkaUjO4ailYTFtHVQ==
+  dependencies:
+    debug "=3.1.0"
+
 follow-redirects@^1.0.0, follow-redirects@^1.14.0, follow-redirects@^1.14.4:
   version "1.14.7"
   resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.7.tgz#2004c02eb9436eee9a21446a6477debf17e81685"
@@ -9126,14 +9116,6 @@ forever-agent@~0.6.1:
   resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91"
   integrity sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=
 
-form-data@1.0.0-rc3:
-  version "1.0.0-rc3"
-  resolved "https://registry.yarnpkg.com/form-data/-/form-data-1.0.0-rc3.tgz#d35bc62e7fbc2937ae78f948aaa0d38d90607577"
-  dependencies:
-    async "^1.4.0"
-    combined-stream "^1.0.5"
-    mime-types "^2.1.3"
-
 form-data@^2.5.0:
   version "2.5.1"
   resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.5.1.tgz#f2cbec57b5e59e23716e128fe44d4e5dd23895f4"
@@ -9166,10 +9148,6 @@ format@^0.2.0:
   resolved "https://registry.yarnpkg.com/format/-/format-0.2.2.tgz#d6170107e9efdc4ed30c9dc39016df942b5cb58b"
   integrity sha1-1hcBB+nv3E7TDJ3DkBbflCtctYs=
 
-formidable@~1.0.14:
-  version "1.0.17"
-  resolved "https://registry.yarnpkg.com/formidable/-/formidable-1.0.17.tgz#ef5491490f9433b705faa77249c99029ae348559"
-
 forwarded@~0.1.2:
   version "0.1.2"
   resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.1.2.tgz#98c23dab1175657b8c0573e8ceccd91b0ff18c84"
@@ -10822,6 +10800,11 @@ is-buffer@^2.0.0, is-buffer@~2.0.4:
   resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-2.0.4.tgz#3e572f23c8411a5cfd9557c849e3665e0b290623"
   integrity sha512-Kq1rokWXOPXWuaMAqZiJW4XxsmD9zGx9q4aePabbn3qCRGedtH7Cm+zV8WETitMfu1wdh+Rvd6w5egwSngUX2A==
 
+is-buffer@^2.0.2:
+  version "2.0.5"
+  resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-2.0.5.tgz#ebc252e400d22ff8d77fa09888821a24a658c191"
+  integrity sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==
+
 is-callable@^1.1.1, is-callable@^1.1.3:
   version "1.1.3"
   resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.1.3.tgz#86eb75392805ddc33af71c92a0eedf74ee7604b2"
@@ -12842,7 +12825,7 @@ macos-release@^2.2.0:
   resolved "https://registry.yarnpkg.com/macos-release/-/macos-release-2.5.0.tgz#067c2c88b5f3fb3c56a375b2ec93826220fa1ff2"
   integrity sha512-EIgv+QZ9r+814gjJj0Bt5vSLJLzswGmSUbUpbi9AIr/fsN2IWFBl2NucV9PAiek+U1STK468tEkxmVYUtuAN3g==
 
-make-dir@3.1.0, make-dir@^3.0.2:
+make-dir@3.1.0, make-dir@^3.0.2, make-dir@^3.1.0:
   version "3.1.0"
   resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-3.1.0.tgz#415e967046b3a7f1d185277d84aa58203726a13f"
   integrity sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==
@@ -12870,13 +12853,6 @@ make-dir@^3.0.0:
   dependencies:
     semver "^6.0.0"
 
-make-dir@^3.0.2, make-dir@^3.1.0:
-  version "3.1.0"
-  resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-3.1.0.tgz#415e967046b3a7f1d185277d84aa58203726a13f"
-  integrity sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==
-  dependencies:
-    semver "^6.0.0"
-
 make-error@1.x, make-error@^1.1.1:
   version "1.3.6"
   resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.6.tgz#2eb2e37ea9b67c4891f684a1394799af484cf7a2"
@@ -13321,7 +13297,7 @@ method-override@^3.0.0:
     parseurl "~1.3.2"
     vary "~1.1.2"
 
-methods@~1.1.1, methods@~1.1.2:
+methods@~1.1.2:
   version "1.1.2"
   resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee"
 
@@ -13538,18 +13514,18 @@ mime-types@^2.1.27:
   dependencies:
     mime-db "1.51.0"
 
-mime-types@^2.1.3, mime-types@~2.1.18:
-  version "2.1.18"
-  resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.18.tgz#6f323f60a83d11146f831ff11fd66e2fe5503bb8"
-  dependencies:
-    mime-db "~1.33.0"
-
 mime-types@~2.1.15, mime-types@~2.1.16, mime-types@~2.1.17:
   version "2.1.17"
   resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.17.tgz#09d7a393f03e995a79f8af857b70a9e0ab16557a"
   dependencies:
     mime-db "~1.30.0"
 
+mime-types@~2.1.18:
+  version "2.1.18"
+  resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.18.tgz#6f323f60a83d11146f831ff11fd66e2fe5503bb8"
+  dependencies:
+    mime-db "~1.33.0"
+
 mime-types@~2.1.24:
   version "2.1.28"
   resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.28.tgz#1160c4757eab2c5363888e005273ecf79d2a0ecd"
@@ -13557,10 +13533,6 @@ mime-types@~2.1.24:
   dependencies:
     mime-db "1.45.0"
 
-mime@1.3.4:
-  version "1.3.4"
-  resolved "https://registry.yarnpkg.com/mime/-/mime-1.3.4.tgz#115f9e3b6b3daf2959983cb38f149a2d40eb5d53"
-
 mime@1.4.1:
   version "1.4.1"
   resolved "https://registry.yarnpkg.com/mime/-/mime-1.4.1.tgz#121f9ebc49e3766f311a76e1fa1c8003c4b03aa6"
@@ -16545,10 +16517,6 @@ q@^1.0.1, q@^1.1.2, q@^1.5.1:
   version "1.5.1"
   resolved "https://registry.yarnpkg.com/q/-/q-1.5.1.tgz#7e32f75b41381291d04611f1bf14109ac00651d7"
 
-qs@2.3.3:
-  version "2.3.3"
-  resolved "https://registry.yarnpkg.com/qs/-/qs-2.3.3.tgz#e9e85adbe75da0bbe4c8e0476a086290f863b404"
-
 qs@6.2.3:
   version "6.2.3"
   resolved "https://registry.yarnpkg.com/qs/-/qs-6.2.3.tgz#1cfcb25c10a9b2b483053ff39f5dfc9233908cfe"
@@ -17132,15 +17100,6 @@ read@1, read@~1.0.1:
     string_decoder "~1.1.1"
     util-deprecate "~1.0.1"
 
-readable-stream@1.0.27-1:
-  version "1.0.27-1"
-  resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.0.27-1.tgz#6b67983c20357cefd07f0165001a16d710d91078"
-  dependencies:
-    core-util-is "~1.0.0"
-    inherits "~2.0.1"
-    isarray "0.0.1"
-    string_decoder "~0.10.x"
-
 readable-stream@1.1:
   version "1.1.13"
   resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.1.13.tgz#f6eef764f514c89e2b9e23146a75ba106756d23e"
@@ -17313,10 +17272,6 @@ redis@^3.0.2:
     redis-errors "^1.2.0"
     redis-parser "^3.0.0"
 
-reduce-component@1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/reduce-component/-/reduce-component-1.0.1.tgz#e0c93542c574521bea13df0f9488ed82ab77c5da"
-
 redux@^4.1.1:
   version "4.1.2"
   resolved "https://registry.yarnpkg.com/redux/-/redux-4.1.2.tgz#140f35426d99bb4729af760afcf79eaaac407104"
@@ -19448,28 +19403,6 @@ subarg@^1.0.0:
   dependencies:
     minimist "^1.1.0"
 
-superagent-no-cache@^0.1.0:
-  version "0.1.1"
-  resolved "https://registry.yarnpkg.com/superagent-no-cache/-/superagent-no-cache-0.1.1.tgz#58ed8de9aeff053a9c98ae01dec4fde4b9f85fda"
-  dependencies:
-    component-ie "^1.0.0"
-
-superagent@^1.2.0:
-  version "1.8.5"
-  resolved "https://registry.yarnpkg.com/superagent/-/superagent-1.8.5.tgz#1c0ddc3af30e80eb84ebc05cb2122da8fe940b55"
-  dependencies:
-    component-emitter "~1.2.0"
-    cookiejar "2.0.6"
-    debug "2"
-    extend "3.0.0"
-    form-data "1.0.0-rc3"
-    formidable "~1.0.14"
-    methods "~1.1.1"
-    mime "1.3.4"
-    qs "2.3.3"
-    readable-stream "1.0.27-1"
-    reduce-component "1.0.1"
-
 supports-color@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-2.0.0.tgz#535d045ce6b6363fa40117084629995e9df324c7"