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

Merge remote-tracking branch 'origin/master' into imprv/refactor-search-page

Yuki Takei 4 лет назад
Родитель
Сommit
f136ec8cb7
47 измененных файлов с 797 добавлено и 443 удалено
  1. 2 0
      packages/app/resource/locales/en_US/translation.json
  2. 2 0
      packages/app/resource/locales/ja_JP/translation.json
  3. 2 0
      packages/app/resource/locales/zh_CN/translation.json
  4. 2 0
      packages/app/src/client/base.jsx
  5. 5 0
      packages/app/src/client/services/PageContainer.js
  6. 1 1
      packages/app/src/components/Common/ClosableTextInput.tsx
  7. 7 7
      packages/app/src/components/Common/Dropdown/PageItemControl.tsx
  8. 1 1
      packages/app/src/components/DescendantsPageListModal.tsx
  9. 2 2
      packages/app/src/components/Fab.jsx
  10. 2 2
      packages/app/src/components/Hotkeys/Subscribers/CreatePage.jsx
  11. 1 1
      packages/app/src/components/IdenticalPathPage.tsx
  12. 61 18
      packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  13. 6 4
      packages/app/src/components/Navbar/GrowiNavbar.tsx
  14. 3 2
      packages/app/src/components/Navbar/GrowiNavbarBottom.jsx
  15. 15 3
      packages/app/src/components/Navbar/SubNavButtons.tsx
  16. 2 1
      packages/app/src/components/Page/DisplaySwitcher.tsx
  17. 1 1
      packages/app/src/components/Page/PageManagement.jsx
  18. 1 0
      packages/app/src/components/Page/RevisionBody.jsx
  19. 1 1
      packages/app/src/components/Page/TrashPageAlert.jsx
  20. 1 1
      packages/app/src/components/PageAccessoriesModal.tsx
  21. 4 5
      packages/app/src/components/PageCreateModal.jsx
  22. 13 14
      packages/app/src/components/PageDeleteModal.tsx
  23. 3 4
      packages/app/src/components/PageDuplicateModal.jsx
  24. 8 0
      packages/app/src/components/PageEditor/AbstractEditor.tsx
  25. 2 10
      packages/app/src/components/PageEditor/CodeMirrorEditor.jsx
  26. 20 1
      packages/app/src/components/PageList/PageListItemL.tsx
  27. 6 16
      packages/app/src/components/PagePresentationModal.jsx
  28. 5 4
      packages/app/src/components/PageRenameModal.jsx
  29. 54 5
      packages/app/src/components/SearchPage/SearchResultContent.tsx
  30. 99 19
      packages/app/src/components/Sidebar/PageTree/Item.tsx
  31. 8 6
      packages/app/src/components/Sidebar/PageTree/ItemsTree.tsx
  32. 1 3
      packages/app/src/components/UncontrolledCodeMirror.tsx
  33. 33 0
      packages/app/src/server/models/obsolete-page.js
  34. 17 2
      packages/app/src/server/models/page.ts
  35. 3 0
      packages/app/src/server/models/revision.js
  36. 10 5
      packages/app/src/server/routes/apiv3/pages.js
  37. 1 1
      packages/app/src/server/routes/page.js
  38. 22 8
      packages/app/src/server/service/page-grant.ts
  39. 103 41
      packages/app/src/server/service/page.ts
  40. 15 8
      packages/app/src/server/service/user-group.ts
  41. 4 0
      packages/app/src/server/util/swigFunctions.js
  42. 1 0
      packages/app/src/server/views/layout/layout.html
  43. 2 2
      packages/app/src/server/views/widget/not_found_content.html
  44. 229 0
      packages/app/src/stores/modal.tsx
  45. 0 241
      packages/app/src/stores/ui.tsx
  46. 5 3
      packages/core/src/utils/page-path-utils.ts
  47. 11 0
      packages/core/src/utils/path-utils.js

+ 2 - 0
packages/app/resource/locales/en_US/translation.json

@@ -167,6 +167,8 @@
   "new_path":"New path",
   "new_path":"New path",
   "duplicated_path":"duplicated_path",
   "duplicated_path":"duplicated_path",
   "Link sharing is disabled": "Link sharing is disabled",
   "Link sharing is disabled": "Link sharing is disabled",
+  "successfully_saved_the_page": "Successfully saved the page",
+  "you_can_not_create_page_with_this_name": "You can not create page with this name",
   "personal_dropdown": {
   "personal_dropdown": {
     "home": "Home",
     "home": "Home",
     "settings": "Settings",
     "settings": "Settings",

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

@@ -169,6 +169,8 @@
   "new_path":"新しいパス",
   "new_path":"新しいパス",
   "duplicated_path":"重複したパス",
   "duplicated_path":"重複したパス",
   "Link sharing is disabled": "リンクのシェアは無効化されています",
   "Link sharing is disabled": "リンクのシェアは無効化されています",
+  "successfully_saved_the_page": "ページが正常に保存されました",
+  "you_can_not_create_page_with_this_name": "この名前でページを作成することはできません",
   "personal_dropdown": {
   "personal_dropdown": {
     "home": "ホーム",
     "home": "ホーム",
     "settings": "設定",
     "settings": "設定",

+ 2 - 0
packages/app/resource/locales/zh_CN/translation.json

@@ -175,6 +175,8 @@
   "new_path":"New path",
   "new_path":"New path",
   "duplicated_path":"duplicated_path",
   "duplicated_path":"duplicated_path",
   "Link sharing is disabled": "你不允许分享该链接",
   "Link sharing is disabled": "你不允许分享该链接",
+  "successfully_saved_the_page": "成功地保存了该页面",
+  "you_can_not_create_page_with_this_name": "您无法使用此名称创建页面",
 	"form_validation": {
 	"form_validation": {
 		"error_message": "有些值不正确",
 		"error_message": "有些值不正确",
 		"required": "%s 是必需的",
 		"required": "%s 是必需的",

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

@@ -10,6 +10,7 @@ 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 PagePresentationModal from '../components/PagePresentationModal';
 import PageAccessoriesModal from '../components/PageAccessoriesModal';
 import PageAccessoriesModal from '../components/PageAccessoriesModal';
 
 
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
@@ -48,6 +49,7 @@ 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-presentation-modal': <PagePresentationModal />,
   'page-accessories-modal': <PageAccessoriesModal />,
   'page-accessories-modal': <PageAccessoriesModal />,
   'descendants-page-list-modal': <DescendantsPageListModal />,
   'descendants-page-list-modal': <DescendantsPageListModal />,
 
 

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

@@ -459,6 +459,7 @@ export default class PageContainer extends Container {
 
 
     const { pageId, remoteRevisionId, path } = this.state;
     const { pageId, remoteRevisionId, path } = this.state;
     const editorContainer = this.appContainer.getContainer('EditorContainer');
     const editorContainer = this.appContainer.getContainer('EditorContainer');
+    const pageEditor = this.appContainer.getComponentInstance('PageEditor');
     const options = editorContainer.getCurrentOptionsToSave();
     const options = editorContainer.getCurrentOptionsToSave();
     const optionsToSave = Object.assign({}, options);
     const optionsToSave = Object.assign({}, options);
 
 
@@ -467,6 +468,10 @@ export default class PageContainer extends Container {
     editorContainer.clearDraft(path);
     editorContainer.clearDraft(path);
     this.updateStateAfterSave(res.page, res.tags, res.revision, editorMode);
     this.updateStateAfterSave(res.page, res.tags, res.revision, editorMode);
 
 
+    if (pageEditor != null) {
+      pageEditor.updateEditorValue(markdown);
+    }
+
     editorContainer.setState({ tags: res.tags });
     editorContainer.setState({ tags: res.tags });
 
 
     return res;
     return res;

+ 1 - 1
packages/app/src/components/Common/ClosableTextInput.tsx

@@ -107,7 +107,7 @@ const ClosableTextInput: FC<ClosableTextInputProps> = memo((props: ClosableTextI
   return (
   return (
     <div className={props.isShown ? 'd-block' : 'd-none'}>
     <div className={props.isShown ? 'd-block' : 'd-none'}>
       <input
       <input
-        value={inputText}
+        value={inputText || ''}
         ref={inputRef}
         ref={inputRef}
         type="text"
         type="text"
         className="form-control"
         className="form-control"

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

@@ -23,7 +23,7 @@ type CommonProps = {
   isEnableActions?: boolean,
   isEnableActions?: boolean,
   showBookmarkMenuItem?: boolean,
   showBookmarkMenuItem?: boolean,
   onClickBookmarkMenuItem?: (pageId: string, newValue?: boolean) => Promise<void>,
   onClickBookmarkMenuItem?: (pageId: string, newValue?: boolean) => Promise<void>,
-  onClickDuplicateMenuItem?: () => Promise<void> | void,
+  onClickDuplicateMenuItem?: (pageId: string) => Promise<void> | void,
   onClickRenameMenuItem?: (pageId: string) => Promise<void> | void,
   onClickRenameMenuItem?: (pageId: string) => Promise<void> | void,
   onClickDeleteMenuItem?: (pageId: string) => Promise<void> | void,
   onClickDeleteMenuItem?: (pageId: string) => Promise<void> | void,
 
 
@@ -60,8 +60,8 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
     if (onClickDuplicateMenuItem == null) {
     if (onClickDuplicateMenuItem == null) {
       return;
       return;
     }
     }
-    await onClickDuplicateMenuItem();
-  }, [onClickDuplicateMenuItem]);
+    await onClickDuplicateMenuItem(pageId);
+  }, [onClickDuplicateMenuItem, pageId]);
 
 
   // eslint-disable-next-line react-hooks/rules-of-hooks
   // eslint-disable-next-line react-hooks/rules-of-hooks
   const renameItemClickedHandler = useCallback(async() => {
   const renameItemClickedHandler = useCallback(async() => {
@@ -112,7 +112,7 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
         ) }
         ) }
 
 
         {/* Duplicate */}
         {/* Duplicate */}
-        { isEnableActions && !pageInfo.isEmpty && (
+        { isEnableActions && (
           <DropdownItem onClick={duplicateItemClickedHandler}>
           <DropdownItem onClick={duplicateItemClickedHandler}>
             <i className="icon-fw icon-docs"></i>
             <i className="icon-fw icon-docs"></i>
             {t('Duplicate')}
             {t('Duplicate')}
@@ -131,7 +131,7 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
 
 
         {/* divider */}
         {/* divider */}
         {/* Delete */}
         {/* Delete */}
-        { isEnableActions && pageInfo.isMovable && !pageInfo.isEmpty && (
+        { isEnableActions && pageInfo.isMovable && (
           <>
           <>
             <DropdownItem divider />
             <DropdownItem divider />
             <DropdownItem
             <DropdownItem
@@ -194,8 +194,8 @@ export const PageItemControlSubstance = (props: PageItemControlSubstanceProps):
     if (onClickDuplicateMenuItem == null) {
     if (onClickDuplicateMenuItem == null) {
       return;
       return;
     }
     }
-    await onClickDuplicateMenuItem();
-  }, [onClickDuplicateMenuItem]);
+    await onClickDuplicateMenuItem(pageId);
+  }, [onClickDuplicateMenuItem, pageId]);
 
 
   const renameMenuItemClickHandler = useCallback(async() => {
   const renameMenuItemClickHandler = useCallback(async() => {
     if (onClickRenameMenuItem == null) {
     if (onClickRenameMenuItem == null) {

+ 1 - 1
packages/app/src/components/DescendantsPageListModal.tsx

@@ -6,7 +6,7 @@ import {
   Modal, ModalHeader, ModalBody,
   Modal, ModalHeader, ModalBody,
 } from 'reactstrap';
 } from 'reactstrap';
 
 
-import { useDescendantsPageListModal } from '~/stores/ui';
+import { useDescendantsPageListModal } from '~/stores/modal';
 import { useIsSharedUser } from '~/stores/context';
 import { useIsSharedUser } from '~/stores/context';
 
 
 import DescendantsPageList from './DescendantsPageList';
 import DescendantsPageList from './DescendantsPageList';

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

@@ -6,7 +6,7 @@ import loggerFactory from '~/utils/logger';
 
 
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
 
 
-import { useCreateModalStatus } from '~/stores/ui';
+import { usePageCreateModal } from '~/stores/modal';
 import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
 import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
 
 
 import { withUnstatedContainers } from './UnstatedUtils';
 import { withUnstatedContainers } from './UnstatedUtils';
@@ -19,7 +19,7 @@ const Fab = (props) => {
   const { appContainer } = props;
   const { appContainer } = props;
   const { currentUser } = appContainer;
   const { currentUser } = appContainer;
 
 
-  const { open: openCreateModal } = useCreateModalStatus();
+  const { open: openCreateModal } = usePageCreateModal();
 
 
   const [animateClasses, setAnimateClasses] = useState('invisible');
   const [animateClasses, setAnimateClasses] = useState('invisible');
   const [buttonClasses, setButtonClasses] = useState('');
   const [buttonClasses, setButtonClasses] = useState('');

+ 2 - 2
packages/app/src/components/Hotkeys/Subscribers/CreatePage.jsx

@@ -1,11 +1,11 @@
 import React, { useEffect } from 'react';
 import React, { useEffect } from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
-import { useCreateModalStatus } from '~/stores/ui';
+import { usePageCreateModal } from '~/stores/modal';
 
 
 const CreatePage = React.memo((props) => {
 const CreatePage = React.memo((props) => {
 
 
-  const { open: openCreateModal } = useCreateModalStatus();
+  const { open: openCreateModal } = usePageCreateModal();
 
 
   // setup effect
   // setup effect
   useEffect(() => {
   useEffect(() => {

+ 1 - 1
packages/app/src/components/IdenticalPathPage.tsx

@@ -5,7 +5,7 @@ import { DevidedPagePath } from '@growi/core';
 
 
 import { IPageHasId, IPageWithMeta } from '~/interfaces/page';
 import { IPageHasId, IPageWithMeta } from '~/interfaces/page';
 import { useCurrentPagePath, useIsSharedUser } from '~/stores/context';
 import { useCurrentPagePath, useIsSharedUser } from '~/stores/context';
-import { useDescendantsPageListModal } from '~/stores/ui';
+import { useDescendantsPageListModal } from '~/stores/modal';
 import { useSWRxPageInfoForList } from '~/stores/page';
 import { useSWRxPageInfoForList } from '~/stores/page';
 
 
 import PageListIcon from './Icons/PageListIcon';
 import PageListIcon from './Icons/PageListIcon';

+ 61 - 18
packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -1,4 +1,4 @@
-import React, { useCallback } from 'react';
+import React, { useState, useCallback } from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
@@ -9,12 +9,17 @@ 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, usePageAccessoriesModal, PageAccessoriesModalContents,
-  usePageRenameModalStatus, usePageDeleteModal,
+  useIsAbleToShowPageEditorModeManager, useIsAbleToShowPageAuthors,
 } from '~/stores/ui';
 } from '~/stores/ui';
+import {
+  usePageAccessoriesModal, PageAccessoriesModalContents,
+  usePageDuplicateModal, usePageRenameModal, usePageDeleteModal, usePagePresentationModal,
+} from '~/stores/modal';
+
+
 import {
 import {
   useCurrentCreatedAt, useCurrentUpdatedAt, useCurrentPageId, useRevisionId, useCurrentPagePath,
   useCurrentCreatedAt, useCurrentUpdatedAt, useCurrentPageId, useRevisionId, useCurrentPagePath,
-  useCreator, useRevisionAuthor, useIsGuestUser, useIsSharedUser, useShareLinkId,
+  useCreator, useRevisionAuthor, useCurrentUser, useIsGuestUser, useIsSharedUser, useShareLinkId,
 } from '~/stores/context';
 } from '~/stores/context';
 import { useSWRTagsInfo } from '~/stores/page';
 import { useSWRTagsInfo } from '~/stores/page';
 
 
@@ -31,6 +36,7 @@ import { SubNavButtons } from './SubNavButtons';
 import PageEditorModeManager from './PageEditorModeManager';
 import PageEditorModeManager from './PageEditorModeManager';
 import { GrowiSubNavigation } from './GrowiSubNavigation';
 import { GrowiSubNavigation } from './GrowiSubNavigation';
 import PresentationIcon from '../Icons/PresentationIcon';
 import PresentationIcon from '../Icons/PresentationIcon';
+import CreateTemplateModal from '../CreateTemplateModal';
 import { exportAsMarkdown } from '~/client/services/page-operation';
 import { exportAsMarkdown } from '~/client/services/page-operation';
 
 
 
 
@@ -38,22 +44,33 @@ type AdditionalMenuItemsProps = AdditionalMenuItemsRendererProps & {
   pageId: string,
   pageId: string,
   revisionId: string,
   revisionId: string,
   isLinkSharingDisabled?: boolean,
   isLinkSharingDisabled?: boolean,
+  onClickTemplateMenuItem: (isPageTemplateModalShown: boolean) => void,
+
 }
 }
 
 
 const AdditionalMenuItems = (props: AdditionalMenuItemsProps): JSX.Element => {
 const AdditionalMenuItems = (props: AdditionalMenuItemsProps): JSX.Element => {
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
-  const { pageId, revisionId, isLinkSharingDisabled } = props;
+  const {
+    pageId, revisionId, isLinkSharingDisabled, onClickTemplateMenuItem,
+  } = props;
+
+  const openPageTemplateModalHandler = () => {
+    onClickTemplateMenuItem(true);
+  };
 
 
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isSharedUser } = useIsSharedUser();
   const { data: isSharedUser } = useIsSharedUser();
 
 
-  const { open } = usePageAccessoriesModal();
+  const { open: openPresentationModal } = usePagePresentationModal();
+  const { open: openAccessoriesModal } = usePageAccessoriesModal();
+
+  const hrefForPresentationModal = '?presentation=1';
 
 
   return (
   return (
     <>
     <>
       {/* Presentation */}
       {/* Presentation */}
-      <DropdownItem onClick={() => { /* TODO: implement in https://redmine.weseek.co.jp/issues/87672 */ }}>
+      <DropdownItem onClick={() => openPresentationModal(hrefForPresentationModal)}>
         <i className="icon-fw"><PresentationIcon /></i>
         <i className="icon-fw"><PresentationIcon /></i>
         { t('Presentation Mode') }
         { t('Presentation Mode') }
       </DropdownItem>
       </DropdownItem>
@@ -71,7 +88,7 @@ const AdditionalMenuItems = (props: AdditionalMenuItemsProps): JSX.Element => {
         refs: PageAccessoriesModalControl
         refs: PageAccessoriesModalControl
       */}
       */}
       <DropdownItem
       <DropdownItem
-        onClick={() => open(PageAccessoriesModalContents.PageHistory)}
+        onClick={() => openAccessoriesModal(PageAccessoriesModalContents.PageHistory)}
         disabled={isGuestUser || isSharedUser}
         disabled={isGuestUser || isSharedUser}
       >
       >
         <span className="mr-1"><HistoryIcon /></span>
         <span className="mr-1"><HistoryIcon /></span>
@@ -79,14 +96,14 @@ const AdditionalMenuItems = (props: AdditionalMenuItemsProps): JSX.Element => {
       </DropdownItem>
       </DropdownItem>
 
 
       <DropdownItem
       <DropdownItem
-        onClick={() => open(PageAccessoriesModalContents.Attachment)}
+        onClick={() => openAccessoriesModal(PageAccessoriesModalContents.Attachment)}
       >
       >
         <span className="mr-1"><AttachmentIcon /></span>
         <span className="mr-1"><AttachmentIcon /></span>
         {t('attachment_data')}
         {t('attachment_data')}
       </DropdownItem>
       </DropdownItem>
 
 
       <DropdownItem
       <DropdownItem
-        onClick={() => open(PageAccessoriesModalContents.ShareLink)}
+        onClick={() => openAccessoriesModal(PageAccessoriesModalContents.ShareLink)}
         disabled={isGuestUser || isSharedUser || isLinkSharingDisabled}
         disabled={isGuestUser || isSharedUser || isLinkSharingDisabled}
       >
       >
         <span className="mr-1"><ShareLinkIcon /></span>
         <span className="mr-1"><ShareLinkIcon /></span>
@@ -96,7 +113,7 @@ const AdditionalMenuItems = (props: AdditionalMenuItemsProps): JSX.Element => {
       <DropdownItem divider />
       <DropdownItem divider />
 
 
       {/* Create template */}
       {/* Create template */}
-      <DropdownItem onClick={() => { /* TODO: implement in https://redmine.weseek.co.jp/issues/87673 */ }}>
+      <DropdownItem onClick={openPageTemplateModalHandler}>
         <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') }
       </DropdownItem>
       </DropdownItem>
     </>
     </>
@@ -115,6 +132,7 @@ const GrowiContextualSubNavigation = (props) => {
   const { data: path } = useCurrentPagePath();
   const { data: path } = useCurrentPagePath();
   const { data: creator } = useCreator();
   const { data: creator } = useCreator();
   const { data: revisionAuthor } = useRevisionAuthor();
   const { data: revisionAuthor } = useRevisionAuthor();
+  const { data: currentUser } = useCurrentUser();
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isSharedUser } = useIsSharedUser();
   const { data: isSharedUser } = useIsSharedUser();
   const { data: shareLinkId } = useShareLinkId();
   const { data: shareLinkId } = useShareLinkId();
@@ -126,9 +144,12 @@ const GrowiContextualSubNavigation = (props) => {
 
 
   const { mutate: mutateSWRTagsInfo, data: tagsInfoData } = useSWRTagsInfo(pageId);
   const { mutate: mutateSWRTagsInfo, data: tagsInfoData } = useSWRTagsInfo(pageId);
 
 
-  const { open: openRenameModal } = usePageRenameModalStatus();
+  const { open: openDuplicateModal } = usePageDuplicateModal();
+  const { open: openRenameModal } = usePageRenameModal();
   const { open: openDeleteModal } = usePageDeleteModal();
   const { open: openDeleteModal } = usePageDeleteModal();
 
 
+  const [isPageTemplateModalShown, setIsPageTempleteModalShown] = useState(false);
+
   const {
   const {
     editorContainer, isCompactMode, isLinkSharingDisabled,
     editorContainer, isCompactMode, isLinkSharingDisabled,
   } = props;
   } = props;
@@ -157,6 +178,10 @@ const GrowiContextualSubNavigation = (props) => {
   // eslint-disable-next-line react-hooks/exhaustive-deps
   // eslint-disable-next-line react-hooks/exhaustive-deps
   }, [pageId]);
   }, [pageId]);
 
 
+  const duplicateItemClickedHandler = useCallback(async(pageId, path) => {
+    openDuplicateModal(pageId, path);
+  }, [openDuplicateModal]);
+
   const renameItemClickedHandler = useCallback(async(pageId, revisionId, path) => {
   const renameItemClickedHandler = useCallback(async(pageId, revisionId, path) => {
     openRenameModal(pageId, revisionId, path);
     openRenameModal(pageId, revisionId, path);
   }, [openRenameModal]);
   }, [openRenameModal]);
@@ -165,6 +190,11 @@ const GrowiContextualSubNavigation = (props) => {
     openDeleteModal([pageToDelete]);
     openDeleteModal([pageToDelete]);
   }, [openDeleteModal]);
   }, [openDeleteModal]);
 
 
+  const templateMenuItemClickHandler = useCallback(() => {
+    setIsPageTempleteModalShown(true);
+  }, []);
+
+
   const ControlComponents = useCallback(() => {
   const ControlComponents = useCallback(() => {
     function onPageEditorModeButtonClicked(viewType) {
     function onPageEditorModeButtonClicked(viewType) {
       mutateEditorMode(viewType);
       mutateEditorMode(viewType);
@@ -183,8 +213,15 @@ const GrowiContextualSubNavigation = (props) => {
               disableSeenUserInfoPopover={isSharedUser}
               disableSeenUserInfoPopover={isSharedUser}
               showPageControlDropdown={isAbleToShowPageManagement}
               showPageControlDropdown={isAbleToShowPageManagement}
               additionalMenuItemRenderer={props => (
               additionalMenuItemRenderer={props => (
-                <AdditionalMenuItems {...props} pageId={pageId} revisionId={revisionId} isLinkSharingDisabled={isLinkSharingDisabled} />
+                <AdditionalMenuItems
+                  {...props}
+                  pageId={pageId}
+                  revisionId={revisionId}
+                  isLinkSharingDisabled={isLinkSharingDisabled}
+                  onClickTemplateMenuItem={templateMenuItemClickHandler}
+                />
               )}
               )}
+              onClickDuplicateMenuItem={duplicateItemClickedHandler}
               onClickRenameMenuItem={renameItemClickedHandler}
               onClickRenameMenuItem={renameItemClickedHandler}
               onClickDeleteMenuItem={deleteItemClickedHandler}
               onClickDeleteMenuItem={deleteItemClickedHandler}
             />
             />
@@ -200,15 +237,21 @@ const GrowiContextualSubNavigation = (props) => {
             />
             />
           )}
           )}
         </div>
         </div>
+        {currentUser != null && (
+          <CreateTemplateModal
+            path={path}
+            isOpen={isPageTemplateModalShown}
+            onClose={() => setIsPageTempleteModalShown(false)}
+          />
+        )}
       </>
       </>
     );
     );
   }, [
   }, [
-    pageId, revisionId, shareLinkId,
-    editorMode, mutateEditorMode,
-    isCompactMode, isLinkSharingDisabled,
-    isDeviceSmallerThanMd, isGuestUser, isSharedUser,
+    pageId, revisionId, shareLinkId, editorMode, mutateEditorMode, isCompactMode,
+    isLinkSharingDisabled, isDeviceSmallerThanMd, isGuestUser, isSharedUser, currentUser,
     isViewMode, isAbleToShowPageEditorModeManager, isAbleToShowPageManagement,
     isViewMode, isAbleToShowPageEditorModeManager, isAbleToShowPageManagement,
-    renameItemClickedHandler, deleteItemClickedHandler, path,
+    duplicateItemClickedHandler, renameItemClickedHandler, deleteItemClickedHandler,
+    path, templateMenuItemClickHandler, isPageTemplateModalShown,
   ]);
   ]);
 
 
 
 

+ 6 - 4
packages/app/src/components/Navbar/GrowiNavbar.tsx

@@ -7,8 +7,9 @@ import { UncontrolledTooltip } from 'reactstrap';
 
 
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
 import { IUser } from '~/interfaces/user';
 import { IUser } from '~/interfaces/user';
-import { useIsDeviceSmallerThanMd, useCreateModalStatus } from '~/stores/ui';
-import { useIsSearchPage } from '~/stores/context';
+import { useIsDeviceSmallerThanMd } from '~/stores/ui';
+import { usePageCreateModal } from '~/stores/modal';
+import { useIsSearchPage, useCurrentPagePath } from '~/stores/context';
 
 
 import { withUnstatedContainers } from '../UnstatedUtils';
 import { withUnstatedContainers } from '../UnstatedUtils';
 import GrowiLogo from '../Icons/GrowiLogo';
 import GrowiLogo from '../Icons/GrowiLogo';
@@ -23,7 +24,8 @@ type NavbarRightProps = {
 }
 }
 const NavbarRight: FC<NavbarRightProps> = memo((props: NavbarRightProps) => {
 const NavbarRight: FC<NavbarRightProps> = memo((props: NavbarRightProps) => {
   const { t } = useTranslation();
   const { t } = useTranslation();
-  const { open: openCreateModal } = useCreateModalStatus();
+  const { data: currentPagePath } = useCurrentPagePath();
+  const { open: openCreateModal } = usePageCreateModal();
 
 
   const { currentUser } = props;
   const { currentUser } = props;
 
 
@@ -42,7 +44,7 @@ const NavbarRight: FC<NavbarRightProps> = memo((props: NavbarRightProps) => {
         <button
         <button
           className="px-md-3 nav-link btn-create-page border-0 bg-transparent"
           className="px-md-3 nav-link btn-create-page border-0 bg-transparent"
           type="button"
           type="button"
-          onClick={() => openCreateModal()}
+          onClick={() => openCreateModal(currentPagePath || '')}
         >
         >
           <i className="icon-pencil mr-2"></i>
           <i className="icon-pencil mr-2"></i>
           <span className="d-none d-lg-block">{ t('New') }</span>
           <span className="d-none d-lg-block">{ t('New') }</span>

+ 3 - 2
packages/app/src/components/Navbar/GrowiNavbarBottom.jsx

@@ -2,7 +2,8 @@ import React from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
 
 
-import { useCreateModalStatus, useIsDeviceSmallerThanMd, useDrawerOpened } from '~/stores/ui';
+import { useIsDeviceSmallerThanMd, useDrawerOpened } from '~/stores/ui';
+import { usePageCreateModal } from '~/stores/modal';
 import { useCurrentPagePath, useIsSearchPage } from '~/stores/context';
 import { useCurrentPagePath, useIsSearchPage } from '~/stores/context';
 
 
 import GlobalSearch from './GlobalSearch';
 import GlobalSearch from './GlobalSearch';
@@ -11,7 +12,7 @@ const GrowiNavbarBottom = (props) => {
 
 
   const { data: isDrawerOpened, mutate: mutateDrawerOpened } = useDrawerOpened();
   const { data: isDrawerOpened, mutate: mutateDrawerOpened } = useDrawerOpened();
   const { data: isDeviceSmallerThanMd } = useIsDeviceSmallerThanMd();
   const { data: isDeviceSmallerThanMd } = useIsDeviceSmallerThanMd();
-  const { open: openCreateModal } = useCreateModalStatus();
+  const { open: openCreateModal } = usePageCreateModal();
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: isSearchPage } = useIsSearchPage();
   const { data: isSearchPage } = useIsSearchPage();
 
 

+ 15 - 3
packages/app/src/components/Navbar/SubNavButtons.tsx

@@ -6,7 +6,7 @@ import { useSWRxPageInfo } from '../../stores/page';
 import { useSWRBookmarkInfo } from '../../stores/bookmark';
 import { useSWRBookmarkInfo } from '../../stores/bookmark';
 import { useSWRxUsersList } from '../../stores/user';
 import { useSWRxUsersList } from '../../stores/user';
 import { useIsGuestUser } from '~/stores/context';
 import { useIsGuestUser } from '~/stores/context';
-import { IPageForPageDeleteModal } from '~/stores/ui';
+import { IPageForPageDeleteModal } from '~/stores/modal';
 
 
 import SubscribeButton from '../SubscribeButton';
 import SubscribeButton from '../SubscribeButton';
 import LikeButtons from '../LikeButtons';
 import LikeButtons from '../LikeButtons';
@@ -21,6 +21,7 @@ type CommonProps = {
   disableSeenUserInfoPopover?: boolean,
   disableSeenUserInfoPopover?: boolean,
   showPageControlDropdown?: boolean,
   showPageControlDropdown?: boolean,
   additionalMenuItemRenderer?: React.FunctionComponent<AdditionalMenuItemsRendererProps>,
   additionalMenuItemRenderer?: React.FunctionComponent<AdditionalMenuItemsRendererProps>,
+  onClickDuplicateMenuItem?: (pageId: string, path: string) => void,
   onClickRenameMenuItem?: (pageId: string, revisionId: string, path: string) => void,
   onClickRenameMenuItem?: (pageId: string, revisionId: string, path: string) => void,
   onClickDeleteMenuItem?: (pageToDelete: IPageForPageDeleteModal | null) => void,
   onClickDeleteMenuItem?: (pageToDelete: IPageForPageDeleteModal | null) => void,
 }
 }
@@ -37,7 +38,8 @@ const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element
   const {
   const {
     pageInfo,
     pageInfo,
     pageId, revisionId, path, shareLinkId,
     pageId, revisionId, path, shareLinkId,
-    isCompactMode, disableSeenUserInfoPopover, showPageControlDropdown, additionalMenuItemRenderer, onClickRenameMenuItem, onClickDeleteMenuItem,
+    isCompactMode, disableSeenUserInfoPopover, showPageControlDropdown, additionalMenuItemRenderer,
+    onClickDuplicateMenuItem, onClickRenameMenuItem, onClickDeleteMenuItem,
   } = props;
   } = props;
 
 
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isGuestUser } = useIsGuestUser();
@@ -91,6 +93,14 @@ const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element
     mutateBookmarkInfo();
     mutateBookmarkInfo();
   }, [isGuestUser, mutateBookmarkInfo, mutatePageInfo, pageId, pageInfo]);
   }, [isGuestUser, mutateBookmarkInfo, mutatePageInfo, pageId, pageInfo]);
 
 
+  const duplicateMenuItemClickHandler = useCallback(async(_pageId: string): Promise<void> => {
+    if (onClickDuplicateMenuItem == null || path == null) {
+      return;
+    }
+
+    onClickDuplicateMenuItem(pageId, path);
+  }, [onClickDuplicateMenuItem, pageId, path]);
+
   const renameMenuItemClickHandler = useCallback(async(_pageId: string): Promise<void> => {
   const renameMenuItemClickHandler = useCallback(async(_pageId: string): Promise<void> => {
     if (onClickRenameMenuItem == null || path == null) {
     if (onClickRenameMenuItem == null || path == null) {
       return;
       return;
@@ -152,6 +162,7 @@ const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element
           isEnableActions={!isGuestUser}
           isEnableActions={!isGuestUser}
           additionalMenuItemRenderer={additionalMenuItemRenderer}
           additionalMenuItemRenderer={additionalMenuItemRenderer}
           onClickRenameMenuItem={renameMenuItemClickHandler}
           onClickRenameMenuItem={renameMenuItemClickHandler}
+          onClickDuplicateMenuItem={duplicateMenuItemClickHandler}
           onClickDeleteMenuItem={deleteMenuItemClickHandler}
           onClickDeleteMenuItem={deleteMenuItemClickHandler}
         />
         />
       )}
       )}
@@ -168,7 +179,7 @@ type SubNavButtonsProps= CommonProps & {
 
 
 export const SubNavButtons = (props: SubNavButtonsProps): JSX.Element => {
 export const SubNavButtons = (props: SubNavButtonsProps): JSX.Element => {
   const {
   const {
-    pageId, revisionId, path, shareLinkId, onClickRenameMenuItem, onClickDeleteMenuItem,
+    pageId, revisionId, path, shareLinkId, onClickDuplicateMenuItem, onClickRenameMenuItem, onClickDeleteMenuItem,
   } = props;
   } = props;
 
 
   const { data: pageInfo, error } = useSWRxPageInfo(pageId ?? null, shareLinkId);
   const { data: pageInfo, error } = useSWRxPageInfo(pageId ?? null, shareLinkId);
@@ -189,6 +200,7 @@ export const SubNavButtons = (props: SubNavButtonsProps): JSX.Element => {
       pageId={pageId}
       pageId={pageId}
       revisionId={revisionId}
       revisionId={revisionId}
       path={path}
       path={path}
+      onClickDuplicateMenuItem={onClickDuplicateMenuItem}
       onClickRenameMenuItem={onClickRenameMenuItem}
       onClickRenameMenuItem={onClickRenameMenuItem}
       onClickDeleteMenuItem={onClickDeleteMenuItem}
       onClickDeleteMenuItem={onClickDeleteMenuItem}
     />
     />

+ 2 - 1
packages/app/src/components/Page/DisplaySwitcher.tsx

@@ -4,7 +4,8 @@ import { TabContent, TabPane } from 'reactstrap';
 
 
 import { pagePathUtils } from '@growi/core';
 import { pagePathUtils } from '@growi/core';
 
 
-import { EditorMode, useEditorMode, useDescendantsPageListModal } from '~/stores/ui';
+import { EditorMode, useEditorMode } from '~/stores/ui';
+import { useDescendantsPageListModal } from '~/stores/modal';
 import {
 import {
   useCurrentPagePath, useIsSharedUser, useIsEditable, useCurrentPageId, useIsUserPage, usePageUser,
   useCurrentPagePath, useIsSharedUser, useIsEditable, useCurrentPageId, useIsUserPage, usePageUser,
 } from '~/stores/context';
 } from '~/stores/context';

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

@@ -5,7 +5,7 @@ import { withTranslation } from 'react-i18next';
 import urljoin from 'url-join';
 import urljoin from 'url-join';
 
 
 import { pagePathUtils } from '@growi/core';
 import { pagePathUtils } from '@growi/core';
-import { usePageDeleteModal } from '~/stores/ui';
+import { usePageDeleteModal } from '~/stores/modal';
 
 
 import { withUnstatedContainers } from '../UnstatedUtils';
 import { withUnstatedContainers } from '../UnstatedUtils';
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';

+ 1 - 0
packages/app/src/components/Page/RevisionBody.jsx

@@ -64,6 +64,7 @@ export default class RevisionBody extends React.PureComponent {
             this.props.inputRef(elm);
             this.props.inputRef(elm);
           }
           }
         }}
         }}
+        id="wiki"
         className={`wiki ${additionalClassName}`}
         className={`wiki ${additionalClassName}`}
         // eslint-disable-next-line react/no-danger
         // eslint-disable-next-line react/no-danger
         dangerouslySetInnerHTML={this.generateInnerHtml(this.props.html)}
         dangerouslySetInnerHTML={this.generateInnerHtml(this.props.html)}

+ 1 - 1
packages/app/src/components/Page/TrashPageAlert.jsx

@@ -11,7 +11,7 @@ import PutbackPageModal from '../PutbackPageModal';
 import EmptyTrashModal from '../EmptyTrashModal';
 import EmptyTrashModal from '../EmptyTrashModal';
 
 
 import { useCurrentUpdatedAt } from '~/stores/context';
 import { useCurrentUpdatedAt } from '~/stores/context';
-import { usePageDeleteModal } from '~/stores/ui';
+import { usePageDeleteModal } from '~/stores/modal';
 
 
 const TrashPageAlert = (props) => {
 const TrashPageAlert = (props) => {
   const { t, pageContainer } = props;
   const { t, pageContainer } = props;

+ 1 - 1
packages/app/src/components/PageAccessoriesModal.tsx

@@ -7,7 +7,7 @@ import {
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 
 
 import { useIsGuestUser, useIsSharedUser } from '~/stores/context';
 import { useIsGuestUser, useIsSharedUser } from '~/stores/context';
-import { usePageAccessoriesModal, PageAccessoriesModalContents } from '~/stores/ui';
+import { usePageAccessoriesModal, PageAccessoriesModalContents } from '~/stores/modal';
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
 
 
 import HistoryIcon from './Icons/HistoryIcon';
 import HistoryIcon from './Icons/HistoryIcon';

+ 4 - 5
packages/app/src/components/PageCreateModal.jsx

@@ -13,7 +13,8 @@ import { pagePathUtils, pathUtils } from '@growi/core';
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
 import { withUnstatedContainers } from './UnstatedUtils';
 import { withUnstatedContainers } from './UnstatedUtils';
 import { toastError } from '~/client/util/apiNotification';
 import { toastError } from '~/client/util/apiNotification';
-import { useCreateModalStatus, useCreateModalOpened, useCreateModalPath } from '~/stores/ui';
+
+import { usePageCreateModal } from '~/stores/modal';
 
 
 import PagePathAutoComplete from './PagePathAutoComplete';
 import PagePathAutoComplete from './PagePathAutoComplete';
 
 
@@ -24,10 +25,8 @@ const {
 const PageCreateModal = (props) => {
 const PageCreateModal = (props) => {
   const { t, appContainer } = props;
   const { t, appContainer } = props;
 
 
-  const { close: closeCreateModal } = useCreateModalStatus();
-  const { data: isOpened } = useCreateModalOpened();
-  const { data: path } = useCreateModalPath();
-
+  const { data: pageCreateModalData, close: closeCreateModal } = usePageCreateModal();
+  const { isOpened, path } = pageCreateModalData;
 
 
   const config = appContainer.getConfig();
   const config = appContainer.getConfig();
   const isReachable = config.isSearchServiceReachable;
   const isReachable = config.isSearchServiceReachable;

+ 13 - 14
packages/app/src/components/PageDeleteModal.tsx

@@ -6,7 +6,7 @@ import { useTranslation } from 'react-i18next';
 
 
 import { apiPost } from '~/client/util/apiv1-client';
 import { apiPost } from '~/client/util/apiv1-client';
 import { apiv3Post } from '~/client/util/apiv3-client';
 import { apiv3Post } from '~/client/util/apiv3-client';
-import { usePageDeleteModal, usePageDeleteModalOpened } from '~/stores/ui';
+import { usePageDeleteModal } from '~/stores/modal';
 
 
 import { IDeleteSinglePageApiv1Result, IDeleteManyPageApiv3Result } from '~/interfaces/page';
 import { IDeleteSinglePageApiv1Result, IDeleteManyPageApiv3Result } from '~/interfaces/page';
 
 
@@ -38,10 +38,9 @@ const PageDeleteModal: FC<Props> = (props: Props) => {
     isDeleteCompletelyModal, isAbleToDeleteCompletely,
     isDeleteCompletelyModal, isAbleToDeleteCompletely,
   } = props;
   } = props;
 
 
-  const { data: deleteModalStatus, close: closeDeleteModal } = usePageDeleteModal();
-  const { data: pageDeleteModalOpened } = usePageDeleteModalOpened();
+  const { data: deleteModalData, close: closeDeleteModal } = usePageDeleteModal();
 
 
-  const isOpened = pageDeleteModalOpened?.isOpend != null ? pageDeleteModalOpened.isOpend : false;
+  const isOpened = deleteModalData?.isOpened ?? false;
 
 
   const [isDeleteRecursively, setIsDeleteRecursively] = useState(true);
   const [isDeleteRecursively, setIsDeleteRecursively] = useState(true);
   const [isDeleteCompletely, setIsDeleteCompletely] = useState(isDeleteCompletelyModal && isAbleToDeleteCompletely);
   const [isDeleteCompletely, setIsDeleteCompletely] = useState(isDeleteCompletelyModal && isAbleToDeleteCompletely);
@@ -62,20 +61,20 @@ const PageDeleteModal: FC<Props> = (props: Props) => {
   }
   }
 
 
   async function deletePage() {
   async function deletePage() {
-    if (deleteModalStatus == null || deleteModalStatus.pages == null) {
+    if (deleteModalData == null || deleteModalData.pages == null) {
       return;
       return;
     }
     }
 
 
     /*
     /*
      * When multiple pages
      * When multiple pages
      */
      */
-    if (deleteModalStatus.pages.length > 1) {
+    if (deleteModalData.pages.length > 1) {
       try {
       try {
         const isRecursively = isDeleteRecursively === true ? true : undefined;
         const isRecursively = isDeleteRecursively === true ? true : undefined;
         const isCompletely = isDeleteCompletely === true ? true : undefined;
         const isCompletely = isDeleteCompletely === true ? true : undefined;
 
 
         const pageIdToRevisionIdMap = {};
         const pageIdToRevisionIdMap = {};
-        deleteModalStatus.pages.forEach((p) => { pageIdToRevisionIdMap[p.pageId] = p.revisionId });
+        deleteModalData.pages.forEach((p) => { pageIdToRevisionIdMap[p.pageId] = p.revisionId });
 
 
         const { data } = await apiv3Post<IDeleteManyPageApiv3Result>('/pages/delete', {
         const { data } = await apiv3Post<IDeleteManyPageApiv3Result>('/pages/delete', {
           pageIdToRevisionIdMap,
           pageIdToRevisionIdMap,
@@ -83,8 +82,8 @@ const PageDeleteModal: FC<Props> = (props: Props) => {
           isCompletely,
           isCompletely,
         });
         });
 
 
-        if (pageDeleteModalOpened != null && pageDeleteModalOpened.onDeleted != null) {
-          pageDeleteModalOpened.onDeleted(data.paths, data.isRecursively, data.isCompletely);
+        if (deleteModalData.onDeleted != null) {
+          deleteModalData.onDeleted(data.paths, data.isRecursively, data.isCompletely);
         }
         }
       }
       }
       catch (err) {
       catch (err) {
@@ -99,7 +98,7 @@ const PageDeleteModal: FC<Props> = (props: Props) => {
         const recursively = isDeleteRecursively === true ? true : undefined;
         const recursively = isDeleteRecursively === true ? true : undefined;
         const completely = isDeleteCompletely === true ? true : undefined;
         const completely = isDeleteCompletely === true ? true : undefined;
 
 
-        const page = deleteModalStatus.pages[0];
+        const page = deleteModalData.pages[0];
 
 
         const { path, isRecursively, isCompletely } = await apiPost('/pages.remove', {
         const { path, isRecursively, isCompletely } = await apiPost('/pages.remove', {
           page_id: page.pageId,
           page_id: page.pageId,
@@ -108,8 +107,8 @@ const PageDeleteModal: FC<Props> = (props: Props) => {
           completely,
           completely,
         }) as IDeleteSinglePageApiv1Result;
         }) as IDeleteSinglePageApiv1Result;
 
 
-        if (pageDeleteModalOpened != null && pageDeleteModalOpened.onDeleted != null) {
-          pageDeleteModalOpened.onDeleted(path, isRecursively, isCompletely);
+        if (deleteModalData.onDeleted != null) {
+          deleteModalData.onDeleted(path, isRecursively, isCompletely);
         }
         }
       }
       }
       catch (err) {
       catch (err) {
@@ -177,8 +176,8 @@ const PageDeleteModal: FC<Props> = (props: Props) => {
   }
   }
 
 
   const renderPagePathsToDelete = () => {
   const renderPagePathsToDelete = () => {
-    if (deleteModalStatus != null && deleteModalStatus.pages != null) {
-      return deleteModalStatus.pages.map(page => <div key={page.pageId}><code>{ page.path }</code></div>);
+    if (deleteModalData != null && deleteModalData.pages != null) {
+      return deleteModalData.pages.map(page => <div key={page.pageId}><code>{ page.path }</code></div>);
     }
     }
     return <></>;
     return <></>;
   };
   };

+ 3 - 4
packages/app/src/components/PageDuplicateModal.jsx

@@ -9,7 +9,7 @@ import { withTranslation } from 'react-i18next';
 import { debounce } from 'throttle-debounce';
 import { debounce } from 'throttle-debounce';
 import { withUnstatedContainers } from './UnstatedUtils';
 import { withUnstatedContainers } from './UnstatedUtils';
 import { toastError } from '~/client/util/apiNotification';
 import { toastError } from '~/client/util/apiNotification';
-import { usePageDuplicateModalStatus, usePageDuplicateModalOpened } from '~/stores/ui';
+import { usePageDuplicateModal } from '~/stores/modal';
 
 
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
 import PagePathAutoComplete from './PagePathAutoComplete';
 import PagePathAutoComplete from './PagePathAutoComplete';
@@ -27,10 +27,9 @@ const PageDuplicateModal = (props) => {
   const config = appContainer.getConfig();
   const config = appContainer.getConfig();
   const isReachable = config.isSearchServiceReachable;
   const isReachable = config.isSearchServiceReachable;
   const { crowi } = appContainer.config;
   const { crowi } = appContainer.config;
-  const { data: pagesDataToDuplicate, close: closeDuplicateModal } = usePageDuplicateModalStatus();
-  const { data: isOpened } = usePageDuplicateModalOpened();
+  const { data: pagesDataToDuplicate, close: closeDuplicateModal } = usePageDuplicateModal();
 
 
-  const { path, pageId } = pagesDataToDuplicate;
+  const { isOpened, path, pageId } = pagesDataToDuplicate;
 
 
   const [pageNameInput, setPageNameInput] = useState(path);
   const [pageNameInput, setPageNameInput] = useState(path);
 
 

+ 8 - 0
packages/app/src/components/PageEditor/AbstractEditor.tsx

@@ -12,6 +12,10 @@ export interface AbstractEditorProps extends ICodeMirror {
   onCtrlEnter?: (event: Event) => void;
   onCtrlEnter?: (event: Event) => void;
 }
 }
 
 
+interface defaultProps {
+  isGfmMode: true,
+}
+
 export default class AbstractEditor<T extends AbstractEditorProps> extends React.Component<T, Record<string, unknown>> {
 export default class AbstractEditor<T extends AbstractEditorProps> extends React.Component<T, Record<string, unknown>> {
 
 
   constructor(props: Readonly<T>) {
   constructor(props: Readonly<T>) {
@@ -29,6 +33,10 @@ export default class AbstractEditor<T extends AbstractEditorProps> extends React
     this.dispatchSave = this.dispatchSave.bind(this);
     this.dispatchSave = this.dispatchSave.bind(this);
   }
   }
 
 
+  public static defaultProps: defaultProps = {
+    isGfmMode: true,
+  };
+
   forceToFocus(): void {}
   forceToFocus(): void {}
 
 
   /**
   /**

+ 2 - 10
packages/app/src/components/PageEditor/CodeMirrorEditor.jsx

@@ -3,8 +3,6 @@ import PropTypes from 'prop-types';
 
 
 import urljoin from 'url-join';
 import urljoin from 'url-join';
 import * as codemirror from 'codemirror';
 import * as codemirror from 'codemirror';
-import { UnControlled as UncontrolledCodeMirror } from 'react-codemirror2';
-
 import { Button } from 'reactstrap';
 import { Button } from 'reactstrap';
 
 
 import { JSHINT } from 'jshint';
 import { JSHINT } from 'jshint';
@@ -13,6 +11,7 @@ import * as loadScript from 'simple-load-script';
 import * as loadCssSync from 'load-css-file';
 import * as loadCssSync from 'load-css-file';
 
 
 import { createValidator } from '@growi/codemirror-textlint';
 import { createValidator } from '@growi/codemirror-textlint';
+import { UncontrolledCodeMirror } from '../UncontrolledCodeMirror';
 import InterceptorManager from '~/services/interceptor-manager';
 import InterceptorManager from '~/services/interceptor-manager';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
@@ -32,7 +31,6 @@ import LinkEditModal from './LinkEditModal';
 import HandsontableModal from './HandsontableModal';
 import HandsontableModal from './HandsontableModal';
 import EditorIcon from './EditorIcon';
 import EditorIcon from './EditorIcon';
 import DrawioModal from './DrawioModal';
 import DrawioModal from './DrawioModal';
-// import { UncontrolledCodeMirror } from '../UncontrolledCodeMirror';
 
 
 // Textlint
 // Textlint
 window.JSHINT = JSHINT;
 window.JSHINT = JSHINT;
@@ -110,7 +108,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
 
 
     this.state = {
     this.state = {
       value: this.props.value,
       value: this.props.value,
-      isGfmMode: this.props.isGfmMode ?? true,
+      isGfmMode: this.props.isGfmMode,
       isEnabledEmojiAutoComplete: false,
       isEnabledEmojiAutoComplete: false,
       isLoadingKeymap: false,
       isLoadingKeymap: false,
       isSimpleCheatsheetShown: this.props.isGfmMode && this.props.value.length === 0,
       isSimpleCheatsheetShown: this.props.isGfmMode && this.props.value.length === 0,
@@ -909,7 +907,6 @@ export default class CodeMirrorEditor extends AbstractEditor {
   }
   }
 
 
   render() {
   render() {
-    const mode = this.state.isGfmMode ? 'gfm-growi' : undefined;
     const lint = this.props.isTextlintEnabled ? this.codemirrorLintConfig : false;
     const lint = this.props.isTextlintEnabled ? this.codemirrorLintConfig : false;
     const additionalClasses = Array.from(this.state.additionalClassSet).join(' ');
     const additionalClasses = Array.from(this.state.additionalClassSet).join(' ');
     const placeholder = this.state.isGfmMode ? 'Input with Markdown..' : 'Input with Plain Text..';
     const placeholder = this.state.isGfmMode ? 'Input with Markdown..' : 'Input with Plain Text..';
@@ -936,11 +933,6 @@ export default class CodeMirrorEditor extends AbstractEditor {
           }}
           }}
           value={this.state.value}
           value={this.state.value}
           options={{
           options={{
-            mode,
-            theme: this.props.editorOptions.theme,
-            styleActiveLine: this.props.editorOptions.styleActiveLine,
-            lineNumbers: this.props.lineNumbers,
-            tabSize: 4,
             indentUnit: this.props.indentSize,
             indentUnit: this.props.indentSize,
             lineWrapping: true,
             lineWrapping: true,
             scrollPastEnd: true,
             scrollPastEnd: true,

+ 20 - 1
packages/app/src/components/PageList/PageListItemL.tsx

@@ -7,10 +7,12 @@ import { CustomInput } from 'reactstrap';
 
 
 import Clamp from 'react-multiline-clamp';
 import Clamp from 'react-multiline-clamp';
 import { format } from 'date-fns';
 import { format } from 'date-fns';
+import urljoin from 'url-join';
 
 
 import { UserPicture, PageListMeta } from '@growi/ui';
 import { UserPicture, PageListMeta } from '@growi/ui';
 import { DevidedPagePath } from '@growi/core';
 import { DevidedPagePath } from '@growi/core';
 import { useIsDeviceSmallerThanLg } from '~/stores/ui';
 import { useIsDeviceSmallerThanLg } from '~/stores/ui';
+import { usePageRenameModal, usePageDuplicateModal } from '~/stores/modal';
 import {
 import {
   IPageInfoAll, IPageWithMeta, isIPageInfoForEntity, isIPageInfoForListing,
   IPageInfoAll, IPageWithMeta, isIPageInfoForEntity, isIPageInfoForListing,
 } from '~/interfaces/page';
 } from '~/interfaces/page';
@@ -58,6 +60,8 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
   }));
   }));
 
 
   const { data: isDeviceSmallerThanLg } = useIsDeviceSmallerThanLg();
   const { data: isDeviceSmallerThanLg } = useIsDeviceSmallerThanLg();
+  const { open: openDuplicateModal } = usePageDuplicateModal();
+  const { open: openRenameModal } = usePageRenameModal();
 
 
   const elasticSearchResult = isIPageSearchMeta(pageMeta) ? pageMeta.elasticSearchResult : null;
   const elasticSearchResult = isIPageSearchMeta(pageMeta) ? pageMeta.elasticSearchResult : null;
   const revisionShortBody = isIPageInfoForListing(pageMeta) ? pageMeta.revisionShortBody : null;
   const revisionShortBody = isIPageInfoForListing(pageMeta) ? pageMeta.revisionShortBody : null;
@@ -80,6 +84,16 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
     }
     }
   }, [isDeviceSmallerThanLg, onClickItem, pageData._id]);
   }, [isDeviceSmallerThanLg, onClickItem, pageData._id]);
 
 
+  const duplicateMenuItemClickHandler = useCallback(() => {
+    const { _id: pageId, path } = pageData;
+    openDuplicateModal(pageId, path);
+  }, [openDuplicateModal, pageData]);
+
+  const renameMenuItemClickHandler = useCallback(() => {
+    const { _id: pageId, revision: revisionId, path } = pageData;
+    openRenameModal(pageId, revisionId as string, path);
+  }, [openRenameModal, pageData]);
+
   const styleListGroupItem = (!isDeviceSmallerThanLg && onClickItem != null) ? 'list-group-item-action' : '';
   const styleListGroupItem = (!isDeviceSmallerThanLg && onClickItem != null) ? 'list-group-item-action' : '';
   // background color of list item changes when class "active" exists under 'list-group-item'
   // background color of list item changes when class "active" exists under 'list-group-item'
   const styleActive = !isDeviceSmallerThanLg && isSelected ? 'active' : '';
   const styleActive = !isDeviceSmallerThanLg && isSelected ? 'active' : '';
@@ -123,7 +137,10 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
               {/* page title */}
               {/* page title */}
               <Clamp lines={1}>
               <Clamp lines={1}>
                 <span className="h5 mb-0">
                 <span className="h5 mb-0">
-                  <PagePathHierarchicalLink linkedPagePath={linkedPagePathLatter} basePath={dPagePath.former} />
+                  {/* Use permanent links to care for pages with the same name (Cannot use page path url) */}
+                  <span className="grw-page-path-hierarchical-link text-break">
+                    <a className="page-segment" href={encodeURI(urljoin('/', pageData._id))}>{linkedPagePathLatter.pathName}</a>
+                  </span>
                 </span>
                 </span>
               </Clamp>
               </Clamp>
 
 
@@ -140,7 +157,9 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
                   pageId={pageData._id}
                   pageId={pageData._id}
                   pageInfo={pageMeta}
                   pageInfo={pageMeta}
                   onClickDeleteMenuItem={props.onClickDeleteButton}
                   onClickDeleteMenuItem={props.onClickDeleteButton}
+                  onClickRenameMenuItem={renameMenuItemClickHandler}
                   isEnableActions={isEnableActions}
                   isEnableActions={isEnableActions}
+                  onClickDuplicateMenuItem={duplicateMenuItemClickHandler}
                 />
                 />
               </div>
               </div>
             </div>
             </div>

+ 6 - 16
packages/app/src/components/PagePresentationModal.jsx

@@ -1,31 +1,21 @@
 import React from 'react';
 import React from 'react';
-import PropTypes from 'prop-types';
 import {
 import {
   Modal, ModalBody,
   Modal, ModalBody,
 } from 'reactstrap';
 } from 'reactstrap';
 
 
-const PagePresentationModal = (props) => {
+import { usePagePresentationModal } from '~/stores/modal';
 
 
-  function closeModalHandler() {
-    if (props.onClose === null) {
-      return;
-    }
-    props.onClose();
-  }
+const PagePresentationModal = () => {
+
+  const { data: presentationData, close: closePresentationModal } = usePagePresentationModal();
 
 
   return (
   return (
-    <Modal isOpen={props.isOpen} toggle={closeModalHandler} className="grw-presentation-modal" unmountOnClose={false}>
+    <Modal isOpen={presentationData.isOpened} toggle={closePresentationModal} className="grw-presentation-modal" unmountOnClose={false}>
       <ModalBody className="modal-body">
       <ModalBody className="modal-body">
-        <iframe src={props.href} />
+        <iframe src={presentationData.href} />
       </ModalBody>
       </ModalBody>
     </Modal>
     </Modal>
   );
   );
 };
 };
-PagePresentationModal.propTypes = {
-  isOpen: PropTypes.bool.isRequired,
-  onClose: PropTypes.func,
-  href: PropTypes.string.isRequired,
-};
-
 
 
 export default PagePresentationModal;
 export default PagePresentationModal;

+ 5 - 4
packages/app/src/components/PageRenameModal.jsx

@@ -10,7 +10,7 @@ import {
 import { withTranslation } from 'react-i18next';
 import { withTranslation } from 'react-i18next';
 
 
 import { debounce } from 'throttle-debounce';
 import { debounce } from 'throttle-debounce';
-import { usePageRenameModalStatus, usePageRenameModalOpened } from '~/stores/ui';
+import { usePageRenameModal } from '~/stores/modal';
 import { withUnstatedContainers } from './UnstatedUtils';
 import { withUnstatedContainers } from './UnstatedUtils';
 import { toastError } from '~/client/util/apiNotification';
 import { toastError } from '~/client/util/apiNotification';
 
 
@@ -29,10 +29,11 @@ const PageRenameModal = (props) => {
   } = props;
   } = props;
 
 
   const { crowi } = appContainer.config;
   const { crowi } = appContainer.config;
-  const { data: isOpened } = usePageRenameModalOpened();
-  const { data: pagesDataToRename, close: closeRenameModal } = usePageRenameModalStatus();
+  const { data: pagesDataToRename, close: closeRenameModal } = usePageRenameModal();
 
 
-  const { path, revisionId, pageId } = pagesDataToRename;
+  const {
+    isOpened, path, revisionId, pageId,
+  } = pagesDataToRename;
 
 
   const [pageNameInput, setPageNameInput] = useState('');
   const [pageNameInput, setPageNameInput] = useState('');
 
 

+ 54 - 5
packages/app/src/components/SearchPage/SearchResultContent.tsx

@@ -1,4 +1,6 @@
-import React, { FC, useCallback } from 'react';
+import React, {
+  FC, useCallback, useEffect, useRef,
+} from 'react';
 
 
 import { DropdownItem } from 'reactstrap';
 import { DropdownItem } from 'reactstrap';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
@@ -10,11 +12,12 @@ import { exportAsMarkdown } from '~/client/services/page-operation';
 
 
 import RevisionLoader from '../Page/RevisionLoader';
 import RevisionLoader from '../Page/RevisionLoader';
 import AppContainer from '../../client/services/AppContainer';
 import AppContainer from '../../client/services/AppContainer';
+import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
 import { GrowiSubNavigation } from '../Navbar/GrowiSubNavigation';
 import { GrowiSubNavigation } from '../Navbar/GrowiSubNavigation';
 import { SubNavButtons } from '../Navbar/SubNavButtons';
 import { SubNavButtons } from '../Navbar/SubNavButtons';
 import { AdditionalMenuItemsRendererProps } from '../Common/Dropdown/PageItemControl';
 import { AdditionalMenuItemsRendererProps } from '../Common/Dropdown/PageItemControl';
 
 
-import { usePageRenameModalStatus, usePageDeleteModal } from '~/stores/ui';
+import { usePageDuplicateModal, usePageRenameModal, usePageDeleteModal } from '~/stores/modal';
 
 
 
 
 type AdditionalMenuItemsProps = AdditionalMenuItemsRendererProps & {
 type AdditionalMenuItemsProps = AdditionalMenuItemsRendererProps & {
@@ -40,6 +43,8 @@ const AdditionalMenuItems = (props: AdditionalMenuItemsProps): JSX.Element => {
   );
   );
 };
 };
 
 
+const SCROLL_OFFSET_TOP = 175; // approximate height of (navigation + subnavigation)
+const MUTATION_OBSERVER_CONFIG = { childList: true, subtree: true };
 
 
 type Props ={
 type Props ={
   appContainer: AppContainer,
   appContainer: AppContainer,
@@ -48,7 +53,45 @@ type Props ={
   showPageControlDropdown?: boolean,
   showPageControlDropdown?: boolean,
 }
 }
 
 
+const scrollTo = (scrollElement:HTMLElement) => {
+  // use querySelector to intentionally get the first element found
+  const highlightedKeyword = scrollElement.querySelector('.highlighted-keyword') as HTMLElement | null;
+  if (highlightedKeyword != null) {
+    smoothScrollIntoView(highlightedKeyword, SCROLL_OFFSET_TOP, scrollElement);
+  }
+};
+
+const generateObserverCallback = (doScroll: ()=>void) => {
+  return (mutationRecords:MutationRecord[]) => {
+    mutationRecords.forEach((record:MutationRecord) => {
+      const target = record.target as HTMLElement;
+      const targetId = target.id as string;
+      if (targetId !== 'wiki') return;
+      doScroll();
+    });
+  };
+};
+
 export const SearchResultContent: FC<Props> = (props: Props) => {
 export const SearchResultContent: FC<Props> = (props: Props) => {
+  const scrollElementRef = useRef(null);
+
+  // ***************************  Auto Scroll  ***************************
+  useEffect(() => {
+    const scrollElement = scrollElementRef.current as HTMLElement | null;
+    if (scrollElement == null) return;
+
+    const observerCallback = generateObserverCallback(() => {
+      scrollTo(scrollElement);
+    });
+
+    const observer = new MutationObserver(observerCallback);
+    observer.observe(scrollElement, MUTATION_OBSERVER_CONFIG);
+    return () => {
+      observer.disconnect();
+    };
+  });
+  // *******************************  end  *******************************
+
   const {
   const {
     appContainer,
     appContainer,
     pageWithMeta,
     pageWithMeta,
@@ -56,7 +99,8 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
     showPageControlDropdown,
     showPageControlDropdown,
   } = props;
   } = props;
 
 
-  const { open: openRenameModal } = usePageRenameModalStatus();
+  const { open: openDuplicateModal } = usePageDuplicateModal();
+  const { open: openRenameModal } = usePageRenameModal();
   const { open: openDeleteModal } = usePageDeleteModal();
   const { open: openDeleteModal } = usePageDeleteModal();
 
 
   const page = pageWithMeta?.pageData;
   const page = pageWithMeta?.pageData;
@@ -64,6 +108,10 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
   const growiRenderer = appContainer.getRenderer('searchresult');
   const growiRenderer = appContainer.getRenderer('searchresult');
 
 
 
 
+  const duplicateItemClickedHandler = useCallback(async(pageId, path) => {
+    openDuplicateModal(pageId, path);
+  }, [openDuplicateModal]);
+
   const renameItemClickedHandler = useCallback(async(pageId, revisionId, path) => {
   const renameItemClickedHandler = useCallback(async(pageId, revisionId, path) => {
     openRenameModal(pageId, revisionId, path);
     openRenameModal(pageId, revisionId, path);
   }, [openRenameModal]);
   }, [openRenameModal]);
@@ -90,6 +138,7 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
             path={page.path}
             path={page.path}
             showPageControlDropdown={showPageControlDropdown}
             showPageControlDropdown={showPageControlDropdown}
             additionalMenuItemRenderer={props => <AdditionalMenuItems {...props} pageId={page._id} revisionId={revisionId} />}
             additionalMenuItemRenderer={props => <AdditionalMenuItems {...props} pageId={page._id} revisionId={revisionId} />}
+            onClickDuplicateMenuItem={duplicateItemClickedHandler}
             onClickRenameMenuItem={renameItemClickedHandler}
             onClickRenameMenuItem={renameItemClickedHandler}
             onClickDeleteMenuItem={deleteItemClickedHandler}
             onClickDeleteMenuItem={deleteItemClickedHandler}
           />
           />
@@ -98,7 +147,7 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
         </div>
         </div>
       </>
       </>
     );
     );
-  }, [page, showPageControlDropdown, renameItemClickedHandler, deleteItemClickedHandler]);
+  }, [page, showPageControlDropdown, duplicateItemClickedHandler, renameItemClickedHandler, deleteItemClickedHandler]);
 
 
   // return if page is null
   // return if page is null
   if (page == null) return <></>;
   if (page == null) return <></>;
@@ -109,7 +158,7 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
         page={page}
         page={page}
         controls={ControlComponents}
         controls={ControlComponents}
       />
       />
-      <div className="search-result-page-content">
+      <div className="search-result-page-content" ref={scrollElementRef}>
         <RevisionLoader
         <RevisionLoader
           growiRenderer={growiRenderer}
           growiRenderer={growiRenderer}
           pageId={page._id}
           pageId={page._id}

+ 99 - 19
packages/app/src/components/Sidebar/PageTree/Item.tsx

@@ -8,18 +8,18 @@ import { useDrag, useDrop } from 'react-dnd';
 
 
 import nodePath from 'path';
 import nodePath from 'path';
 
 
-import { pathUtils } from '@growi/core';
+import { pathUtils, pagePathUtils } from '@growi/core';
 
 
-import { toastWarning, toastError } from '~/client/util/apiNotification';
+import { toastWarning, toastError, toastSuccess } from '~/client/util/apiNotification';
 
 
 import { useSWRxPageChildren } from '~/stores/page-listing';
 import { useSWRxPageChildren } from '~/stores/page-listing';
-import { IPageForPageDeleteModal } from '~/stores/ui';
-import { apiv3Put } from '~/client/util/apiv3-client';
+import { apiv3Put, apiv3Post } from '~/client/util/apiv3-client';
+import { IPageForPageDeleteModal } from '~/stores/modal';
 
 
 import TriangleIcon from '~/components/Icons/TriangleIcon';
 import TriangleIcon from '~/components/Icons/TriangleIcon';
 import { bookmark, unbookmark } from '~/client/services/page-operation';
 import { bookmark, unbookmark } from '~/client/services/page-operation';
 import ClosableTextInput, { AlertInfo, AlertType } from '../../Common/ClosableTextInput';
 import ClosableTextInput, { AlertInfo, AlertType } from '../../Common/ClosableTextInput';
-import { AsyncPageItemControl } from '../../Common/Dropdown/PageItemControl';
+import { PageItemControl } from '../../Common/Dropdown/PageItemControl';
 import { ItemNode } from './ItemNode';
 import { ItemNode } from './ItemNode';
 
 
 interface ItemProps {
 interface ItemProps {
@@ -79,24 +79,78 @@ 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 [shouldHide, setShouldHide] = useState(false);
   // const [isRenameInputShown, setRenameInputShown] = useState(false);
   // const [isRenameInputShown, setRenameInputShown] = useState(false);
 
 
-  const { data, error } = useSWRxPageChildren(isOpen ? page._id : null);
+  const { data, mutate: mutateChildren } = useSWRxPageChildren(isOpen ? page._id : null);
 
 
-  const hasDescendants = (page.descendantCount != null && page?.descendantCount > 0);
+  // hasDescendants flag
+  const isChildrenLoaded = currentChildren?.length > 0;
+  const hasDescendants = (page.descendantCount != null && page?.descendantCount > 0) || isChildrenLoaded;
+
+  // to re-show hidden item when useDrag end() callback
+  const displayDroppedItemByPageId = useCallback((pageId) => {
+    const target = document.getElementById(`pagetree-item-${pageId}`);
+    if (target == null) {
+      return;
+    }
+
+    // wait 500ms to avoid removing before d-none is set by useDrag end() callback
+    setTimeout(() => {
+      target.classList.remove('d-none');
+    }, 500);
+  }, []);
 
 
   const [{ isDragging }, drag] = useDrag(() => ({
   const [{ isDragging }, drag] = useDrag(() => ({
     type: 'PAGE_TREE',
     type: 'PAGE_TREE',
     item: { page },
     item: { page },
+    end: () => {
+      // in order to set d-none to dropped Item
+      setShouldHide(true);
+    },
     collect: monitor => ({
     collect: monitor => ({
       isDragging: monitor.isDragging(),
       isDragging: monitor.isDragging(),
     }),
     }),
   }));
   }));
 
 
-  const pageItemDropHandler = () => {
-    // TODO: hit an api to rename the page by 85175
-    // eslint-disable-next-line no-console
-    console.log('pageItem was droped!!');
+  const pageItemDropHandler = async(item, monitor) => {
+    if (page == null || page.path == null) {
+      return;
+    }
+
+    const { page: droppedPage } = item;
+
+    const pageTitle = nodePath.basename(droppedPage.path);
+    const newParentPath = page.path;
+    const newPagePath = nodePath.join(newParentPath, pageTitle);
+
+    try {
+      await apiv3Put('/pages/rename', {
+        pageId: droppedPage._id,
+        revisionId: droppedPage.revision,
+        newPagePath,
+        isRenameRedirect: false,
+        isRemainMetadata: false,
+      });
+
+      await mutateChildren();
+
+      // force open
+      setIsOpen(true);
+
+      toastSuccess('TODO: i18n Successfully moved pages.');
+    }
+    catch (err) {
+      // display the dropped item
+      displayDroppedItemByPageId(droppedPage._id);
+
+      if (err.code === 'operation__blocked') {
+        toastWarning('TODO: i18n You cannot move this page now.');
+      }
+      else {
+        toastError('TODO: i18n Something went wrong with moving page.');
+      }
+    }
   };
   };
 
 
   const [{ isOver }, drop] = useDrop(() => ({
   const [{ isOver }, drop] = useDrop(() => ({
@@ -203,12 +257,38 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
     onClickDeleteMenuItem(pageToDelete);
     onClickDeleteMenuItem(pageToDelete);
   }, [page, onClickDeleteMenuItem]);
   }, [page, onClickDeleteMenuItem]);
 
 
-  const onPressEnterForCreateHandler = (inputText: string) => {
+  const onPressEnterForCreateHandler = async(inputText: string) => {
     setNewPageInputShown(false);
     setNewPageInputShown(false);
     const parentPath = pathUtils.addTrailingSlash(page.path as string);
     const parentPath = pathUtils.addTrailingSlash(page.path as string);
     const newPagePath = `${parentPath}${inputText}`;
     const newPagePath = `${parentPath}${inputText}`;
-    console.log(newPagePath);
-    // TODO: https://redmine.weseek.co.jp/issues/87943
+    const isCreatable = pagePathUtils.isCreatablePage(newPagePath);
+
+    if (!isCreatable) {
+      toastWarning(t('you_can_not_create_page_with_this_name'));
+      return;
+    }
+
+    // TODO 88261: Get the isEnabledAttachTitleHeader by SWR
+    // const initBody = '';
+    // const { isEnabledAttachTitleHeader } = props.appContainer.getConfig();
+    // if (isEnabledAttachTitleHeader) {
+    //   initBody = pathUtils.attachTitleHeader(newPagePath);
+    // }
+
+    try {
+      await apiv3Post('/pages/', {
+        path: newPagePath,
+        body: '',
+        grant: page.grant,
+        grantUserGroupId: page.grantedGroup,
+        createFromPageTree: true,
+      });
+      mutateChildren();
+      toastSuccess(t('successfully_saved_the_page'));
+    }
+    catch (err) {
+      toastError(err);
+    }
   };
   };
 
 
   const inputValidator = (title: string | null): AlertInfo | null => {
   const inputValidator = (title: string | null): AlertInfo | null => {
@@ -248,15 +328,15 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
    * When swr fetch succeeded
    * When swr fetch succeeded
    */
    */
   useEffect(() => {
   useEffect(() => {
-    if (isOpen && error == null && data != null) {
+    if (isOpen && data != null) {
       const newChildren = ItemNode.generateNodesFromPages(data.children);
       const newChildren = ItemNode.generateNodesFromPages(data.children);
       markTarget(newChildren, targetPathOrId);
       markTarget(newChildren, targetPathOrId);
       setCurrentChildren(newChildren);
       setCurrentChildren(newChildren);
     }
     }
-  }, [data, error, isOpen, targetPathOrId]);
+  }, [data, isOpen, targetPathOrId]);
 
 
   return (
   return (
-    <div className={`grw-pagetree-item-container ${isOver ? 'grw-pagetree-is-over' : ''}`}>
+    <div id={`pagetree-item-${page._id}`} className={`grw-pagetree-item-container ${isOver ? 'grw-pagetree-is-over' : ''} ${shouldHide ? 'd-none' : ''}`}>
       <li
       <li
         ref={(c) => { drag(c); drop(c) }}
         ref={(c) => { drag(c); drop(c) }}
         className={`list-group-item list-group-item-action border-0 py-1 d-flex align-items-center ${page.isTarget ? 'grw-pagetree-is-target' : ''}`}
         className={`list-group-item list-group-item-action border-0 py-1 d-flex align-items-center ${page.isTarget ? 'grw-pagetree-is-target' : ''}`}
@@ -296,7 +376,7 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
           </div>
           </div>
         )}
         )}
         <div className="grw-pagetree-control d-none">
         <div className="grw-pagetree-control d-none">
-          <AsyncPageItemControl
+          <PageItemControl
             pageId={page._id}
             pageId={page._id}
             isEnableActions={isEnableActions}
             isEnableActions={isEnableActions}
             showBookmarkMenuItem
             showBookmarkMenuItem
@@ -308,7 +388,7 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
             <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>
             </DropdownToggle>
             </DropdownToggle>
-          </AsyncPageItemControl>
+          </PageItemControl>
           <button
           <button
             type="button"
             type="button"
             className="border-0 rounded btn-page-item-control p-0"
             className="border-0 rounded btn-page-item-control p-0"

+ 8 - 6
packages/app/src/components/Sidebar/PageTree/ItemsTree.tsx

@@ -4,13 +4,12 @@ import { useTranslation } from 'react-i18next';
 import { IPageHasId } from '../../../interfaces/page';
 import { IPageHasId } from '../../../interfaces/page';
 import { ItemNode } from './ItemNode';
 import { ItemNode } from './ItemNode';
 import Item from './Item';
 import Item from './Item';
-import { useSWRxPageAncestorsChildren, useSWRxRootPage } from '../../../stores/page-listing';
+import { useSWRxPageAncestorsChildren, useSWRxPageChildren, useSWRxRootPage } from '../../../stores/page-listing';
 import { TargetAndAncestors } from '~/interfaces/page-listing-results';
 import { TargetAndAncestors } from '~/interfaces/page-listing-results';
 import { toastError, toastSuccess } from '~/client/util/apiNotification';
 import { toastError, toastSuccess } from '~/client/util/apiNotification';
 import {
 import {
-  IPageForPageDeleteModal, usePageDuplicateModalStatus, usePageRenameModalStatus, usePageDeleteModal,
-  OnDeletedFunction,
-} from '~/stores/ui';
+  OnDeletedFunction, IPageForPageDeleteModal, usePageDuplicateModal, usePageRenameModal, usePageDeleteModal,
+} from '~/stores/modal';
 import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
 import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
 
 
 /*
 /*
@@ -96,9 +95,10 @@ const ItemsTree: FC<ItemsTreeProps> = (props: ItemsTreeProps) => {
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
   const { data: ancestorsChildrenData, error: error1 } = useSWRxPageAncestorsChildren(targetPath);
   const { data: ancestorsChildrenData, error: error1 } = useSWRxPageAncestorsChildren(targetPath);
+  const { mutate: mutateChildren } = useSWRxPageChildren(targetPathOrId);
   const { data: rootPageData, error: error2 } = useSWRxRootPage();
   const { data: rootPageData, error: error2 } = useSWRxRootPage();
-  const { open: openDuplicateModal } = usePageDuplicateModalStatus();
-  const { open: openRenameModal } = usePageRenameModalStatus();
+  const { open: openDuplicateModal } = usePageDuplicateModal();
+  const { open: openRenameModal } = usePageRenameModal();
   const { open: openDeleteModal } = usePageDeleteModal();
   const { open: openDeleteModal } = usePageDeleteModal();
 
 
   useEffect(() => {
   useEffect(() => {
@@ -123,6 +123,8 @@ const ItemsTree: FC<ItemsTreeProps> = (props: ItemsTreeProps) => {
       return;
       return;
     }
     }
 
 
+    mutateChildren();
+
     const path = pathOrPathsToDelete;
     const path = pathOrPathsToDelete;
 
 
     if (isRecursively) {
     if (isRecursively) {

+ 1 - 3
packages/app/src/components/UncontrolledCodeMirror.tsx

@@ -12,7 +12,6 @@ export interface UncontrolledCodeMirrorProps extends AbstractEditorProps {
   value: string;
   value: string;
   options?: ICodeMirror['options'];
   options?: ICodeMirror['options'];
   isGfmMode?: boolean;
   isGfmMode?: boolean;
-  indentSize?: number;
   lineNumbers?: boolean;
   lineNumbers?: boolean;
 }
 }
 
 
@@ -26,7 +25,7 @@ class UncontrolledCodeMirrorCore extends AbstractEditor<UncontrolledCodeMirrorCo
   render(): ReactNode {
   render(): ReactNode {
 
 
     const {
     const {
-      value, isGfmMode, indentSize, lineNumbers, editorContainer, options, forwardedRef, ...rest
+      value, isGfmMode, lineNumbers, editorContainer, options, forwardedRef, ...rest
     } = this.props;
     } = this.props;
 
 
     const { editorOptions } = editorContainer.state;
     const { editorOptions } = editorContainer.state;
@@ -41,7 +40,6 @@ class UncontrolledCodeMirrorCore extends AbstractEditor<UncontrolledCodeMirrorCo
           theme: editorOptions.theme,
           theme: editorOptions.theme,
           styleActiveLine: editorOptions.styleActiveLine,
           styleActiveLine: editorOptions.styleActiveLine,
           tabSize: 4,
           tabSize: 4,
-          indentUnit: indentSize,
           ...options,
           ...options,
         }}
         }}
         {...rest}
         {...rest}

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

@@ -189,6 +189,39 @@ export class PageQueryBuilder {
     return this;
     return this;
   }
   }
 
 
+  async addConditionForParentNormalization(user) {
+    // determine UserGroup condition
+    let userGroups = null;
+    if (user != null) {
+      const UserGroupRelation = mongoose.model('UserGroupRelation');
+      userGroups = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
+    }
+
+    const grantConditions = [
+      { grant: null },
+      { grant: GRANT_PUBLIC },
+    ];
+
+    if (user != null) {
+      grantConditions.push(
+        { grant: GRANT_OWNER, grantedUsers: user._id },
+      );
+    }
+
+    if (userGroups != null && userGroups.length > 0) {
+      grantConditions.push(
+        { grant: GRANT_USER_GROUP, grantedGroup: { $in: userGroups } },
+      );
+    }
+
+    this.query = this.query
+      .and({
+        $or: grantConditions,
+      });
+
+    return this;
+  }
+
   addConditionToFilteringByViewer(user, userGroups, showAnyoneKnowsLink = false, showPagesRestrictedByOwner = false, showPagesRestrictedByGroup = false) {
   addConditionToFilteringByViewer(user, userGroups, showAnyoneKnowsLink = false, showPagesRestrictedByOwner = false, showPagesRestrictedByGroup = false) {
     const grantConditions = [
     const grantConditions = [
       { grant: null },
       { grant: null },

+ 17 - 2
packages/app/src/server/models/page.ts

@@ -167,7 +167,7 @@ schema.statics.createEmptyPage = async function(
  * @param exPage a page document to be replaced
  * @param exPage a page document to be replaced
  * @returns Promise<void>
  * @returns Promise<void>
  */
  */
-schema.statics.replaceTargetWithPage = async function(exPage, pageToReplaceWith?): Promise<void> {
+schema.statics.replaceTargetWithPage = async function(exPage, pageToReplaceWith?, deleteExPageIfEmpty = false): Promise<void> {
   // find parent
   // find parent
   const parent = await this.findOne({ _id: exPage.parent });
   const parent = await this.findOne({ _id: exPage.parent });
   if (parent == null) {
   if (parent == null) {
@@ -201,6 +201,12 @@ schema.statics.replaceTargetWithPage = async function(exPage, pageToReplaceWith?
   };
   };
 
 
   await this.bulkWrite([operationForNewTarget, operationsForChildren]);
   await this.bulkWrite([operationForNewTarget, operationsForChildren]);
+
+  const isExPageEmpty = exPage.isEmpty;
+  if (deleteExPageIfEmpty && isExPageEmpty) {
+    await this.deleteOne({ _id: exPage._id });
+    logger.warn('Deleted empty page since it was replaced with another page.');
+  }
 };
 };
 
 
 /**
 /**
@@ -219,7 +225,7 @@ schema.statics.getParentAndFillAncestors = async function(path: string): Promise
   /*
   /*
    * Fill parents if parent is null
    * Fill parents if parent is null
    */
    */
-  const ancestorPaths = collectAncestorPaths(path); // paths of parents need to be created
+  const ancestorPaths = collectAncestorPaths(path, [path]); // paths of parents need to be created
 
 
   // just create ancestors with empty pages
   // just create ancestors with empty pages
   await this.createEmptyPagesByPaths(ancestorPaths);
   await this.createEmptyPagesByPaths(ancestorPaths);
@@ -577,6 +583,15 @@ schema.statics.findByPageIdsToEdit = async function(ids, user, shouldIncludeEmpt
   return pages;
   return pages;
 };
 };
 
 
+schema.statics.normalizeDescendantCountById = async function(pageId) {
+  const children = await this.find({ parent: pageId });
+
+  const sumChildrenDescendantCount = children.map(d => d.descendantCount).reduce((c1, c2) => c1 + c2);
+  const sumChildPages = children.filter(p => !p.isEmpty).length;
+
+  return this.updateOne({ _id: pageId }, { $set: { descendantCount: sumChildrenDescendantCount + sumChildPages } }, { new: true });
+};
+
 export type PageCreateOptions = {
 export type PageCreateOptions = {
   format?: string
   format?: string
   grantUserGroupId?: ObjectIdLike
   grantUserGroupId?: ObjectIdLike

+ 3 - 0
packages/app/src/server/models/revision.js

@@ -10,6 +10,9 @@ module.exports = function(crowi) {
   const mongoose = require('mongoose');
   const mongoose = require('mongoose');
   const mongoosePaginate = require('mongoose-paginate-v2');
   const mongoosePaginate = require('mongoose-paginate-v2');
 
 
+  // allow empty strings
+  mongoose.Schema.Types.String.checkRequired(v => v != null);
+
   const ObjectId = mongoose.Schema.Types.ObjectId;
   const ObjectId = mongoose.Schema.Types.ObjectId;
   const revisionSchema = new mongoose.Schema({
   const revisionSchema = new mongoose.Schema({
     // OBSOLETE path: { type: String, required: true, index: true }
     // OBSOLETE path: { type: String, required: true, index: true }

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

@@ -159,8 +159,8 @@ module.exports = (crowi) => {
 
 
   const validator = {
   const validator = {
     createPage: [
     createPage: [
-      body('body').exists().not().isEmpty({ ignore_whitespace: true })
-        .withMessage('body is required'),
+      body('body').exists()
+        .withMessage('body is re quired but an empty string is allowed'),
       body('path').exists().not().isEmpty({ ignore_whitespace: true })
       body('path').exists().not().isEmpty({ ignore_whitespace: true })
         .withMessage('path is required'),
         .withMessage('path is required'),
       body('grant').if(value => value != null).isInt({ min: 0, max: 5 }).withMessage('grant must be integer from 1 to 5'),
       body('grant').if(value => value != null).isInt({ min: 0, max: 5 }).withMessage('grant must be integer from 1 to 5'),
@@ -168,6 +168,7 @@ module.exports = (crowi) => {
       body('isSlackEnabled').if(value => value != null).isBoolean().withMessage('isSlackEnabled must be boolean'),
       body('isSlackEnabled').if(value => value != null).isBoolean().withMessage('isSlackEnabled must be boolean'),
       body('slackChannels').if(value => value != null).isString().withMessage('slackChannels must be string'),
       body('slackChannels').if(value => value != null).isString().withMessage('slackChannels must be string'),
       body('pageTags').if(value => value != null).isArray().withMessage('pageTags must be array'),
       body('pageTags').if(value => value != null).isArray().withMessage('pageTags must be array'),
+      body('createFromPageTree').optional().isBoolean().withMessage('createFromPageTree must be boolean'),
     ],
     ],
     renamePage: [
     renamePage: [
       body('pageId').isMongoId().withMessage('pageId is required'),
       body('pageId').isMongoId().withMessage('pageId is required'),
@@ -244,6 +245,9 @@ module.exports = (crowi) => {
    *                    type: array
    *                    type: array
    *                    items:
    *                    items:
    *                      $ref: '#/components/schemas/Tag'
    *                      $ref: '#/components/schemas/Tag'
+   *                  createFromPageTree:
+   *                    type: boolean
+   *                    description: Whether the page was created from the page tree or not
    *                required:
    *                required:
    *                  - body
    *                  - body
    *                  - path
    *                  - path
@@ -641,7 +645,8 @@ module.exports = (crowi) => {
 
 
     const page = await Page.findByIdAndViewerToEdit(pageId, req.user, true);
     const page = await Page.findByIdAndViewerToEdit(pageId, req.user, true);
 
 
-    if (page == null) {
+    const isEmptyAndNotRecursively = page?.isEmpty && isRecursively;
+    if (page == null || isEmptyAndNotRecursively) {
       res.code = 'Page is not found';
       res.code = 'Page is not found';
       logger.error('Failed to find the pages');
       logger.error('Failed to find the pages');
       return res.apiv3Err(new ErrorV3(`Page '${pageId}' is not found or forbidden`, 'notfound_or_forbidden'), 401);
       return res.apiv3Err(new ErrorV3(`Page '${pageId}' is not found or forbidden`, 'notfound_or_forbidden'), 401);
@@ -796,7 +801,7 @@ module.exports = (crowi) => {
     }
     }
     else {
     else {
       try {
       try {
-        await crowi.pageService.normalizeParentByPageIds(pageIds);
+        await crowi.pageService.normalizeParentByPageIds(pageIds, req.user);
       }
       }
       catch (err) {
       catch (err) {
         return res.apiv3Err(new ErrorV3(`Failed to migrate pages: ${err.message}`), 500);
         return res.apiv3Err(new ErrorV3(`Failed to migrate pages: ${err.message}`), 500);
@@ -809,7 +814,7 @@ module.exports = (crowi) => {
   router.get('/v5-migration-status', accessTokenParser, loginRequired, async(req, res) => {
   router.get('/v5-migration-status', accessTokenParser, loginRequired, async(req, res) => {
     try {
     try {
       const isV5Compatible = crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
       const isV5Compatible = crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
-      const migratablePagesCount = req.user != null ? await crowi.pageService.v5MigratablePrivatePagesCount(req.user) : null;
+      const migratablePagesCount = req.user != null ? await crowi.pageService.countPagesCanNormalizeParentByUser(req.user) : null; // null check since not using loginRequiredStrictly
       return res.apiv3({ isV5Compatible, migratablePagesCount });
       return res.apiv3({ isV5Compatible, migratablePagesCount });
     }
     }
     catch (err) {
     catch (err) {

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

@@ -900,7 +900,7 @@ module.exports = function(crowi, app) {
    * - If revision_id is not specified => force update by the new contents.
    * - If revision_id is not specified => force update by the new contents.
    */
    */
   api.update = async function(req, res) {
   api.update = async function(req, res) {
-    const pageBody = req.body.body || null;
+    const pageBody = body ?? null;
     const pageId = req.body.page_id || null;
     const pageId = req.body.page_id || null;
     const revisionId = req.body.revision_id || null;
     const revisionId = req.body.revision_id || null;
     const grant = req.body.grant || null;
     const grant = req.body.grant || null;

+ 22 - 8
packages/app/src/server/service/page-grant.ts

@@ -10,6 +10,8 @@ import { isIncludesObjectId, excludeTestIdsFromTargetIds } from '~/server/util/c
 const { addTrailingSlash } = pathUtils;
 const { addTrailingSlash } = pathUtils;
 const { isTopPage } = pagePathUtils;
 const { isTopPage } = pagePathUtils;
 
 
+const LIMIT_FOR_MULTIPLE_PAGE_OP = 20;
+
 type ObjectIdLike = mongoose.Types.ObjectId | string;
 type ObjectIdLike = mongoose.Types.ObjectId | string;
 
 
 type ComparableTarget = {
 type ComparableTarget = {
@@ -231,7 +233,7 @@ class PageGrantService {
       .addConditionToSortPagesByDescPath()
       .addConditionToSortPagesByDescPath()
       .query
       .query
       .exec();
       .exec();
-    const testAncestor = ancestors[0];
+    const testAncestor = ancestors[0]; // TODO: consider when duplicate testAncestors exist
     if (testAncestor == null) {
     if (testAncestor == null) {
       throw Error('testAncestor must exist');
       throw Error('testAncestor must exist');
     }
     }
@@ -318,7 +320,9 @@ class PageGrantService {
 
 
   /**
   /**
    * About the rule of validation, see: https://dev.growi.org/61b2cdabaa330ce7d8152844
    * About the rule of validation, see: https://dev.growi.org/61b2cdabaa330ce7d8152844
-   * Only v5 schema pages will be used to compare.
+   * Only v5 schema pages will be used to compare by default (Set includeNotMigratedPages to true to include v4 schema pages as well).
+   * When comparing, it will use path regex to collect pages instead of using parent attribute of the Page model. This is reasonable since
+   * using the path attribute is safer than using the parent attribute in this case. 2022.02.13 -- Taichi Masuyama
    * @returns Promise<boolean>
    * @returns Promise<boolean>
    */
    */
   async isGrantNormalized(
   async isGrantNormalized(
@@ -342,14 +346,24 @@ class PageGrantService {
     return this.processValidation(comparableTarget, comparableAncestor, comparableDescendants);
     return this.processValidation(comparableTarget, comparableAncestor, comparableDescendants);
   }
   }
 
 
-  async separateNormalizedAndNonNormalizedPages(pageIds: ObjectIdLike[]): Promise<[(PageDocument & { _id: any })[], (PageDocument & { _id: any })[]]> {
+  /**
+   * Separate normalizable pages and NOT normalizable pages by PageService.prototype.isGrantNormalized method.
+   * normalizable pages = Pages which are able to run normalizeParentRecursively method (grant & userGroup rule is correct)
+   * @param pageIds pageIds to be tested
+   * @returns a tuple with the first element of normalizable pages and the second element of NOT normalizable pages
+   */
+  async separateNormalizableAndNotNormalizablePages(pageIds: ObjectIdLike[]): Promise<[(PageDocument & { _id: any })[], (PageDocument & { _id: any })[]]> {
+    if (pageIds.length > LIMIT_FOR_MULTIPLE_PAGE_OP) {
+      throw Error(`The maximum number of pageIds allowed is ${LIMIT_FOR_MULTIPLE_PAGE_OP}.`);
+    }
+
     const Page = mongoose.model('Page') as unknown as PageModel;
     const Page = mongoose.model('Page') as unknown as PageModel;
     const { PageQueryBuilder } = Page;
     const { PageQueryBuilder } = Page;
     const shouldCheckDescendants = true;
     const shouldCheckDescendants = true;
     const shouldIncludeNotMigratedPages = true;
     const shouldIncludeNotMigratedPages = true;
 
 
-    const normalizedPages: (PageDocument & { _id: any })[] = [];
-    const nonNormalizedPages: (PageDocument & { _id: any })[] = []; // can be used to tell user which page failed to migrate
+    const normalizable: (PageDocument & { _id: any })[] = [];
+    const nonNormalizable: (PageDocument & { _id: any })[] = []; // can be used to tell user which page failed to migrate
 
 
     const builder = new PageQueryBuilder(Page.find());
     const builder = new PageQueryBuilder(Page.find());
     builder.addConditionToListByPageIdsArray(pageIds);
     builder.addConditionToListByPageIdsArray(pageIds);
@@ -363,14 +377,14 @@ class PageGrantService {
 
 
       const isNormalized = await this.isGrantNormalized(path, grant, grantedUserIds, grantedGroupId, shouldCheckDescendants, shouldIncludeNotMigratedPages);
       const isNormalized = await this.isGrantNormalized(path, grant, grantedUserIds, grantedGroupId, shouldCheckDescendants, shouldIncludeNotMigratedPages);
       if (isNormalized) {
       if (isNormalized) {
-        normalizedPages.push(page);
+        normalizable.push(page);
       }
       }
       else {
       else {
-        nonNormalizedPages.push(page);
+        nonNormalizable.push(page);
       }
       }
     }
     }
 
 
-    return [normalizedPages, nonNormalizedPages];
+    return [normalizable, nonNormalizable];
   }
   }
 
 
 }
 }

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

@@ -9,7 +9,7 @@ import { serializePageSecurely } from '../models/serializers/page-serializer';
 import { createBatchStream } from '~/server/util/batch-stream';
 import { createBatchStream } from '~/server/util/batch-stream';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 import {
 import {
-  CreateMethod, generateGrantCondition, PageCreateOptions, PageModel,
+  CreateMethod, generateGrantCondition, PageCreateOptions, PageDocument, PageModel,
 } from '~/server/models/page';
 } from '~/server/models/page';
 import { stringifySnapshot } from '~/models/serializers/in-app-notification-snapshot/page';
 import { stringifySnapshot } from '~/models/serializers/in-app-notification-snapshot/page';
 import ActivityDefine from '../util/activityDefine';
 import ActivityDefine from '../util/activityDefine';
@@ -1065,7 +1065,7 @@ class PageService {
       // replace with an empty page
       // replace with an empty page
       const shouldReplace = await Page.exists({ parent: page._id });
       const shouldReplace = await Page.exists({ parent: page._id });
       if (shouldReplace) {
       if (shouldReplace) {
-        await Page.replaceTargetWithPage(page);
+        await Page.replaceTargetWithPage(page, null, true);
       }
       }
 
 
       // update descendantCount of ancestors'
       // update descendantCount of ancestors'
@@ -1077,10 +1077,7 @@ class PageService {
 
 
     let deletedPage;
     let deletedPage;
     // update Revisions
     // update Revisions
-    if (page.isEmpty) {
-      await Page.remove({ _id: page._id });
-    }
-    else {
+    if (!page.isEmpty) {
       await Revision.updateRevisionListByPageId(page._id, { pageId: page._id });
       await Revision.updateRevisionListByPageId(page._id, { pageId: page._id });
       deletedPage = await Page.findByIdAndUpdate(page._id, {
       deletedPage = await Page.findByIdAndUpdate(page._id, {
         $set: {
         $set: {
@@ -1808,10 +1805,18 @@ class PageService {
     await inAppNotificationService.emitSocketIo(targetUsers);
     await inAppNotificationService.emitSocketIo(targetUsers);
   }
   }
 
 
-  async normalizeParentByPageIds(pageIds: ObjectIdLike[]): Promise<void> {
+  async normalizeParentByPageIds(pageIds: ObjectIdLike[], user): Promise<void> {
     for await (const pageId of pageIds) {
     for await (const pageId of pageIds) {
       try {
       try {
-        await this.normalizeParentByPageId(pageId);
+        const normalizedPage = await this.normalizeParentByPageId(pageId, user);
+
+        if (normalizedPage == null) {
+          logger.error(`Failed to update descendantCount of page of id: "${pageId}"`);
+        }
+        else {
+          // update descendantCount of ancestors'
+          await this.updateDescendantCountOfAncestors(pageId, normalizedPage.descendantCount, false);
+        }
       }
       }
       catch (err) {
       catch (err) {
         // socket.emit('normalizeParentByPageIds', { error: err.message }); TODO: use socket to tell user
         // socket.emit('normalizeParentByPageIds', { error: err.message }); TODO: use socket to tell user
@@ -1819,17 +1824,23 @@ class PageService {
     }
     }
   }
   }
 
 
-  private async normalizeParentByPageId(pageId: ObjectIdLike) {
+  private async normalizeParentByPageId(pageId: ObjectIdLike, user) {
     const Page = mongoose.model('Page') as unknown as PageModel;
     const Page = mongoose.model('Page') as unknown as PageModel;
-    const target = await Page.findById(pageId);
+    const target = await Page.findByIdAndViewerToEdit(pageId, user);
     if (target == null) {
     if (target == null) {
-      throw Error('target does not exist');
+      throw Error('target does not exist or forbidden');
     }
     }
 
 
     const {
     const {
       path, grant, grantedUsers: grantedUserIds, grantedGroup: grantedGroupId,
       path, grant, grantedUsers: grantedUserIds, grantedGroup: grantedGroupId,
     } = target;
     } = target;
 
 
+    // check if any page exists at target path already
+    const existingPage = await Page.findOne({ path });
+    if (existingPage != null && !existingPage.isEmpty) {
+      throw Error('Page already exists. Please rename the page to continue.');
+    }
+
     /*
     /*
      * UserGroup & Owner validation
      * UserGroup & Owner validation
      */
      */
@@ -1852,55 +1863,73 @@ class PageService {
       throw Error('Restricted pages can not be migrated');
       throw Error('Restricted pages can not be migrated');
     }
     }
 
 
-    // getParentAndFillAncestors
-    const parent = await Page.getParentAndFillAncestors(target.path);
+    let updatedPage;
+
+    // replace if empty page exists
+    if (existingPage != null && existingPage.isEmpty) {
+      await Page.replaceTargetWithPage(existingPage, target, true);
+      updatedPage = await Page.findById(pageId);
+    }
+    else {
+      // getParentAndFillAncestors
+      const parent = await Page.getParentAndFillAncestors(target.path);
+      updatedPage = await Page.findOneAndUpdate({ _id: pageId }, { parent: parent._id }, { new: true });
+    }
 
 
-    return Page.updateOne({ _id: pageId }, { parent: parent._id });
+    return updatedPage;
   }
   }
 
 
+  // TODO: this should be resumable
   async normalizeParentRecursivelyByPageIds(pageIds, user) {
   async normalizeParentRecursivelyByPageIds(pageIds, user) {
+    const Page = mongoose.model('Page') as unknown as PageModel;
+
     if (pageIds == null || pageIds.length === 0) {
     if (pageIds == null || pageIds.length === 0) {
       logger.error('pageIds is null or 0 length.');
       logger.error('pageIds is null or 0 length.');
       return;
       return;
     }
     }
 
 
-    const [normalizedIds, notNormalizedPaths] = await this.crowi.pageGrantService.separateNormalizedAndNonNormalizedPages(pageIds);
-
-    if (normalizedIds.length === 0) {
-      // socket.emit('normalizeParentRecursivelyByPageIds', { error: err.message }); TODO: use socket to tell user
-      return;
-    }
-
-    if (notNormalizedPaths.length !== 0) {
-      // TODO: iterate notNormalizedPaths and send socket error to client so that the user can know which path failed to migrate
-      // socket.emit('normalizeParentRecursivelyByPageIds', { error: err.message }); TODO: use socket to tell user
+    if (pageIds.length > LIMIT_FOR_MULTIPLE_PAGE_OP) {
+      throw Error(`The maximum number of pageIds allowed is ${LIMIT_FOR_MULTIPLE_PAGE_OP}.`);
     }
     }
 
 
-    /*
-     * generate regexps
-     */
-    const Page = mongoose.model('Page') as unknown as PageModel;
-
-    let pages;
+    let normalizablePages;
+    let nonNormalizablePages;
     try {
     try {
-      pages = await Page.findByPageIdsToEdit(pageIds, user, false);
+      [normalizablePages, nonNormalizablePages] = await this.crowi.pageGrantService.separateNormalizableAndNotNormalizablePages(pageIds);
     }
     }
     catch (err) {
     catch (err) {
-      logger.error('Failed to find pages by ids', err);
       throw err;
       throw err;
     }
     }
 
 
-    // prepare no duplicated area paths
-    let paths = pages.map(p => p.path);
-    paths = omitDuplicateAreaPathFromPaths(paths);
+    if (normalizablePages.length === 0) {
+      // socket.emit('normalizeParentRecursivelyByPageIds', { error: err.message }); TODO: use socket to tell user
+      return;
+    }
+
+    if (nonNormalizablePages.length !== 0) {
+      // TODO: iterate nonNormalizablePages and send socket error to client so that the user can know which path failed to migrate
+      // socket.emit('normalizeParentRecursivelyByPageIds', { error: err.message }); TODO: use socket to tell user
+    }
 
 
-    const regexps = paths.map(path => new RegExp(`^${escapeStringRegexp(path)}`));
+    const pagesToNormalize = omitDuplicateAreaPageFromPages(normalizablePages);
+    const pageIdsToNormalize = pagesToNormalize.map(p => p._id);
+    const pathsToNormalize = Array.from(new Set(pagesToNormalize.map(p => p.path)));
 
 
     // TODO: insertMany PageOperationBlock
     // TODO: insertMany PageOperationBlock
 
 
-    // migrate recursively
+    // for updating descendantCount
+    const pageIdToExDescendantCountMap = new Map<ObjectIdLike, number>();
+
+    // MAIN PROCESS migrate recursively
+    const regexps = pathsToNormalize.map(p => new RegExp(`^${escapeStringRegexp(p)}`, 'i'));
     try {
     try {
       await this.normalizeParentRecursively(null, regexps);
       await this.normalizeParentRecursively(null, regexps);
+
+      // find pages to save descendantCount of normalized pages (only pages in pageIds parameter of this method)
+      const pagesBeforeUpdatingDescendantCount = await Page.findByIdsAndViewer(pageIdsToNormalize, user);
+      pagesBeforeUpdatingDescendantCount.forEach((p) => {
+        pageIdToExDescendantCountMap.set(p._id.toString(), p.descendantCount);
+      });
     }
     }
     catch (err) {
     catch (err) {
       logger.error('V5 initial miration failed.', err);
       logger.error('V5 initial miration failed.', err);
@@ -1908,6 +1937,28 @@ class PageService {
 
 
       throw err;
       throw err;
     }
     }
+
+    // POST MAIN PROCESS update descendantCount
+    try {
+      // update descendantCount of self and descendant pages first
+      for await (const path of pathsToNormalize) {
+        await this.updateDescendantCountOfSelfAndDescendants(path);
+      }
+
+      // find pages again to get updated descendantCount
+      // then calculate inc
+      const pagesAfterUpdatingDescendantCount = await Page.findByIdsAndViewer(pageIdsToNormalize, user);
+      for await (const page of pagesAfterUpdatingDescendantCount) {
+        const exDescendantCount = pageIdToExDescendantCountMap.get(page._id.toString()) || 0;
+        const newDescendantCount = page.descendantCount;
+        const inc = newDescendantCount - exDescendantCount;
+        await this.updateDescendantCountOfAncestors(page._id, inc, false);
+      }
+    }
+    catch (err) {
+      logger.error('Failed to update descendantCount after normalizing parent:', err);
+      throw Error(`Failed to update descendantCount after normalizing parent: ${err}`);
+    }
   }
   }
 
 
   async _isPagePathIndexUnique() {
   async _isPagePathIndexUnique() {
@@ -2187,12 +2238,23 @@ class PageService {
     }
     }
   }
   }
 
 
-  async v5MigratablePrivatePagesCount(user) {
+  async countPagesCanNormalizeParentByUser(user): Promise<number> {
     if (user == null) {
     if (user == null) {
       throw Error('user is required');
       throw Error('user is required');
     }
     }
-    const Page = this.crowi.model('Page');
-    return Page.count({ parent: null, creator: user, grant: { $ne: Page.GRANT_PUBLIC } });
+
+    const Page = mongoose.model('Page') as unknown as PageModel;
+    const { PageQueryBuilder } = Page;
+
+    const builder = new PageQueryBuilder(Page.count(), false);
+    builder.addConditionAsNotMigrated();
+    builder.addConditionAsNonRootPage();
+    builder.addConditionToExcludeTrashed();
+    await builder.addConditionForParentNormalization(user);
+
+    const nMigratablePages = await builder.query.exec();
+
+    return nMigratablePages;
   }
   }
 
 
   /**
   /**
@@ -2200,7 +2262,7 @@ class PageService {
    * - page that has the same path as the provided path
    * - page that has the same path as the provided path
    * - pages that are descendants of the above page
    * - pages that are descendants of the above page
    */
    */
-  async updateDescendantCountOfSelfAndDescendants(path) {
+  async updateDescendantCountOfSelfAndDescendants(path: string): Promise<void> {
     const BATCH_SIZE = 200;
     const BATCH_SIZE = 200;
     const Page = this.crowi.model('Page');
     const Page = this.crowi.model('Page');
 
 

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

@@ -58,30 +58,36 @@ class UserGroupService {
       throw Error('Parent group does not exist.');
       throw Error('Parent group does not exist.');
     }
     }
 
 
+    /*
+     * check if able to update parent or not
+     */
 
 
-    // throw if parent was in its descendants
+    // throw if parent was in self and its descendants
     const descendantsWithTarget = await UserGroup.findGroupsWithDescendantsRecursively([userGroup]);
     const descendantsWithTarget = await UserGroup.findGroupsWithDescendantsRecursively([userGroup]);
-    const descendants = descendantsWithTarget.filter(d => d._id.equals(userGroup._id));
-    if (isIncludesObjectId(descendants, parent._id)) {
+    if (isIncludesObjectId(descendantsWithTarget, parent._id)) {
       throw Error('It is not allowed to choose parent from descendant groups.');
       throw Error('It is not allowed to choose parent from descendant groups.');
     }
     }
 
 
     // find users for comparison
     // find users for comparison
     const [targetGroupUsers, parentGroupUsers] = await Promise.all(
     const [targetGroupUsers, parentGroupUsers] = await Promise.all(
-      [UserGroupRelation.findUserIdsByGroupId(userGroup._id), UserGroupRelation.findUserIdsByGroupId(parent?._id)], // TODO 85062: consider when parent is null to update the group as the root
+      [UserGroupRelation.findUserIdsByGroupId(userGroup._id), UserGroupRelation.findUserIdsByGroupId(parent._id)],
     );
     );
-
     const usersBelongsToTargetButNotParent = targetGroupUsers.filter(user => !parentGroupUsers.includes(user));
     const usersBelongsToTargetButNotParent = targetGroupUsers.filter(user => !parentGroupUsers.includes(user));
+
+    // save if no users exist in both target and parent groups
+    if (targetGroupUsers.length === 0 && parentGroupUsers.length === 0) {
+      userGroup.parent = parent._id;
+      return userGroup.save();
+    }
+
     // add the target group's users to all ancestors
     // add the target group's users to all ancestors
     if (forceUpdateParents) {
     if (forceUpdateParents) {
       const ancestorGroups = await UserGroup.findGroupsWithAncestorsRecursively(parent);
       const ancestorGroups = await UserGroup.findGroupsWithAncestorsRecursively(parent);
       const ancestorGroupIds = ancestorGroups.map(group => group._id);
       const ancestorGroupIds = ancestorGroups.map(group => group._id);
 
 
       await UserGroupRelation.createByGroupIdsAndUserIds(ancestorGroupIds, usersBelongsToTargetButNotParent);
       await UserGroupRelation.createByGroupIdsAndUserIds(ancestorGroupIds, usersBelongsToTargetButNotParent);
-
-      userGroup.parent = parent?._id; // TODO 85062: consider when parent is null to update the group as the root
     }
     }
-    // validate related users
+    // throw if any of users in the target group is NOT included in the parent group
     else {
     else {
       const isUpdatable = usersBelongsToTargetButNotParent.length === 0;
       const isUpdatable = usersBelongsToTargetButNotParent.length === 0;
       if (!isUpdatable) {
       if (!isUpdatable) {
@@ -89,6 +95,7 @@ class UserGroupService {
       }
       }
     }
     }
 
 
+    userGroup.parent = parent._id;
     return userGroup.save();
     return userGroup.save();
   }
   }
 
 

+ 4 - 0
packages/app/src/server/util/swigFunctions.js

@@ -171,6 +171,10 @@ module.exports = function(crowi, req, locals) {
     });
     });
   };
   };
 
 
+  locals.attachTitleHeader = function(path) {
+    return pathUtils.attachTitleHeader(path);
+  };
+
   locals.css = {
   locals.css = {
     grant(pageData) {
     grant(pageData) {
       if (!pageData) {
       if (!pageData) {

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

@@ -107,6 +107,7 @@
 <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-presentation-modal"></div>
 <div id="page-accessories-modal"></div>
 <div id="page-accessories-modal"></div>
 <div id="descendants-page-list-modal"></div>
 <div id="descendants-page-list-modal"></div>
 
 

+ 2 - 2
packages/app/src/server/views/widget/not_found_content.html

@@ -17,9 +17,9 @@
 
 
     {% if getConfig('crowi', 'customize:isEnabledAttachTitleHeader') %}
     {% if getConfig('crowi', 'customize:isEnabledAttachTitleHeader') %}
     {% if template %}
     {% if template %}
-    <script type="text/template" id="raw-text-original"># {{ path | path2name | preventXss }}&NewLine;{{ template }}</script>
+    <script type="text/template" id="raw-text-original">{{ attachTitleHeader(path | path2name | preventXss) }}&NewLine;{{ template }}</script>
     {% else %}
     {% else %}
-    <script type="text/template" id="raw-text-original"># {{ path | path2name | preventXss }}</script>
+    <script type="text/template" id="raw-text-original">{{ attachTitleHeader(path | path2name | preventXss) }}</script>
     {% endif %}
     {% endif %}
     {% else %}
     {% else %}
     {% if template %}
     {% if template %}

+ 229 - 0
packages/app/src/stores/modal.tsx

@@ -0,0 +1,229 @@
+import { SWRResponse } from 'swr';
+import { useStaticSWR } from './use-static-swr';
+import { Nullable } from '~/interfaces/common';
+
+
+/*
+* PageCreateModal
+*/
+type CreateModalStatus = {
+  isOpened: boolean,
+  path?: string,
+}
+
+type CreateModalStatusUtils = {
+  open(path?: string): Promise<CreateModalStatus | undefined>
+  close(): Promise<CreateModalStatus | undefined>
+}
+
+export const usePageCreateModal = (status?: CreateModalStatus): SWRResponse<CreateModalStatus, Error> & CreateModalStatusUtils => {
+  const initialData: CreateModalStatus = { isOpened: false };
+  const swrResponse = useStaticSWR<CreateModalStatus, Error>('pageCreateModalStatus', status, { fallbackData: initialData });
+
+  return {
+    ...swrResponse,
+    open: (path?: string) => swrResponse.mutate({ isOpened: true, path }),
+    close: () => swrResponse.mutate({ isOpened: false }),
+  };
+};
+
+/*
+* PageDeleteModal
+*/
+export type IPageForPageDeleteModal = {
+  pageId: string,
+  revisionId?: string,
+  path: string
+}
+
+export type OnDeletedFunction = (pathOrPaths: string | string[], isRecursively: Nullable<true>, isCompletely: Nullable<true>) => void;
+
+type DeleteModalStatus = {
+  isOpened: boolean,
+  pages?: IPageForPageDeleteModal[],
+  onDeleted?: OnDeletedFunction,
+}
+
+type DeleteModalStatusUtils = {
+  open(pages?: IPageForPageDeleteModal[], onDeleted?: OnDeletedFunction): Promise<DeleteModalStatus | undefined>,
+  close(): Promise<DeleteModalStatus | undefined>,
+}
+
+export const usePageDeleteModal = (status?: DeleteModalStatus): SWRResponse<DeleteModalStatus, Error> & DeleteModalStatusUtils => {
+  const initialData: DeleteModalStatus = { isOpened: false, pages: [], onDeleted: () => {} };
+  const swrResponse = useStaticSWR<DeleteModalStatus, Error>('deleteModalStatus', status, { fallbackData: initialData });
+
+  return {
+    ...swrResponse,
+    open: (pages?: IPageForPageDeleteModal[], onDeleted?: OnDeletedFunction) => swrResponse.mutate({ isOpened: true, pages, onDeleted }),
+    close: () => swrResponse.mutate({ isOpened: false }),
+  };
+};
+
+/*
+* PageDuplicateModal
+*/
+export type IPageForPageDuplicateModal = {
+  pageId: string,
+  path: string
+}
+
+type DuplicateModalStatus = {
+  isOpened: boolean,
+  pageId?: string,
+  path?: string,
+}
+
+type DuplicateModalStatusUtils = {
+  open(pageId: string, path: string): Promise<DuplicateModalStatus | undefined>
+  close(): Promise<DuplicateModalStatus | undefined>
+}
+
+export const usePageDuplicateModal = (status?: DuplicateModalStatus): SWRResponse<DuplicateModalStatus, Error> & DuplicateModalStatusUtils => {
+  const initialData: DuplicateModalStatus = { isOpened: false, pageId: '', path: '' };
+  const swrResponse = useStaticSWR<DuplicateModalStatus, Error>('duplicateModalStatus', status, { fallbackData: initialData });
+
+  return {
+    ...swrResponse,
+    open: (pageId: string, path: string) => swrResponse.mutate({ isOpened: true, pageId, path }),
+    close: () => swrResponse.mutate({ isOpened: false }),
+  };
+};
+
+
+/*
+* PageRenameModal
+*/
+export type IPageForPageRenameModal = {
+  pageId: string,
+  revisionId: string,
+  path: string
+}
+
+type RenameModalStatus = {
+  isOpened: boolean,
+  pageId?: string,
+  revisionId?: string
+  path?: string,
+}
+
+type RenameModalStatusUtils = {
+  open(pageId: string, revisionId: string, path: string): Promise<RenameModalStatus | undefined>
+  close(): Promise<RenameModalStatus | undefined>
+}
+
+export const usePageRenameModal = (status?: RenameModalStatus): SWRResponse<RenameModalStatus, Error> & RenameModalStatusUtils => {
+  const initialData: RenameModalStatus = {
+    isOpened: false, pageId: '', revisionId: '', path: '',
+  };
+  const swrResponse = useStaticSWR<RenameModalStatus, Error>('renameModalStatus', status, { fallbackData: initialData });
+
+  return {
+    ...swrResponse,
+    open: (pageId: string, revisionId: string, path: string) => swrResponse.mutate({
+      isOpened: true, pageId, revisionId, path,
+    }),
+    close: () => swrResponse.mutate({ isOpened: false }),
+  };
+};
+
+/*
+* PagePresentationModal
+*/
+type PresentationModalStatus = {
+  isOpened: boolean,
+  href?: string
+}
+
+type PresentationModalStatusUtils = {
+  open(href: string): Promise<PresentationModalStatus | undefined>
+  close(): Promise<PresentationModalStatus | undefined>
+}
+
+export const usePagePresentationModal = (
+    status?: PresentationModalStatus,
+): SWRResponse<PresentationModalStatus, Error> & PresentationModalStatusUtils => {
+  const initialData: PresentationModalStatus = {
+    isOpened: false, href: '?presentation=1',
+  };
+  const swrResponse = useStaticSWR<PresentationModalStatus, Error>('presentationModalStatus', status, { fallbackData: initialData });
+
+  return {
+    ...swrResponse,
+    open: (href: string) => swrResponse.mutate({ isOpened: true, href }),
+    close: () => swrResponse.mutate({ isOpened: false }),
+  };
+};
+
+/*
+* DescendantsPageListModal
+*/
+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 }),
+  };
+};
+
+/*
+* PageAccessoriesModal
+*/
+export const PageAccessoriesModalContents = {
+  PageHistory: 'PageHistory',
+  Attachment: 'Attachment',
+  ShareLink: 'ShareLink',
+} as const;
+export type PageAccessoriesModalContents = typeof PageAccessoriesModalContents[keyof typeof PageAccessoriesModalContents];
+
+type PageAccessoriesModalStatus = {
+  isOpened: boolean,
+  onOpened?: (initialActivatedContents: PageAccessoriesModalContents) => void,
+}
+
+type PageAccessoriesModalUtils = {
+  open(activatedContents: PageAccessoriesModalContents): void
+  close(): void
+}
+
+export const usePageAccessoriesModal = (): SWRResponse<PageAccessoriesModalStatus, Error> & PageAccessoriesModalUtils => {
+
+  const initialStatus = { isOpened: false };
+  const swrResponse = useStaticSWR<PageAccessoriesModalStatus, Error>('pageAccessoriesModalStatus', undefined, { fallbackData: initialStatus });
+
+  return {
+    ...swrResponse,
+    open: (activatedContents: PageAccessoriesModalContents) => {
+      if (swrResponse.data == null) {
+        return;
+      }
+      swrResponse.mutate({ isOpened: true });
+
+      if (swrResponse.data.onOpened != null) {
+        swrResponse.data.onOpened(activatedContents);
+      }
+    },
+    close: () => {
+      if (swrResponse.data == null) {
+        return;
+      }
+      swrResponse.mutate({ isOpened: false });
+    },
+  };
+};

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

@@ -253,247 +253,6 @@ export const useSidebarResizeDisabled = (isDisabled?: boolean): SWRResponse<bool
   return useStaticSWR('isSidebarResizeDisabled', isDisabled, { fallbackData: false });
   return useStaticSWR('isSidebarResizeDisabled', isDisabled, { fallbackData: false });
 };
 };
 
 
-// PageCreateModal
-type CreateModalStatus = {
-  isOpened: boolean,
-  path?: string,
-}
-
-type CreateModalStatusUtils = {
-  open(path?: string): Promise<CreateModalStatus | undefined>
-  close(): Promise<CreateModalStatus | undefined>
-}
-
-export const useCreateModalStatus = (status?: CreateModalStatus): SWRResponse<CreateModalStatus, Error> & CreateModalStatusUtils => {
-  const initialData: CreateModalStatus = { isOpened: false };
-  const swrResponse = useStaticSWR<CreateModalStatus, Error>('pageCreateModalStatus', status, { fallbackData: initialData });
-
-  return {
-    ...swrResponse,
-    open: (path?: string) => swrResponse.mutate({ isOpened: true, path }),
-    close: () => swrResponse.mutate({ isOpened: false }),
-  };
-};
-
-export const useCreateModalOpened = (): SWRResponse<boolean, Error> => {
-  const { data } = useCreateModalStatus();
-  return useSWR(
-    data != null ? ['isCreaateModalOpened', data] : null,
-    () => {
-      return data != null ? data.isOpened : false;
-    },
-  );
-};
-
-export const useCreateModalPath = (): SWRResponse<string | null | undefined, Error> => {
-  const { data: currentPagePath } = useCurrentPagePath();
-  const { data: status } = useCreateModalStatus();
-
-  return useSWR(
-    currentPagePath != null && status != null ? [currentPagePath, status] : null,
-    (currentPagePath, status) => {
-      return status?.path || currentPagePath;
-    },
-  );
-};
-
-// PageDeleteModal
-export type IPageForPageDeleteModal = {
-  pageId: string,
-  revisionId?: string,
-  path: string
-}
-
-export type OnDeletedFunction = (pathOrPaths: string | string[], isRecursively: Nullable<true>, isCompletely: Nullable<true>) => void;
-
-type DeleteModalStatus = {
-  isOpened: boolean,
-  pages?: IPageForPageDeleteModal[],
-  onDeleted?: OnDeletedFunction,
-}
-
-type DeleteModalOpened = {
-  isOpend: boolean,
-  onDeleted?: OnDeletedFunction,
-}
-
-type DeleteModalStatusUtils = {
-  open(pages?: IPageForPageDeleteModal[], onDeleted?: OnDeletedFunction): Promise<DeleteModalStatus | undefined>,
-  close(): Promise<DeleteModalStatus | undefined>,
-}
-
-export const usePageDeleteModal = (status?: DeleteModalStatus): SWRResponse<DeleteModalStatus, Error> & DeleteModalStatusUtils => {
-  const initialData: DeleteModalStatus = { isOpened: false };
-  const swrResponse = useStaticSWR<DeleteModalStatus, Error>('deleteModalStatus', status, { fallbackData: initialData });
-
-  return {
-    ...swrResponse,
-    open: (pages?: IPageForPageDeleteModal[], onDeleted?: OnDeletedFunction) => swrResponse.mutate({ isOpened: true, pages, onDeleted }),
-    close: () => swrResponse.mutate({ isOpened: false }),
-  };
-};
-
-export const usePageDeleteModalOpened = (): SWRResponse<(DeleteModalOpened | null), Error> => {
-  const { data } = usePageDeleteModal();
-  return useSWRImmutable(
-    data != null ? ['isDeleteModalOpened', data] : null,
-    () => {
-      return data != null ? { isOpend: data.isOpened, onDeleted: data?.onDeleted } : null;
-    },
-  );
-};
-
-// PageDuplicateModal
-export type IPageForPageDuplicateModal = {
-  pageId: string,
-  path: string
-}
-
-type DuplicateModalStatus = {
-  isOpened: boolean,
-  pageId?: string,
-  path?: string,
-}
-
-type DuplicateModalStatusUtils = {
-  open(pageId: string, path: string): Promise<DuplicateModalStatus | undefined>
-  close(): Promise<DuplicateModalStatus | undefined>
-}
-
-export const usePageDuplicateModalStatus = (status?: DuplicateModalStatus): SWRResponse<DuplicateModalStatus, Error> & DuplicateModalStatusUtils => {
-  const initialData: DuplicateModalStatus = { isOpened: false, pageId: '', path: '' };
-  const swrResponse = useStaticSWR<DuplicateModalStatus, Error>('duplicateModalStatus', status, { fallbackData: initialData });
-
-  return {
-    ...swrResponse,
-    open: (pageId: string, path: string) => swrResponse.mutate({ isOpened: true, pageId, path }),
-    close: () => swrResponse.mutate({ isOpened: false }),
-  };
-};
-
-export const usePageDuplicateModalOpened = (): SWRResponse<boolean, Error> => {
-  const { data } = usePageDuplicateModalStatus();
-  return useSWRImmutable(
-    data != null ? ['isDuplicateModalOpened', data] : null,
-    () => {
-      return data != null ? data.isOpened : false;
-    },
-  );
-};
-
-// PageRenameModal
-export type IPageForPageRenameModal = {
-  pageId: string,
-  revisionId: string,
-  path: string
-}
-
-type RenameModalStatus = {
-  isOpened: boolean,
-  pageId?: string,
-  revisionId?: string
-  path?: string,
-}
-
-type RenameModalStatusUtils = {
-  open(pageId: string, revisionId: string, path: string): Promise<RenameModalStatus | undefined>
-  close(): Promise<RenameModalStatus | undefined>
-}
-
-export const usePageRenameModalStatus = (status?: RenameModalStatus): SWRResponse<RenameModalStatus, Error> & RenameModalStatusUtils => {
-  const initialData: RenameModalStatus = {
-    isOpened: false, pageId: '', revisionId: '', path: '',
-  };
-  const swrResponse = useStaticSWR<RenameModalStatus, Error>('renameModalStatus', status, { fallbackData: initialData });
-
-  return {
-    ...swrResponse,
-    open: (pageId: string, revisionId: string, path: string) => swrResponse.mutate({
-      isOpened: true, pageId, revisionId, path,
-    }),
-    close: () => swrResponse.mutate({ isOpened: false }),
-  };
-};
-
-export const usePageRenameModalOpened = (): SWRResponse<boolean, Error> => {
-  const { data } = usePageRenameModalStatus();
-  return useSWRImmutable(
-    data != null ? ['isRenameModalOpened', data] : null,
-    () => {
-      return data != null ? data.isOpened : false;
-    },
-  );
-};
-
-
-type DescendantsPageListModalStatus = {
-  isOpened: boolean,
-  path?: string,
-}
-
-type DescendantsPageListUtils = {
-  open(path: string): Promise<DescendantsPageListModalStatus | undefined>
-  close(): Promise<DuplicateModalStatus | undefined>
-}
-
-export const useDescendantsPageListModal = (
-    status?: DescendantsPageListModalStatus,
-): SWRResponse<DescendantsPageListModalStatus, Error> & DescendantsPageListUtils => {
-
-  const initialData: DescendantsPageListModalStatus = { isOpened: false };
-  const swrResponse = useStaticSWR<DescendantsPageListModalStatus, Error>('descendantsPageListModalStatus', status, { fallbackData: initialData });
-
-  return {
-    ...swrResponse,
-    open: (path: string) => swrResponse.mutate({ isOpened: true, path }),
-    close: () => swrResponse.mutate({ isOpened: false }),
-  };
-};
-
-
-export const PageAccessoriesModalContents = {
-  PageHistory: 'PageHistory',
-  Attachment: 'Attachment',
-  ShareLink: 'ShareLink',
-} as const;
-export type PageAccessoriesModalContents = typeof PageAccessoriesModalContents[keyof typeof PageAccessoriesModalContents];
-
-type PageAccessoriesModalStatus = {
-  isOpened: boolean,
-  onOpened?: (initialActivatedContents: PageAccessoriesModalContents) => void,
-}
-
-type PageAccessoriesModalUtils = {
-  open(activatedContents: PageAccessoriesModalContents): void
-  close(): void
-}
-
-export const usePageAccessoriesModal = (): SWRResponse<PageAccessoriesModalStatus, Error> & PageAccessoriesModalUtils => {
-
-  const initialStatus = { isOpened: false };
-  const swrResponse = useStaticSWR<PageAccessoriesModalStatus, Error>('pageAccessoriesModalStatus', undefined, { fallbackData: initialStatus });
-
-  return {
-    ...swrResponse,
-    open: (activatedContents: PageAccessoriesModalContents) => {
-      if (swrResponse.data == null) {
-        return;
-      }
-      swrResponse.mutate({ isOpened: true });
-
-      if (swrResponse.data.onOpened != null) {
-        swrResponse.data.onOpened(activatedContents);
-      }
-    },
-    close: () => {
-      if (swrResponse.data == null) {
-        return;
-      }
-      swrResponse.mutate({ isOpened: false });
-    },
-  };
-};
-
 
 
 export const useSelectedGrant = (initialData?: Nullable<number>): SWRResponse<Nullable<number>, Error> => {
 export const useSelectedGrant = (initialData?: Nullable<number>): SWRResponse<Nullable<number>, Error> => {
   return useStaticSWR<Nullable<number>, Error>('grant', initialData);
   return useStaticSWR<Nullable<number>, Error>('grant', initialData);

+ 5 - 3
packages/core/src/utils/page-path-utils.ts

@@ -169,8 +169,9 @@ export const collectAncestorPaths = (path: string, ancestorPaths: string[] = [])
  * @returns omitted paths
  * @returns omitted paths
  */
  */
 export const omitDuplicateAreaPathFromPaths = (paths: string[]): string[] => {
 export const omitDuplicateAreaPathFromPaths = (paths: string[]): string[] => {
-  return paths.filter((path) => {
-    const isDuplicate = paths.filter(p => (new RegExp(`^${p}\\/.+`, 'i')).test(path)).length > 0;
+  const uniquePaths = Array.from(new Set(paths));
+  return uniquePaths.filter((path) => {
+    const isDuplicate = uniquePaths.filter(p => (new RegExp(`^${p}\\/.+`, 'i')).test(path)).length > 0;
 
 
     return !isDuplicate;
     return !isDuplicate;
   });
   });
@@ -178,12 +179,13 @@ export const omitDuplicateAreaPathFromPaths = (paths: string[]): string[] => {
 
 
 /**
 /**
  * return pages with path without duplicate area of regexp /^${path}\/.+/i
  * return pages with path without duplicate area of regexp /^${path}\/.+/i
+ * if the pages' path are the same, it will NOT omit any of them since the other attributes will not be the same
  * @param paths paths to be tested
  * @param paths paths to be tested
  * @returns omitted paths
  * @returns omitted paths
  */
  */
 export const omitDuplicateAreaPageFromPages = (pages: any[]): any[] => {
 export const omitDuplicateAreaPageFromPages = (pages: any[]): any[] => {
   return pages.filter((page) => {
   return pages.filter((page) => {
-    const isDuplicate = pages.filter(p => (new RegExp(`^${p.path}\\/.+`, 'i')).test(page.path)).length > 0;
+    const isDuplicate = pages.some(p => (new RegExp(`^${p.path}\\/.+`, 'i')).test(page.path));
 
 
     return !isDuplicate;
     return !isDuplicate;
   });
   });

+ 11 - 0
packages/core/src/utils/path-utils.js

@@ -106,3 +106,14 @@ export function normalizePath(path) {
   }
   }
   return `/${match[3]}`;
   return `/${match[3]}`;
 }
 }
+
+
+/**
+ *
+ * @param {string} path
+ * @returns {string}
+ * @memberof pathUtils
+ */
+export function attachTitleHeader(path) {
+  return `# ${path}`;
+}