Răsfoiți Sursa

Merge branch 'dev/5.0.x' into feat/86349-delete-operation-from-the-delete-modal

Shun Miyazawa 4 ani în urmă
părinte
comite
1c0f512b5c
46 a modificat fișierele cu 942 adăugiri și 666 ștergeri
  1. 3 0
      packages/app/resource/locales/en_US/admin/admin.json
  2. 3 0
      packages/app/resource/locales/ja_JP/admin/admin.json
  3. 3 0
      packages/app/resource/locales/zh_CN/admin/admin.json
  4. 7 7
      packages/app/src/client/app.jsx
  5. 4 0
      packages/app/src/client/base.jsx
  6. 0 54
      packages/app/src/client/services/PageAccessoriesContainer.js
  7. 0 65
      packages/app/src/client/services/PageContainer.js
  8. 6 1
      packages/app/src/components/Admin/ImportData/GrowiArchive/ImportForm.jsx
  9. 10 2
      packages/app/src/components/Common/Dropdown/PageItemControl.tsx
  10. 0 96
      packages/app/src/components/ContentLinkButtons.jsx
  11. 66 0
      packages/app/src/components/ContentLinkButtons.tsx
  12. 3 2
      packages/app/src/components/DescendantsPageList.tsx
  13. 100 0
      packages/app/src/components/DescendantsPageListModal.tsx
  14. 3 3
      packages/app/src/components/EventListeneres/HashChanged.tsx
  15. 3 3
      packages/app/src/components/ForbiddenPage.tsx
  16. 2 1
      packages/app/src/components/Icons/AttachmentIcon.jsx
  17. 2 1
      packages/app/src/components/Icons/HistoryIcon.jsx
  18. 1 1
      packages/app/src/components/Icons/ShareLinkIcon.jsx
  19. 24 11
      packages/app/src/components/IdenticalPathPage.tsx
  20. 57 11
      packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  21. 3 1
      packages/app/src/components/Navbar/GrowiSubNavigationSwitcher.jsx
  22. 8 4
      packages/app/src/components/Navbar/SubNavButtons.tsx
  23. 0 89
      packages/app/src/components/Page/DisplaySwitcher.jsx
  24. 134 0
      packages/app/src/components/Page/DisplaySwitcher.tsx
  25. 0 40
      packages/app/src/components/PageAccessories.jsx
  26. 0 160
      packages/app/src/components/PageAccessoriesModal.jsx
  27. 134 0
      packages/app/src/components/PageAccessoriesModal.tsx
  28. 2 3
      packages/app/src/components/PageAccessoriesModalControl.jsx
  29. 57 35
      packages/app/src/components/Sidebar/PageTree/Item.tsx
  30. 12 3
      packages/app/src/components/Sidebar/PageTree/ItemsTree.tsx
  31. 2 2
      packages/app/src/components/TableOfContents.jsx
  32. 59 7
      packages/app/src/server/models/page.ts
  33. 14 1
      packages/app/src/server/routes/apiv3/import.js
  34. 8 0
      packages/app/src/server/routes/apiv3/overwrite-params/pages.js
  35. 12 2
      packages/app/src/server/routes/apiv3/page.js
  36. 5 4
      packages/app/src/server/routes/apiv3/pages.js
  37. 3 1
      packages/app/src/server/routes/page.js
  38. 12 0
      packages/app/src/server/service/import.js
  39. 1 0
      packages/app/src/server/service/installer.ts
  40. 84 35
      packages/app/src/server/service/page.ts
  41. 3 0
      packages/app/src/server/views/layout/layout.html
  42. 14 9
      packages/app/src/stores/page.tsx
  43. 74 0
      packages/app/src/stores/ui.tsx
  44. 2 9
      packages/app/src/styles/_page-accessories-control.scss
  45. 0 1
      packages/app/src/styles/_toc.scss
  46. 2 2
      packages/app/test/integration/service/v5-migration.test.js

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

@@ -189,6 +189,9 @@
     "beta_warning": "This function is Beta.",
     "beta_warning": "This function is Beta.",
     "import_from": "Import from {{from}}",
     "import_from": "Import from {{from}}",
     "import_growi_archive": "Import GROWI archive",
     "import_growi_archive": "Import GROWI archive",
+    "error": {
+      "only_upsert_available": "Only 'Upsert' option is available for pages collection."
+    },
     "growi_settings": {
     "growi_settings": {
       "description_of_import_mode": {
       "description_of_import_mode": {
         "about": "When you import data with the same name as an existing one, choose from the following three modes below.",
         "about": "When you import data with the same name as an existing one, choose from the following three modes below.",

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

@@ -207,6 +207,9 @@
     "beta_warning": "この機能はベータ版です",
     "beta_warning": "この機能はベータ版です",
     "import_from": "{{from}} からインポート",
     "import_from": "{{from}} からインポート",
     "import_growi_archive": "GROWI アーカイブをインポート",
     "import_growi_archive": "GROWI アーカイブをインポート",
+    "error": {
+      "only_upsert_available": "pages コレクションには 'Upsert' オプションのみ対応しています"
+    },
     "growi_settings": {
     "growi_settings": {
       "description_of_import_mode": {
       "description_of_import_mode": {
         "about": "既存のデータと同名であるデータをインポートする際の挙動は以下の3つのモードから選べます。",
         "about": "既存のデータと同名であるデータをインポートする際の挙動は以下の3つのモードから選べます。",

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

@@ -199,6 +199,9 @@
     "beta_warning": "这个函数是Beta。",
     "beta_warning": "这个函数是Beta。",
     "import_from": "Import from {{from}}",
     "import_from": "Import from {{from}}",
     "import_growi_archive": "Import GROWI archive",
     "import_growi_archive": "Import GROWI archive",
+    "error": {
+      "only_upsert_available": "Only 'Upsert' option is available for pages collection."
+    },
     "growi_settings": {
     "growi_settings": {
       "description_of_import_mode": {
       "description_of_import_mode": {
         "about": "When you import data with the same name as an existing one, choose from the following three modes below.",
         "about": "When you import data with the same name as an existing one, choose from the following three modes below.",

+ 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 EditorContainer from '~/client/services/EditorContainer';
 import TagContainer from '~/client/services/TagContainer';
 import TagContainer from '~/client/services/TagContainer';
 import PersonalContainer from '~/client/services/PersonalContainer';
 import PersonalContainer from '~/client/services/PersonalContainer';
-import PageAccessoriesContainer from '~/client/services/PageAccessoriesContainer';
 
 
 import { appContainer, componentMappings } from './base';
 import { appContainer, componentMappings } from './base';
+import { toastError } from './util/apiNotification';
 
 
 const logger = loggerFactory('growi:cli:app');
 const logger = loggerFactory('growi:cli:app');
 
 
@@ -70,10 +70,9 @@ const commentContainer = new CommentContainer(appContainer);
 const editorContainer = new EditorContainer(appContainer, defaultEditorOptions, defaultPreviewOptions);
 const editorContainer = new EditorContainer(appContainer, defaultEditorOptions, defaultPreviewOptions);
 const tagContainer = new TagContainer(appContainer);
 const tagContainer = new TagContainer(appContainer);
 const personalContainer = new PersonalContainer(appContainer);
 const personalContainer = new PersonalContainer(appContainer);
-const pageAccessoriesContainer = new PageAccessoriesContainer(appContainer);
 const injectableContainers = [
 const injectableContainers = [
   appContainer, socketIoContainer, pageContainer, pageHistoryContainer, revisionComparerContainer,
   appContainer, socketIoContainer, pageContainer, pageHistoryContainer, revisionComparerContainer,
-  commentContainer, editorContainer, tagContainer, personalContainer, pageAccessoriesContainer,
+  commentContainer, editorContainer, tagContainer, personalContainer,
 ];
 ];
 
 
 logger.info('unstated containers have been initialized');
 logger.info('unstated containers have been initialized');
@@ -101,7 +100,7 @@ Object.assign(componentMappings, {
 
 
   'not-found-page': <NotFoundPage />,
   'not-found-page': <NotFoundPage />,
 
 
-  'forbidden-page': <ForbiddenPage isSharePage={appContainer.config.disableLinkSharing} />,
+  'forbidden-page': <ForbiddenPage isLinkSharingDisabled={appContainer.config.disableLinkSharing} />,
 
 
   'page-timeline': <PageTimeline />,
   'page-timeline': <PageTimeline />,
 
 
@@ -133,7 +132,8 @@ if (pageContainer.state.pageId != null) {
 
 
   // show the Page accessory modal when query of "compare" is requested
   // show the Page accessory modal when query of "compare" is requested
   if (revisionComparerContainer.getRevisionIDsToCompareAsParam().length > 0) {
   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) {
 if (pageContainer.state.creator != null) {
@@ -146,8 +146,8 @@ if (pageContainer.state.path != null) {
   Object.assign(componentMappings, {
   Object.assign(componentMappings, {
     // eslint-disable-next-line quote-props
     // eslint-disable-next-line quote-props
     'page': <Page />,
     '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 />,
     '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 PageDeleteModal from '../components/PageDeleteModal';
 import PageDuplicateModal from '../components/PageDuplicateModal';
 import PageDuplicateModal from '../components/PageDuplicateModal';
 import PageRenameModal from '../components/PageRenameModal';
 import PageRenameModal from '../components/PageRenameModal';
+import PageAccessoriesModal from '../components/PageAccessoriesModal';
 
 
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
 import SocketIoContainer from '~/client/services/SocketIoContainer';
 import SocketIoContainer from '~/client/services/SocketIoContainer';
+import { DescendantsPageListModal } from '~/components/DescendantsPageListModal';
 
 
 const logger = loggerFactory('growi:cli:app');
 const logger = loggerFactory('growi:cli:app');
 
 
@@ -46,6 +48,8 @@ const componentMappings = {
   'page-delete-modal': <PageDeleteModal />,
   'page-delete-modal': <PageDeleteModal />,
   'page-duplicate-modal': <PageDuplicateModal />,
   'page-duplicate-modal': <PageDuplicateModal />,
   'page-rename-modal': <PageRenameModal />,
   'page-rename-modal': <PageRenameModal />,
+  'page-accessories-modal': <PageAccessoriesModal />,
+  'descendants-page-list-modal': <DescendantsPageListModal />,
 
 
   'grw-hotkeys-manager': <HotkeysManager />,
   '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';
     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
    * whether to Empty Trash Page
    * not displayed when guest user and not on trash page
    * not displayed when guest user and not on trash page

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

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

+ 10 - 2
packages/app/src/components/Common/Dropdown/PageItemControl.tsx

@@ -24,7 +24,7 @@ type CommonProps = {
   showBookmarkMenuItem?: boolean,
   showBookmarkMenuItem?: boolean,
   onClickBookmarkMenuItem?: (pageId: string, newValue?: boolean) => Promise<void>,
   onClickBookmarkMenuItem?: (pageId: string, newValue?: boolean) => Promise<void>,
   onClickDuplicateMenuItem?: () => Promise<void> | void,
   onClickDuplicateMenuItem?: () => Promise<void> | void,
-  onClickRenameMenuItem?: (pageId: string) => void,
+  onClickRenameMenuItem?: (pageId: string) => Promise<void> | void,
   onClickDeleteMenuItem?: (pageId: string) => void,
   onClickDeleteMenuItem?: (pageId: string) => void,
 
 
   additionalMenuItemRenderer?: React.FunctionComponent<AdditionalMenuItemsRendererProps>,
   additionalMenuItemRenderer?: React.FunctionComponent<AdditionalMenuItemsRendererProps>,
@@ -167,7 +167,7 @@ export const PageItemControlSubstance = (props: PageItemControlSubstanceProps):
   const {
   const {
     pageId, pageInfo: presetPageInfo, fetchOnInit,
     pageId, pageInfo: presetPageInfo, fetchOnInit,
     children,
     children,
-    onClickBookmarkMenuItem, onClickDuplicateMenuItem,
+    onClickBookmarkMenuItem, onClickDuplicateMenuItem, onClickRenameMenuItem,
   } = props;
   } = props;
 
 
   const [isOpen, setIsOpen] = useState(false);
   const [isOpen, setIsOpen] = useState(false);
@@ -197,6 +197,13 @@ export const PageItemControlSubstance = (props: PageItemControlSubstanceProps):
     await onClickDuplicateMenuItem();
     await onClickDuplicateMenuItem();
   }, [onClickDuplicateMenuItem]);
   }, [onClickDuplicateMenuItem]);
 
 
+  const renameMenuItemClickHandler = useCallback(async() => {
+    if (onClickRenameMenuItem == null) {
+      return;
+    }
+    await onClickRenameMenuItem(pageId);
+  }, [onClickRenameMenuItem, pageId]);
+
   return (
   return (
     <Dropdown isOpen={isOpen} toggle={() => setIsOpen(!isOpen)}>
     <Dropdown isOpen={isOpen} toggle={() => setIsOpen(!isOpen)}>
 
 
@@ -212,6 +219,7 @@ export const PageItemControlSubstance = (props: PageItemControlSubstanceProps):
         pageInfo={fetchedPageInfo ?? presetPageInfo}
         pageInfo={fetchedPageInfo ?? presetPageInfo}
         onClickBookmarkMenuItem={bookmarkMenuItemClickHandler}
         onClickBookmarkMenuItem={bookmarkMenuItemClickHandler}
         onClickDuplicateMenuItem={duplicateMenuItemClickHandler}
         onClickDuplicateMenuItem={duplicateMenuItemClickHandler}
+        onClickRenameMenuItem={renameMenuItemClickHandler}
       />
       />
     </Dropdown>
     </Dropdown>
   );
   );

+ 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;

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

@@ -3,7 +3,7 @@ import {
   IPageHasId, IPageWithMeta,
   IPageHasId, IPageWithMeta,
 } from '~/interfaces/page';
 } from '~/interfaces/page';
 import { IPagingResult } from '~/interfaces/paging-result';
 import { IPagingResult } from '~/interfaces/paging-result';
-import { useIsGuestUser } from '~/stores/context';
+import { useIsGuestUser, useIsSharedUser } from '~/stores/context';
 
 
 import { useSWRxPageInfoForList, useSWRxPageList } from '~/stores/page';
 import { useSWRxPageInfoForList, useSWRxPageList } from '~/stores/page';
 
 
@@ -25,8 +25,9 @@ const DescendantsPageList = (props: Props): JSX.Element => {
   const [activePage, setActivePage] = useState(1);
   const [activePage, setActivePage] = useState(1);
 
 
   const { data: isGuestUser } = useIsGuestUser();
   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 pageIds = pagingResult?.items?.map(page => page._id);
   const { data: idToPageInfo } = useSWRxPageInfoForList(pageIds);
   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 { useEditorMode, determineEditorModeByHash } from '~/stores/ui';
 import { useIsEditable } from '~/stores/context';
 import { useIsEditable } from '~/stores/context';
@@ -6,7 +6,7 @@ import { useIsEditable } from '~/stores/context';
 /**
 /**
  * Change editorMode by browser forward/back operation
  * Change editorMode by browser forward/back operation
  */
  */
-const HashChanged: FC<void> = () => {
+const HashChanged = (): JSX.Element => {
   const { data: isEditable } = useIsEditable();
   const { data: isEditable } = useIsEditable();
   const { data: editorMode, mutate: mutateEditorMode } = useEditorMode();
   const { data: editorMode, mutate: mutateEditorMode } = useEditorMode();
 
 
@@ -33,7 +33,7 @@ const HashChanged: FC<void> = () => {
 
 
   }, [hashchangeHandler, isEditable, mutateEditorMode]);
   }, [hashchangeHandler, isEditable, mutateEditorMode]);
 
 
-  return null;
+  return <></>;
 };
 };
 
 
 export default HashChanged;
 export default HashChanged;

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

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

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

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

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

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

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

@@ -1,7 +1,7 @@
 import React from 'react';
 import React from 'react';
 
 
 const ShareLink = () => (
 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)">
     <g transform="translate(-142 -502)">
       <rect width="20" height="20" transform="translate(142 502)" fill="none" />
       <rect width="20" height="20" transform="translate(142 502)" fill="none" />
       <g transform="translate(16 286.938)">
       <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 { useTranslation } from 'react-i18next';
 
 
 import { DevidedPagePath } from '@growi/core';
 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 { PageListItemL } from './PageList/PageListItemL';
-import { useSWRxPageInfoForList } from '~/stores/page';
-import { IPageHasId, IPageWithMeta } from '~/interfaces/page';
 
 
 
 
 type IdenticalPathAlertProps = {
 type IdenticalPathAlertProps = {
@@ -55,24 +55,37 @@ type IdenticalPathPageProps= {
 const jsonNull = 'null';
 const jsonNull = 'null';
 
 
 const IdenticalPathPage:FC<IdenticalPathPageProps> = (props: IdenticalPathPageProps) => {
 const IdenticalPathPage:FC<IdenticalPathPageProps> = (props: IdenticalPathPageProps) => {
+  const { t } = useTranslation();
 
 
   const identicalPageDocument = document.getElementById('identical-path-page');
   const identicalPageDocument = document.getElementById('identical-path-page');
   const pages = JSON.parse(identicalPageDocument?.getAttribute('data-identical-path-pages') || jsonNull) as IPageHasId[];
   const pages = JSON.parse(identicalPageDocument?.getAttribute('data-identical-path-pages') || jsonNull) as IPageHasId[];
 
 
   const pageIds = pages.map(page => page._id) as string[];
   const pageIds = pages.map(page => page._id) as string[];
 
 
-  const { data: idToPageInfoMap } = useSWRxPageInfoForList(pageIds);
 
 
   const { data: currentPath } = useCurrentPagePath();
   const { data: currentPath } = useCurrentPagePath();
+  const { data: isSharedUser } = useIsSharedUser();
+
+  const { data: idToPageInfoMap } = useSWRxPageInfoForList(pageIds);
+
+  const { open: openDescendantPageListModal } = useDescendantsPageListModal();
 
 
   return (
   return (
     <div className="d-flex flex-column flex-lg-row-reverse">
     <div className="d-flex flex-column flex-lg-row-reverse">
 
 
       <div className="grw-side-contents-container">
       <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>
       </div>
       </div>
 
 

+ 57 - 11
packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -9,21 +9,25 @@ import { withUnstatedContainers } from '../UnstatedUtils';
 import EditorContainer from '~/client/services/EditorContainer';
 import EditorContainer from '~/client/services/EditorContainer';
 import {
 import {
   EditorMode, useDrawerMode, useEditorMode, useIsDeviceSmallerThanMd, useIsAbleToShowPageManagement, useIsAbleToShowTagLabel,
   EditorMode, useDrawerMode, useEditorMode, useIsDeviceSmallerThanMd, useIsAbleToShowPageManagement, useIsAbleToShowTagLabel,
-  useIsAbleToShowPageEditorModeManager, useIsAbleToShowPageAuthors,
+  useIsAbleToShowPageEditorModeManager, useIsAbleToShowPageAuthors, usePageAccessoriesModal, PageAccessoriesModalContents,
 } from '~/stores/ui';
 } from '~/stores/ui';
 import {
 import {
   useCurrentCreatedAt, useCurrentUpdatedAt, useCurrentPageId, useRevisionId, useCurrentPagePath,
   useCurrentCreatedAt, useCurrentUpdatedAt, useCurrentPageId, useRevisionId, useCurrentPagePath,
-  useCreator, useRevisionAuthor, useIsGuestUser, useIsSharedUser,
+  useCreator, useRevisionAuthor, useIsGuestUser, useIsSharedUser, useShareLinkId,
 } from '~/stores/context';
 } from '~/stores/context';
 import { useSWRTagsInfo } from '~/stores/page';
 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 { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { apiPost } from '~/client/util/apiv1-client';
 import { apiPost } from '~/client/util/apiv1-client';
 import { IPageHasId } from '~/interfaces/page';
 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 { GrowiSubNavigation } from './GrowiSubNavigation';
 import PresentationIcon from '../Icons/PresentationIcon';
 import PresentationIcon from '../Icons/PresentationIcon';
 import { exportAsMarkdown } from '~/client/services/page-operation';
 import { exportAsMarkdown } from '~/client/services/page-operation';
@@ -32,17 +36,21 @@ import { exportAsMarkdown } from '~/client/services/page-operation';
 type AdditionalMenuItemsProps = AdditionalMenuItemsRendererProps & {
 type AdditionalMenuItemsProps = AdditionalMenuItemsRendererProps & {
   pageId: string,
   pageId: string,
   revisionId: string,
   revisionId: string,
+  isLinkSharingDisabled?: boolean,
 }
 }
 
 
 const AdditionalMenuItems = (props: AdditionalMenuItemsProps): JSX.Element => {
 const AdditionalMenuItems = (props: AdditionalMenuItemsProps): JSX.Element => {
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
-  const { pageId, revisionId } = props;
+  const { pageId, revisionId, isLinkSharingDisabled } = props;
+
+  const { data: isGuestUser } = useIsGuestUser();
+  const { data: isSharedUser } = useIsSharedUser();
+
+  const { open } = usePageAccessoriesModal();
 
 
   return (
   return (
     <>
     <>
-      <DropdownItem divider />
-
       {/* Presentation */}
       {/* Presentation */}
       <DropdownItem onClick={() => { /* TODO: implement in https://redmine.weseek.co.jp/issues/87672 */ }}>
       <DropdownItem onClick={() => { /* TODO: implement in https://redmine.weseek.co.jp/issues/87672 */ }}>
         <i className="icon-fw"><PresentationIcon /></i>
         <i className="icon-fw"><PresentationIcon /></i>
@@ -57,6 +65,35 @@ const AdditionalMenuItems = (props: AdditionalMenuItemsProps): JSX.Element => {
 
 
       <DropdownItem divider />
       <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 */}
       {/* Create template */}
       <DropdownItem onClick={() => { /* TODO: implement in https://redmine.weseek.co.jp/issues/87673 */ }}>
       <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') }
         <i className="icon-fw icon-magic-wand"></i> { t('template.option_label.create/edit') }
@@ -79,6 +116,7 @@ const GrowiContextualSubNavigation = (props) => {
   const { data: revisionAuthor } = useRevisionAuthor();
   const { data: revisionAuthor } = useRevisionAuthor();
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isSharedUser } = useIsSharedUser();
   const { data: isSharedUser } = useIsSharedUser();
+  const { data: shareLinkId } = useShareLinkId();
 
 
   const { data: isAbleToShowPageManagement } = useIsAbleToShowPageManagement();
   const { data: isAbleToShowPageManagement } = useIsAbleToShowPageManagement();
   const { data: isAbleToShowTagLabel } = useIsAbleToShowTagLabel();
   const { data: isAbleToShowTagLabel } = useIsAbleToShowTagLabel();
@@ -88,7 +126,7 @@ const GrowiContextualSubNavigation = (props) => {
   const { mutate: mutateSWRTagsInfo, data: tagsInfoData } = useSWRTagsInfo(pageId);
   const { mutate: mutateSWRTagsInfo, data: tagsInfoData } = useSWRTagsInfo(pageId);
 
 
   const {
   const {
-    editorContainer, isCompactMode,
+    editorContainer, isCompactMode, isLinkSharingDisabled,
   } = props;
   } = props;
 
 
   const isViewMode = editorMode === EditorMode.View;
   const isViewMode = editorMode === EditorMode.View;
@@ -127,10 +165,13 @@ const GrowiContextualSubNavigation = (props) => {
             <SubNavButtons
             <SubNavButtons
               isCompactMode={isCompactMode}
               isCompactMode={isCompactMode}
               pageId={pageId}
               pageId={pageId}
+              shareLinkId={shareLinkId}
               revisionId={revisionId}
               revisionId={revisionId}
               disableSeenUserInfoPopover={isSharedUser}
               disableSeenUserInfoPopover={isSharedUser}
               showPageControlDropdown={isAbleToShowPageManagement}
               showPageControlDropdown={isAbleToShowPageManagement}
-              additionalMenuItemRenderer={props => <AdditionalMenuItems {...props} pageId={pageId} revisionId={revisionId} />}
+              additionalMenuItemRenderer={props => (
+                <AdditionalMenuItems {...props} pageId={pageId} revisionId={revisionId} isLinkSharingDisabled={isLinkSharingDisabled} />
+              )}
             />
             />
           ) }
           ) }
         </div>
         </div>
@@ -149,7 +190,8 @@ const GrowiContextualSubNavigation = (props) => {
   }, [
   }, [
     pageId, revisionId,
     pageId, revisionId,
     editorMode, mutateEditorMode,
     editorMode, mutateEditorMode,
-    isCompactMode, isDeviceSmallerThanMd, isGuestUser, isSharedUser,
+    isCompactMode, isLinkSharingDisabled,
+    isDeviceSmallerThanMd, isGuestUser, isSharedUser,
     isViewMode, isAbleToShowPageEditorModeManager, isAbleToShowPageManagement,
     isViewMode, isAbleToShowPageEditorModeManager, isAbleToShowPageManagement,
   ]);
   ]);
 
 
@@ -175,6 +217,9 @@ const GrowiContextualSubNavigation = (props) => {
       showDrawerToggler={isDrawerMode}
       showDrawerToggler={isDrawerMode}
       showTagLabel={isAbleToShowTagLabel}
       showTagLabel={isAbleToShowTagLabel}
       showPageAuthors={isAbleToShowPageAuthors}
       showPageAuthors={isAbleToShowPageAuthors}
+      isGuestUser={isGuestUser}
+      isDrawerMode={isDrawerMode}
+      isCompactMode={isCompactMode}
       tags={tagsInfoData?.tags || []}
       tags={tagsInfoData?.tags || []}
       tagsUpdatedHandler={tagsUpdatedHandler}
       tagsUpdatedHandler={tagsUpdatedHandler}
       controls={ControlComponents}
       controls={ControlComponents}
@@ -192,6 +237,7 @@ GrowiContextualSubNavigation.propTypes = {
   editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
   editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
 
 
   isCompactMode: PropTypes.bool,
   isCompactMode: PropTypes.bool,
+  isLinkSharingDisabled: PropTypes.bool,
 };
 };
 
 
 export default GrowiContextualSubNavigationWrapper;
 export default GrowiContextualSubNavigationWrapper;

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

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

+ 8 - 4
packages/app/src/components/Navbar/SubNavButtons.tsx

@@ -24,18 +24,21 @@ type CommonProps = {
 
 
 type SubNavButtonsSubstanceProps= CommonProps & {
 type SubNavButtonsSubstanceProps= CommonProps & {
   pageId: string,
   pageId: string,
+  shareLinkId?: string | null,
   revisionId: string,
   revisionId: string,
   pageInfo: IPageInfoAll,
   pageInfo: IPageInfoAll,
 }
 }
 
 
 const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element => {
 const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element => {
   const {
   const {
-    pageInfo, pageId, isCompactMode, disableSeenUserInfoPopover, showPageControlDropdown, additionalMenuItemRenderer,
+    pageInfo,
+    pageId, shareLinkId,
+    isCompactMode, disableSeenUserInfoPopover, showPageControlDropdown, additionalMenuItemRenderer,
   } = props;
   } = props;
 
 
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isGuestUser } = useIsGuestUser();
 
 
-  const { mutate: mutatePageInfo } = useSWRxPageInfo(pageId);
+  const { mutate: mutatePageInfo } = useSWRxPageInfo(pageId, shareLinkId);
 
 
   const { data: bookmarkInfo, mutate: mutateBookmarkInfo } = useSWRBookmarkInfo(pageId);
   const { data: bookmarkInfo, mutate: mutateBookmarkInfo } = useSWRBookmarkInfo(pageId);
 
 
@@ -129,13 +132,14 @@ const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element
 
 
 type SubNavButtonsProps= CommonProps & {
 type SubNavButtonsProps= CommonProps & {
   pageId: string,
   pageId: string,
+  shareLinkId?: string | null,
   revisionId?: string | null,
   revisionId?: string | null,
 };
 };
 
 
 export const SubNavButtons = (props: SubNavButtonsProps): JSX.Element => {
 export const SubNavButtons = (props: SubNavButtonsProps): JSX.Element => {
-  const { pageId, revisionId } = props;
+  const { pageId, shareLinkId, revisionId } = props;
 
 
-  const { data: pageInfo, error } = useSWRxPageInfo(pageId ?? null);
+  const { data: pageInfo, error } = useSWRxPageInfo(pageId ?? null, shareLinkId);
 
 
   if (revisionId == null || error != null) {
   if (revisionId == null || error != null) {
     return <></>;
     return <></>;

+ 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;

+ 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, { useMemo, useState } from 'react';
+
+import {
+  Modal, ModalBody, ModalHeader, TabContent, TabPane,
+} 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';
+
+
+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.PageHistory);
+  const [isWindowExpanded, setIsWindowExpanded] = useState(false);
+
+  const { data: isSharedUser } = useIsSharedUser();
+  const { data: isGuestUser } = useIsGuestUser();
+
+  const { data: status, open, close } = usePageAccessoriesModal();
+
+  const navTabMapping = useMemo(() => {
+    return {
+      [PageAccessoriesModalContents.PageHistory]: {
+        Icon: HistoryIcon,
+        i18n: t('History'),
+        index: 0,
+        isLinkEnabled: () => !isGuestUser && !isSharedUser,
+      },
+      [PageAccessoriesModalContents.Attachment]: {
+        Icon: AttachmentIcon,
+        i18n: t('attachment_data'),
+        index: 1,
+      },
+      [PageAccessoriesModalContents.ShareLink]: {
+        Icon: ShareLinkIcon,
+        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, activatedContents } = 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);
+            open(v);
+          }}
+          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 activatedContents */}
+        <TabContent activeTab={activeTab}>
+          {!isGuestUser && (
+            <TabPane tabId={PageAccessoriesModalContents.PageHistory}>
+              {activatedContents.has(PageAccessoriesModalContents.PageHistory) && <PageHistory /> }
+            </TabPane>
+          )}
+          <TabPane tabId={PageAccessoriesModalContents.Attachment}>
+            {activatedContents.has(PageAccessoriesModalContents.Attachment) && <PageAttachment />}
+          </TabPane>
+          {!isGuestUser && (
+            <TabPane tabId={PageAccessoriesModalContents.ShareLink}>
+              {activatedContents.has(PageAccessoriesModalContents.ShareLink) && <ShareLink />}
+            </TabPane>
+          )}
+        </TabContent>
+      </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 { withTranslation } from 'react-i18next';
 
 
 import { UncontrolledTooltip } from 'reactstrap';
 import { UncontrolledTooltip } from 'reactstrap';
-import PageAccessoriesContainer from '~/client/services/PageAccessoriesContainer';
 
 
 import PageListIcon from './Icons/PageListIcon';
 import PageListIcon from './Icons/PageListIcon';
 import TimeLineIcon from './Icons/TimeLineIcon';
 import TimeLineIcon from './Icons/TimeLineIcon';
@@ -96,12 +95,12 @@ const PageAccessoriesModalControl = (props) => {
 /**
 /**
  * Wrapper component for using unstated
  * Wrapper component for using unstated
  */
  */
-const PageAccessoriesModalControlWrapper = withUnstatedContainers(PageAccessoriesModalControl, [PageAccessoriesContainer]);
+const PageAccessoriesModalControlWrapper = withUnstatedContainers(PageAccessoriesModalControl, []);
 
 
 PageAccessoriesModalControl.propTypes = {
 PageAccessoriesModalControl.propTypes = {
   t: PropTypes.func.isRequired, //  i18next
   t: PropTypes.func.isRequired, //  i18next
 
 
-  pageAccessoriesContainer: PropTypes.instanceOf(PageAccessoriesContainer).isRequired,
+  pageAccessoriesContainer: PropTypes.any,
 
 
   isGuestUser: PropTypes.bool.isRequired,
   isGuestUser: PropTypes.bool.isRequired,
   isSharedUser: PropTypes.bool.isRequired,
   isSharedUser: PropTypes.bool.isRequired,

+ 57 - 35
packages/app/src/components/Sidebar/PageTree/Item.tsx

@@ -26,6 +26,7 @@ interface ItemProps {
   targetPathOrId?: string
   targetPathOrId?: string
   isOpen?: boolean
   isOpen?: boolean
   onClickDuplicateMenuItem?(pageId: string, path: string): void
   onClickDuplicateMenuItem?(pageId: string, path: string): void
+  onClickRenameMenuItem?(pageId: string, revisionId: string, path: string): void
   onClickDeleteByPage?(pageToDelete: IPageForPageDeleteModal | null): void
   onClickDeleteByPage?(pageToDelete: IPageForPageDeleteModal | null): void
 }
 }
 
 
@@ -67,7 +68,7 @@ const ItemCount: FC<ItemCountProps> = (props:ItemCountProps) => {
 const Item: FC<ItemProps> = (props: ItemProps) => {
 const Item: FC<ItemProps> = (props: ItemProps) => {
   const { t } = useTranslation();
   const { t } = useTranslation();
   const {
   const {
-    itemNode, targetPathOrId, isOpen: _isOpen = false, onClickDuplicateMenuItem, onClickDeleteByPage, isEnableActions,
+    itemNode, targetPathOrId, isOpen: _isOpen = false, onClickDuplicateMenuItem, onClickRenameMenuItem, onClickDeleteByPage, isEnableActions,
   } = props;
   } = props;
 
 
   const { page, children } = itemNode;
   const { page, children } = itemNode;
@@ -76,7 +77,7 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
   const [currentChildren, setCurrentChildren] = useState(children);
   const [currentChildren, setCurrentChildren] = useState(children);
   const [isOpen, setIsOpen] = useState(_isOpen);
   const [isOpen, setIsOpen] = useState(_isOpen);
   const [isNewPageInputShown, setNewPageInputShown] = useState(false);
   const [isNewPageInputShown, setNewPageInputShown] = useState(false);
-  const [isRenameInputShown, setRenameInputShown] = useState(false);
+  // const [isRenameInputShown, setRenameInputShown] = useState(false);
 
 
   const { data, error } = useSWRxPageChildren(isOpen ? page._id : null);
   const { data, error } = useSWRxPageChildren(isOpen ? page._id : null);
 
 
@@ -140,6 +141,51 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
     onClickDuplicateMenuItem(pageId, path);
     onClickDuplicateMenuItem(pageId, path);
   }, [onClickDuplicateMenuItem, page]);
   }, [onClickDuplicateMenuItem, page]);
 
 
+
+  /*
+  * Rename: TODO: rename page title on input form by #87757
+  */
+
+  // const onClickRenameButton = useCallback(async(_pageId: string): Promise<void> => {
+  //   setRenameInputShown(true);
+  // }, []);
+
+  // const onPressEnterForRenameHandler = async(inputText: string) => {
+  //   if (inputText == null || inputText === '' || inputText.trim() === '' || inputText.includes('/')) {
+  //     return;
+  //   }
+
+  //   const parentPath = nodePath.dirname(page.path as string);
+  //   const newPagePath = `${parentPath}/${inputText}`;
+
+  //   try {
+  //     setPageTitle(inputText);
+  //     setRenameInputShown(false);
+  //     await apiv3Put('/pages/rename', { newPagePath, pageId: page._id, revisionId: page.revision });
+  //   }
+  //   catch (err) {
+  //     // open ClosableInput and set pageTitle back to the previous title
+  //     setPageTitle(nodePath.basename(pageTitle as string));
+  //     setRenameInputShown(true);
+  //     toastError(err);
+  //   }
+  // };
+
+  const renameMenuItemClickHandler = useCallback((): void => {
+    if (onClickRenameMenuItem == null) {
+      return;
+    }
+
+    const { _id: pageId, revision: revisionId, path } = page;
+
+    if (pageId == null || revisionId == null || path == null) {
+      throw Error('Any of _id and revisionId and path must not be null.');
+    }
+
+    onClickRenameMenuItem(pageId, revisionId as string, path);
+  }, [onClickRenameMenuItem, page]);
+
+
   const onClickDeleteButton = useCallback(async(_pageId: string): Promise<void> => {
   const onClickDeleteButton = useCallback(async(_pageId: string): Promise<void> => {
     if (onClickDeleteByPage == null) {
     if (onClickDeleteByPage == null) {
       return;
       return;
@@ -161,32 +207,6 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
   }, [page, onClickDeleteByPage]);
   }, [page, onClickDeleteByPage]);
 
 
 
 
-  const onClickRenameButton = useCallback(async(_pageId: string): Promise<void> => {
-    setRenameInputShown(true);
-  }, []);
-
-  const onPressEnterForRenameHandler = async(inputText: string) => {
-    if (inputText == null || inputText === '' || inputText.trim() === '' || inputText.includes('/')) {
-      return;
-    }
-
-    const parentPath = nodePath.dirname(page.path as string);
-    const newPagePath = `${parentPath}/${inputText}`;
-
-    try {
-      setPageTitle(inputText);
-      setRenameInputShown(false);
-      await apiv3Put('/pages/rename', { newPagePath, pageId: page._id, revisionId: page.revision });
-    }
-    catch (err) {
-      // open ClosableInput and set pageTitle back to the previous title
-      setPageTitle(nodePath.basename(pageTitle as string));
-      setRenameInputShown(true);
-      toastError(err);
-    }
-  };
-
-
   // TODO: go to create page page
   // TODO: go to create page page
   const onPressEnterForCreateHandler = () => {
   const onPressEnterForCreateHandler = () => {
     toastWarning(t('search_result.currently_not_implemented'));
     toastWarning(t('search_result.currently_not_implemented'));
@@ -256,7 +276,8 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
             </button>
             </button>
           )}
           )}
         </div>
         </div>
-        { isRenameInputShown && (
+        {/* TODO: rename page title on input form by 87757 */}
+        {/* { isRenameInputShown && (
           <ClosableTextInput
           <ClosableTextInput
             isShown
             isShown
             value={nodePath.basename(pageTitle as string)}
             value={nodePath.basename(pageTitle as string)}
@@ -266,11 +287,11 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
             inputValidator={inputValidator}
             inputValidator={inputValidator}
           />
           />
         )}
         )}
-        { !isRenameInputShown && (
-          <a href={page._id} className="grw-pagetree-title-anchor flex-grow-1">
-            <p className={`text-truncate m-auto ${page.isEmpty && 'text-muted'}`}>{nodePath.basename(pageTitle as string) || '/'}</p>
-          </a>
-        )}
+        { !isRenameInputShown && ( */}
+        <a href={page._id} className="grw-pagetree-title-anchor flex-grow-1">
+          <p className={`text-truncate m-auto ${page.isEmpty && 'text-muted'}`}>{nodePath.basename(pageTitle as string) || '/'}</p>
+        </a>
+        {/* )} */}
         {(page.descendantCount != null && page.descendantCount > 0) && (
         {(page.descendantCount != null && page.descendantCount > 0) && (
           <div className="grw-pagetree-count-wrapper">
           <div className="grw-pagetree-count-wrapper">
             <ItemCount descendantCount={page.descendantCount} />
             <ItemCount descendantCount={page.descendantCount} />
@@ -284,7 +305,7 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
             onClickBookmarkMenuItem={bookmarkMenuItemClickHandler}
             onClickBookmarkMenuItem={bookmarkMenuItemClickHandler}
             onClickDuplicateMenuItem={duplicateMenuItemClickHandler}
             onClickDuplicateMenuItem={duplicateMenuItemClickHandler}
             onClickDeleteMenuItem={onClickDeleteButton}
             onClickDeleteMenuItem={onClickDeleteButton}
-            onClickRenameMenuItem={onClickRenameButton}
+            onClickRenameMenuItem={renameMenuItemClickHandler}
           >
           >
             <DropdownToggle color="transparent" className="border-0 rounded btn-page-item-control p-0">
             <DropdownToggle color="transparent" className="border-0 rounded btn-page-item-control p-0">
               <i className="icon-options fa fa-rotate-90 text-muted p-1"></i>
               <i className="icon-options fa fa-rotate-90 text-muted p-1"></i>
@@ -318,6 +339,7 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
               isOpen={false}
               isOpen={false}
               targetPathOrId={targetPathOrId}
               targetPathOrId={targetPathOrId}
               onClickDuplicateMenuItem={onClickDuplicateMenuItem}
               onClickDuplicateMenuItem={onClickDuplicateMenuItem}
+              onClickRenameMenuItem={onClickRenameMenuItem}
               onClickDeleteByPage={onClickDeleteByPage}
               onClickDeleteByPage={onClickDeleteByPage}
             />
             />
           </div>
           </div>

+ 12 - 3
packages/app/src/components/Sidebar/PageTree/ItemsTree.tsx

@@ -6,7 +6,9 @@ import Item from './Item';
 import { useSWRxPageAncestorsChildren, useSWRxRootPage } from '../../../stores/page-listing';
 import { useSWRxPageAncestorsChildren, useSWRxRootPage } from '../../../stores/page-listing';
 import { TargetAndAncestors } from '~/interfaces/page-listing-results';
 import { TargetAndAncestors } from '~/interfaces/page-listing-results';
 import { toastError } from '~/client/util/apiNotification';
 import { toastError } from '~/client/util/apiNotification';
-import { IPageForPageDeleteModal, usePageDuplicateModalStatus, usePageDeleteModalStatus } from '~/stores/ui';
+import {
+  IPageForPageDeleteModal, usePageDuplicateModalStatus, usePageRenameModalStatus, usePageDeleteModalStatus,
+} from '~/stores/ui';
 
 
 /*
 /*
  * Utility to generate initial node
  * Utility to generate initial node
@@ -59,6 +61,7 @@ const renderByInitialNode = (
     isEnableActions: boolean,
     isEnableActions: boolean,
     targetPathOrId?: string,
     targetPathOrId?: string,
     onClickDuplicateMenuItem?: (pageId: string, path: string) => void,
     onClickDuplicateMenuItem?: (pageId: string, path: string) => void,
+    onClickRenameMenuItem?: (pageId: string, revisionId: string, path: string) => void,
     onClickDeleteByPage?: (pageToDelete: IPageForPageDeleteModal | null) => void,
     onClickDeleteByPage?: (pageToDelete: IPageForPageDeleteModal | null) => void,
 ): JSX.Element => {
 ): JSX.Element => {
 
 
@@ -71,6 +74,7 @@ const renderByInitialNode = (
         isOpen
         isOpen
         isEnableActions={isEnableActions}
         isEnableActions={isEnableActions}
         onClickDuplicateMenuItem={onClickDuplicateMenuItem}
         onClickDuplicateMenuItem={onClickDuplicateMenuItem}
+        onClickRenameMenuItem={onClickRenameMenuItem}
         onClickDeleteByPage={onClickDeleteByPage}
         onClickDeleteByPage={onClickDeleteByPage}
       />
       />
     </ul>
     </ul>
@@ -89,12 +93,17 @@ const ItemsTree: FC<ItemsTreeProps> = (props: ItemsTreeProps) => {
   const { data: ancestorsChildrenData, error: error1 } = useSWRxPageAncestorsChildren(targetPath);
   const { data: ancestorsChildrenData, error: error1 } = useSWRxPageAncestorsChildren(targetPath);
   const { data: rootPageData, error: error2 } = useSWRxRootPage();
   const { data: rootPageData, error: error2 } = useSWRxRootPage();
   const { open: openDuplicateModal } = usePageDuplicateModalStatus();
   const { open: openDuplicateModal } = usePageDuplicateModalStatus();
+  const { open: openRenameModal } = usePageRenameModalStatus();
   const { open: openDeleteModal } = usePageDeleteModalStatus();
   const { open: openDeleteModal } = usePageDeleteModalStatus();
 
 
   const onClickDuplicateMenuItem = (pageId: string, path: string) => {
   const onClickDuplicateMenuItem = (pageId: string, path: string) => {
     openDuplicateModal(pageId, path);
     openDuplicateModal(pageId, path);
   };
   };
 
 
+  const onClickRenameMenuItem = (pageId: string, revisionId: string, path: string) => {
+    openRenameModal(pageId, revisionId, path);
+  };
+
   const onClickDeleteByPage = (pageToDelete: IPageForPageDeleteModal) => {
   const onClickDeleteByPage = (pageToDelete: IPageForPageDeleteModal) => {
     openDeleteModal([pageToDelete]);
     openDeleteModal([pageToDelete]);
   };
   };
@@ -110,7 +119,7 @@ const ItemsTree: FC<ItemsTreeProps> = (props: ItemsTreeProps) => {
    */
    */
   if (ancestorsChildrenData != null && rootPageData != null) {
   if (ancestorsChildrenData != null && rootPageData != null) {
     const initialNode = generateInitialNodeAfterResponse(ancestorsChildrenData.ancestorsChildren, new ItemNode(rootPageData.rootPage));
     const initialNode = generateInitialNodeAfterResponse(ancestorsChildrenData.ancestorsChildren, new ItemNode(rootPageData.rootPage));
-    return renderByInitialNode(initialNode, isEnableActions, targetPathOrId, onClickDuplicateMenuItem, onClickDeleteByPage);
+    return renderByInitialNode(initialNode, isEnableActions, targetPathOrId, onClickDuplicateMenuItem, onClickRenameMenuItem, onClickDeleteByPage);
   }
   }
 
 
   /*
   /*
@@ -118,7 +127,7 @@ const ItemsTree: FC<ItemsTreeProps> = (props: ItemsTreeProps) => {
    */
    */
   if (targetAndAncestorsData != null) {
   if (targetAndAncestorsData != null) {
     const initialNode = generateInitialNodeBeforeResponse(targetAndAncestorsData.targetAndAncestors);
     const initialNode = generateInitialNodeBeforeResponse(targetAndAncestorsData.targetAndAncestors);
-    return renderByInitialNode(initialNode, isEnableActions, targetPathOrId, onClickDuplicateMenuItem, onClickDeleteByPage);
+    return renderByInitialNode(initialNode, isEnableActions, targetPathOrId, onClickDuplicateMenuItem, onClickRenameMenuItem, onClickDeleteByPage);
   }
   }
 
 
   return null;
   return null;

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

@@ -34,8 +34,8 @@ const TableOfContents = (props) => {
     const containerComputedStyle = getComputedStyle(containerElem);
     const containerComputedStyle = getComputedStyle(containerElem);
     const containerPaddingTop = parseFloat(containerComputedStyle['padding-top']);
     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) {
     if (isUserPage) {
       // raise the bottom line by the height and margin-top of UserContentLinks
       // raise the bottom line by the height and margin-top of UserContentLinks

+ 59 - 7
packages/app/src/server/models/page.ts

@@ -145,7 +145,7 @@ schema.statics.createEmptyPagesByPaths = async function(paths: string[], publicO
 };
 };
 
 
 schema.statics.createEmptyPage = async function(
 schema.statics.createEmptyPage = async function(
-    path: string, parent: any, descendantCount: number, // TODO: improve type including IPage at https://redmine.weseek.co.jp/issues/86506
+    path: string, parent: any, descendantCount = 0, // TODO: improve type including IPage at https://redmine.weseek.co.jp/issues/86506
 ): Promise<PageDocument & { _id: any }> {
 ): Promise<PageDocument & { _id: any }> {
   if (parent == null) {
   if (parent == null) {
     throw Error('parent must not be null');
     throw Error('parent must not be null');
@@ -459,8 +459,10 @@ schema.statics.incrementDescendantCountOfPageIds = async function(pageIds: Objec
   await this.updateMany({ _id: { $in: pageIds } }, { $inc: { descendantCount: increment } });
   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(
   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) {
 schema.statics.findAncestorsUsingParentRecursively = async function(pageId: ObjectIdLike, shouldIncludeTarget: boolean) {
@@ -518,6 +519,54 @@ schema.statics.findAncestorsUsingParentRecursively = async function(pageId: Obje
   return findAncestorsRecursively(target);
   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 } });
+};
+
 export type PageCreateOptions = {
 export type PageCreateOptions = {
   format?: string
   format?: string
   grantUserGroupId?: ObjectIdLike
   grantUserGroupId?: ObjectIdLike
@@ -534,7 +583,7 @@ export default (crowi: Crowi): any => {
   }
   }
 
 
   schema.statics.create = async function(path: string, body: string, user, options: PageCreateOptions = {}) {
   schema.statics.create = async function(path: string, body: string, user, options: PageCreateOptions = {}) {
-    if (crowi.pageGrantService == null || crowi.configManager == null) {
+    if (crowi.pageGrantService == null || crowi.configManager == null || crowi.pageService == null) {
       throw Error('Crowi is not setup');
       throw Error('Crowi is not setup');
     }
     }
 
 
@@ -593,6 +642,9 @@ export default (crowi: Crowi): any => {
     let page;
     let page;
     if (emptyPage != null) {
     if (emptyPage != null) {
       page = emptyPage;
       page = emptyPage;
+      const descendantCount = await this.recountDescendantCount(page._id);
+
+      page.descendantCount = descendantCount;
       page.isEmpty = false;
       page.isEmpty = false;
     }
     }
     else {
     else {
@@ -622,7 +674,7 @@ export default (crowi: Crowi): any => {
 
 
     let savedPage = await page.save();
     let savedPage = await page.save();
 
 
-    await crowi.pageService?.updateDescendantCountOfAncestors(page._id, 1, false);
+    await crowi.pageService.updateDescendantCountOfAncestors(page._id, 1, false);
 
 
     /*
     /*
      * After save
      * After save

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

@@ -1,3 +1,5 @@
+import mongoose from 'mongoose';
+
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 const logger = loggerFactory('growi:routes:apiv3:import'); // eslint-disable-line no-unused-vars
 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) => {
   router.post('/', accessTokenParser, loginRequired, adminRequired, csrf, async(req, res) => {
     // TODO: add express validator
     // TODO: add express validator
-
     const { fileName, collections, optionsMap } = req.body;
     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);
     const zipFile = importService.getFile(fileName);
 
 
     // return response first
     // return response first

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

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

+ 12 - 2
packages/app/src/server/routes/apiv3/page.js

@@ -159,6 +159,7 @@ module.exports = (crowi) => {
   const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
   const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
   const csrf = require('../../middlewares/csrf')(crowi);
   const csrf = require('../../middlewares/csrf')(crowi);
   const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
   const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
+  const certifySharedPage = require('../../middlewares/certify-shared-page')(crowi);
 
 
   const globalNotificationService = crowi.getGlobalNotificationService();
   const globalNotificationService = crowi.getGlobalNotificationService();
   const socketIoService = crowi.socketIoService;
   const socketIoService = crowi.socketIoService;
@@ -354,8 +355,8 @@ module.exports = (crowi) => {
    *          500:
    *          500:
    *            description: Internal server error.
    *            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;
     const { pageId } = req.query;
 
 
     try {
     try {
@@ -365,6 +366,15 @@ module.exports = (crowi) => {
         return res.apiv3Err(`Page '${pageId}' is not found or forbidden`);
         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 isGuestUser = !req.user;
       const pageInfo = pageService.constructBasicPageInfo(page, isGuestUser);
       const pageInfo = pageService.constructBasicPageInfo(page, isGuestUser);
 
 

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

@@ -638,9 +638,11 @@ module.exports = (crowi) => {
     const newParentPage = await crowi.pageService.duplicate(page, newPagePath, req.user, isRecursively);
     const newParentPage = await crowi.pageService.duplicate(page, newPagePath, req.user, isRecursively);
     const result = { page: serializePageSecurely(newParentPage) };
     const result = { page: serializePageSecurely(newParentPage) };
 
 
-    page.path = newPagePath;
+    // copy the page since it's used and updated in crowi.pageService.duplicate
+    const copyPage = { ...page };
+    copyPage.path = newPagePath;
     try {
     try {
-      await globalNotificationService.fire(GlobalNotificationSetting.EVENT.PAGE_CREATE, page, req.user);
+      await globalNotificationService.fire(GlobalNotificationSetting.EVENT.PAGE_CREATE, copyPage, req.user);
     }
     }
     catch (err) {
     catch (err) {
       logger.error('Create grobal notification failed', err);
       logger.error('Create grobal notification failed', err);
@@ -708,12 +710,11 @@ module.exports = (crowi) => {
 
 
   router.post('/v5-schema-migration', accessTokenParser, loginRequired, adminRequired, csrf, async(req, res) => {
   router.post('/v5-schema-migration', accessTokenParser, loginRequired, adminRequired, csrf, async(req, res) => {
     const isV5Compatible = crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
     const isV5Compatible = crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
-    const Page = crowi.model('Page');
 
 
     try {
     try {
       if (!isV5Compatible) {
       if (!isV5Compatible) {
         // this method throws and emit socketIo event when error occurs
         // 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) {
     catch (err) {

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

@@ -338,6 +338,7 @@ module.exports = function(crowi, app) {
     const offset = parseInt(req.query.offset) || 0;
     const offset = parseInt(req.query.offset) || 0;
     await addRenderVarsForDescendants(renderVars, path, req.user, offset, limit, true);
     await addRenderVarsForDescendants(renderVars, path, req.user, offset, limit, true);
     await addRenderVarsForPageTree(renderVars, pathOrId, req.user);
     await addRenderVarsForPageTree(renderVars, pathOrId, req.user);
+
     addRenderVarsWhenNotFound(renderVars, pathOrId);
     addRenderVarsWhenNotFound(renderVars, pathOrId);
 
 
     return res.render(view, renderVars);
     return res.render(view, renderVars);
@@ -1198,12 +1199,13 @@ module.exports = function(crowi, app) {
         await crowi.pageService.deleteCompletely(page, req.user, options, isRecursively);
         await crowi.pageService.deleteCompletely(page, req.user, options, isRecursively);
       }
       }
       else {
       else {
+        // behave like not found
         const notRecursivelyAndEmpty = page.isEmpty && !isRecursively;
         const notRecursivelyAndEmpty = page.isEmpty && !isRecursively;
         if (notRecursivelyAndEmpty) {
         if (notRecursivelyAndEmpty) {
           return res.json(ApiResponse.error(`Page '${pageId}' is not found.`, 'notfound'));
           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'));
           return res.json(ApiResponse.error('Someone could update this page, so couldn\'t delete.', 'outdated'));
         }
         }
 
 

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

@@ -182,6 +182,13 @@ class ImportService {
     // init status object
     // init status object
     this.currentProgressingStatus = new CollectionProgressingStatus(collections);
     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
     // process serially so as not to waste memory
     const promises = collections.map((collectionName) => {
     const promises = collections.map((collectionName) => {
       const importSettings = importSettingsMap[collectionName];
       const importSettings = importSettingsMap[collectionName];
@@ -199,6 +206,9 @@ class ImportService {
       }
       }
     }
     }
 
 
+    // run normalizeAllPublicPages
+    if (shouldNormalizePages) await this.crowi.pageService.normalizeAllPublicPages();
+
     this.currentProgressingStatus = null;
     this.currentProgressingStatus = null;
     this.emitTerminateEvent();
     this.emitTerminateEvent();
   }
   }
@@ -333,6 +343,8 @@ class ImportService {
 
 
     // upsert
     // upsert
     switch (collectionName) {
     switch (collectionName) {
+      case 'pages':
+        return bulk.find({ path: document.path }).upsert().replaceOne(document);
       default:
       default:
         return bulk.find({ _id: document._id }).upsert().replaceOne(document);
         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 rootPage = await Page.findOne({ path: '/' });
     const rootRevision = await Revision.findOne({ path: '/' });
     const rootRevision = await Revision.findOne({ path: '/' });
     rootPage.creator = adminUser._id;
     rootPage.creator = adminUser._id;
+    rootPage.lastUpdateUser = adminUser._id;
     rootRevision.creator = adminUser._id;
     rootRevision.creator = adminUser._id;
     await Promise.all([rootPage.save(), rootRevision.save()]);
     await Promise.all([rootPage.save(), rootRevision.save()]);
 
 

+ 84 - 35
packages/app/src/server/service/page.ts

@@ -268,6 +268,20 @@ class PageService {
     return page.grant !== Page.GRANT_RESTRICTED && page.grant !== Page.GRANT_SPECIFIED;
     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
    * Generate read stream to operate descendants of the specified page path
    * @param {string} targetPagePath
    * @param {string} targetPagePath
@@ -356,15 +370,39 @@ class PageService {
       update.lastUpdateUser = user;
       update.lastUpdateUser = user;
       update.updatedAt = new Date();
       update.updatedAt = new Date();
     }
     }
-    const renamedPage = await Page.findByIdAndUpdate(page._id, { $set: update }, { new: true });
 
 
+    // *************************
+    // * before rename target page
+    // *************************
+    const oldPageParentId = page.parent; // this is used to update descendantCount of old page's ancestors
+
+    // *************************
+    // * rename target page
+    // *************************
+    const renamedPage = await Page.findByIdAndUpdate(page._id, { $set: update }, { new: true });
     this.pageEvent.emit('rename', page, user);
     this.pageEvent.emit('rename', page, user);
 
 
+    // *************************
+    // * after rename target page
+    // *************************
+    // rename descendants and update descendantCount asynchronously
+    this.resumableRenameDescendants(page, newPagePath, user, options, shouldUseV4Process, renamedPage, oldPageParentId);
+
+    return renamedPage;
+  }
+
+  async resumableRenameDescendants(page, newPagePath, user, options, shouldUseV4Process, renamedPage, oldPageParentId) {
     // TODO: resume
     // TODO: resume
     // update descendants first
     // update descendants first
-    this.renameDescendantsWithStream(page, newPagePath, user, options, shouldUseV4Process);
+    await this.renameDescendantsWithStream(page, newPagePath, user, options, shouldUseV4Process);
 
 
-    return renamedPage;
+    // reduce ancestore's descendantCount
+    const nToReduce = -1 * ((page.isEmpty ? 0 : 1) + page.descendantCount);
+    await this.updateDescendantCountOfAncestors(oldPageParentId, nToReduce, true);
+
+    // increase ancestore's descendantCount
+    const nToIncrease = (renamedPage.isEmpty ? 0 : 1) + page.descendantCount;
+    await this.updateDescendantCountOfAncestors(renamedPage._id, nToIncrease, false);
   }
   }
 
 
   // !!renaming always include descendant pages!!
   // !!renaming always include descendant pages!!
@@ -676,9 +714,17 @@ class PageService {
 
 
     newPagePath = this.crowi.xss.process(newPagePath); // eslint-disable-line no-param-reassign
     newPagePath = this.crowi.xss.process(newPagePath); // eslint-disable-line no-param-reassign
 
 
-    const createdPage = await (Page.create as CreateMethod)(
-      newPagePath, page.revision.body, user, options,
-    );
+    let createdPage;
+
+    if (page.isEmpty) {
+      const parent = await Page.getParentAndFillAncestors(newPagePath);
+      createdPage = await Page.createEmptyPage(newPagePath, parent);
+    }
+    else {
+      createdPage = await (Page.create as CreateMethod)(
+        newPagePath, page.revision.body, user, options,
+      );
+    }
 
 
     // take over tags
     // take over tags
     const originTags = await page.findRelatedTagsById();
     const originTags = await page.findRelatedTagsById();
@@ -694,12 +740,16 @@ class PageService {
 
 
     // TODO: resume
     // TODO: resume
     if (isRecursively) {
     if (isRecursively) {
-      this.duplicateDescendantsWithStream(page, newPagePath, user, shouldUseV4Process);
+      this.resumableDuplicateDescendants(page, newPagePath, user, shouldUseV4Process, createdPage._id);
     }
     }
-
     return result;
     return result;
   }
   }
 
 
+  async resumableDuplicateDescendants(page, newPagePath, user, shouldUseV4Process, createdPageId) {
+    const descendantCountAppliedToAncestors = await this.duplicateDescendantsWithStream(page, newPagePath, user, shouldUseV4Process);
+    await this.updateDescendantCountOfAncestors(createdPageId, descendantCountAppliedToAncestors, false);
+  }
+
   async duplicateV4(page, newPagePath, user, isRecursively) {
   async duplicateV4(page, newPagePath, user, isRecursively) {
     const Page = this.crowi.model('Page');
     const Page = this.crowi.model('Page');
     const PageTagRelation = mongoose.model('PageTagRelation') as any; // TODO: Typescriptize model
     const PageTagRelation = mongoose.model('PageTagRelation') as any; // TODO: Typescriptize model
@@ -803,14 +853,7 @@ class PageService {
       pageIdMapping[page._id] = newPageId;
       pageIdMapping[page._id] = newPageId;
 
 
       let newPage;
       let newPage;
-      if (page.isEmpty) {
-        newPage = {
-          _id: newPageId,
-          path: newPagePath,
-          isEmpty: true,
-        };
-      }
-      else {
+      if (!page.isEmpty) {
         newPage = {
         newPage = {
           _id: newPageId,
           _id: newPageId,
           path: newPagePath,
           path: newPagePath,
@@ -821,14 +864,11 @@ class PageService {
           lastUpdateUser: user._id,
           lastUpdateUser: user._id,
           revision: revisionId,
           revision: revisionId,
         };
         };
+        newRevisions.push({
+          _id: revisionId, pageId: newPageId, body: pageIdRevisionMapping[page._id].body, author: user._id, format: 'markdown',
+        });
       }
       }
-
       newPages.push(newPage);
       newPages.push(newPage);
-
-      newRevisions.push({
-        _id: revisionId, pageId: newPageId, body: pageIdRevisionMapping[page._id].body, author: user._id, format: 'markdown',
-      });
-
     });
     });
 
 
     await Page.insertMany(newPages, { ordered: false });
     await Page.insertMany(newPages, { ordered: false });
@@ -898,11 +938,13 @@ class PageService {
     const normalizeParentAndDescendantCountOfDescendants = this.normalizeParentAndDescendantCountOfDescendants.bind(this);
     const normalizeParentAndDescendantCountOfDescendants = this.normalizeParentAndDescendantCountOfDescendants.bind(this);
     const pageEvent = this.pageEvent;
     const pageEvent = this.pageEvent;
     let count = 0;
     let count = 0;
+    let nNonEmptyDuplicatedPages = 0;
     const writeStream = new Writable({
     const writeStream = new Writable({
       objectMode: true,
       objectMode: true,
       async write(batch, encoding, callback) {
       async write(batch, encoding, callback) {
         try {
         try {
           count += batch.length;
           count += batch.length;
+          nNonEmptyDuplicatedPages += batch.filter(page => !page.isEmpty).length;
           await duplicateDescendants(batch, user, pathRegExp, newPagePathPrefix, shouldUseV4Process);
           await duplicateDescendants(batch, user, pathRegExp, newPagePathPrefix, shouldUseV4Process);
           logger.debug(`Adding pages progressing: (count=${count})`);
           logger.debug(`Adding pages progressing: (count=${count})`);
         }
         }
@@ -938,6 +980,9 @@ class PageService {
       .pipe(createBatchStream(BULK_REINDEX_SIZE))
       .pipe(createBatchStream(BULK_REINDEX_SIZE))
       .pipe(writeStream);
       .pipe(writeStream);
 
 
+    await streamToPromise(writeStream);
+
+    return nNonEmptyDuplicatedPages;
   }
   }
 
 
   private async duplicateDescendantsWithStreamV4(page, newPagePath, user) {
   private async duplicateDescendantsWithStreamV4(page, newPagePath, user) {
@@ -976,6 +1021,9 @@ class PageService {
       .pipe(createBatchStream(BULK_REINDEX_SIZE))
       .pipe(createBatchStream(BULK_REINDEX_SIZE))
       .pipe(writeStream);
       .pipe(writeStream);
 
 
+    await streamToPromise(writeStream);
+
+    return count;
   }
   }
 
 
   /*
   /*
@@ -1014,10 +1062,8 @@ class PageService {
       // update descendantCount of ancestors'
       // update descendantCount of ancestors'
       await this.updateDescendantCountOfAncestors(page.parent, -1, true);
       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;
     let deletedPage;
@@ -1050,7 +1096,8 @@ class PageService {
         if (page.parent != null) {
         if (page.parent != null) {
           await this.updateDescendantCountOfAncestors(page.parent, (deletedDescendantCount + 1) * -1, true);
           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);
         }
         }
       })();
       })();
     }
     }
@@ -1281,10 +1328,11 @@ class PageService {
 
 
     if (!isRecursively) {
     if (!isRecursively) {
       await this.updateDescendantCountOfAncestors(page.parent, -1, true);
       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) {
     if (!page.isEmpty && !preventEmitting) {
       this.pageEvent.emit('deleteCompletely', page, user);
       this.pageEvent.emit('deleteCompletely', page, user);
     }
     }
@@ -1299,8 +1347,6 @@ class PageService {
         if (page.parent != null) {
         if (page.parent != null) {
           await this.updateDescendantCountOfAncestors(page.parent, (deletedDescendantCount + 1) * -1, true);
           await this.updateDescendantCountOfAncestors(page.parent, (deletedDescendantCount + 1) * -1, true);
         }
         }
-
-        // TODO https://redmine.weseek.co.jp/issues/87667 : delete leaf empty pages here
       })();
       })();
     }
     }
 
 
@@ -1460,7 +1506,8 @@ class PageService {
         if (page.parent != null) {
         if (page.parent != null) {
           await this.updateDescendantCountOfAncestors(page.parent, revertedDescendantCount + 1, true);
           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);
         }
         }
       })();
       })();
     }
     }
@@ -1844,7 +1891,7 @@ class PageService {
   }
   }
 
 
   // TODO: use socket to send status to the client
   // TODO: use socket to send status to the client
-  async v5InitialMigration(grant) {
+  async normalizeAllPublicPages() {
     // const socket = this.crowi.socketIoService.getAdminSocket();
     // const socket = this.crowi.socketIoService.getAdminSocket();
 
 
     let isUnique;
     let isUnique;
@@ -1870,7 +1917,8 @@ class PageService {
 
 
     // then migrate
     // then migrate
     try {
     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) {
     catch (err) {
       logger.error('V5 initial miration failed.', err);
       logger.error('V5 initial miration failed.', err);
@@ -2134,7 +2182,8 @@ class PageService {
       objectMode: true,
       objectMode: true,
       async write(pageDocuments, encoding, callback) {
       async write(pageDocuments, encoding, callback) {
         for await (const document of pageDocuments) {
         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();
         callback();
       },
       },

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

@@ -107,6 +107,9 @@
 <div id="page-delete-modal"></div>
 <div id="page-delete-modal"></div>
 <div id="page-duplicate-modal"></div>
 <div id="page-duplicate-modal"></div>
 <div id="page-rename-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' %}
 {% include '../modal/shortcuts.html' %}
 
 
 {% block body_end %}
 {% 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
 // 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(
   return useSWR(
-    `/pages/list?path=${path}&page=${page}`,
+    key,
     endpoint => apiv3Get<{pages: IPageHasId[], totalCount: number, limit: number}>(endpoint).then((response) => {
     endpoint => apiv3Get<{pages: IPageHasId[], totalCount: number, limit: number}>(endpoint).then((response) => {
       return {
       return {
         items: response.data.pages,
         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(
   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),
   );
   );
 };
 };
 
 

+ 74 - 0
packages/app/src/stores/ui.tsx

@@ -416,6 +416,80 @@ 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,
+  activatedContents: Set<PageAccessoriesModalContents>,
+}
+
+type PageAccessoriesModalUtils = {
+  open(activatedContent: PageAccessoriesModalContents): Promise<PageAccessoriesModalStatus> | void
+  close(): Promise<PageAccessoriesModalStatus> | void
+}
+
+export const usePageAccessoriesModal = (
+    status?: PageAccessoriesModalStatus,
+): SWRResponse<PageAccessoriesModalStatus, Error> & PageAccessoriesModalUtils => {
+
+  const initialData: PageAccessoriesModalStatus = { isOpened: false, activatedContents: new Set<PageAccessoriesModalContents>() };
+  const swrResponse = useStaticSWR<PageAccessoriesModalStatus, Error>('pageAccessoriesModalStatus', status, { fallbackData: initialData });
+
+  return {
+    ...swrResponse,
+    open: (activatedContent: PageAccessoriesModalContents) => {
+      if (swrResponse.data == null) {
+        return;
+      }
+      swrResponse.mutate({
+        isOpened: true,
+        activatedContents: swrResponse.data.activatedContents.add(activatedContent),
+      });
+    },
+    close: () => {
+      if (swrResponse.data == null) {
+        return;
+      }
+      swrResponse.mutate({
+        isOpened: false,
+        activatedContents: swrResponse.data.activatedContents,
+      });
+    },
+  };
+};
+
+
 export const useSelectedGrant = (initialData?: Nullable<number>): SWRResponse<Nullable<number>, Error> => {
 export const useSelectedGrant = (initialData?: Nullable<number>): SWRResponse<Nullable<number>, Error> => {
   return useStaticSWR<Nullable<number>, Error>('grant', initialData);
   return useStaticSWR<Nullable<number>, Error>('grant', initialData);
 };
 };

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

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

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

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

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

@@ -75,7 +75,7 @@ describe('V5 page migration', () => {
 
 
   });
   });
 
 
-  describe('v5InitialMigration()', () => {
+  describe('normalizeAllPublicPages()', () => {
     jest.setTimeout(60000);
     jest.setTimeout(60000);
     let createPagePaths;
     let createPagePaths;
     let allPossiblePagePaths;
     let allPossiblePagePaths;
@@ -132,7 +132,7 @@ describe('V5 page migration', () => {
       ]);
       ]);
 
 
       // migrate
       // migrate
-      await crowi.pageService.v5InitialMigration(Page.GRANT_PUBLIC);
+      await crowi.pageService.normalizeAllPublicPages(Page.GRANT_PUBLIC);
       jest.setTimeout(30000);
       jest.setTimeout(30000);
     });
     });