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

Merge branch 'master' into imprv/page-v5-test-code-revert

Haku Mizuki 4 лет назад
Родитель
Сommit
f8711a722e
23 измененных файлов с 271 добавлено и 247 удалено
  1. 11 3
      packages/app/src/components/Common/Dropdown/PageItemControl.tsx
  2. 4 8
      packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  3. 3 2
      packages/app/src/components/Navbar/SubNavButtons.tsx
  4. 11 13
      packages/app/src/components/Page/TrashPageAlert.jsx
  5. 27 21
      packages/app/src/components/PageDeleteModal.tsx
  6. 3 2
      packages/app/src/components/PageEditor/LinkEditModal.jsx
  7. 30 9
      packages/app/src/components/PageList/PageListItemL.tsx
  8. 17 4
      packages/app/src/components/PagePathHierarchicalLink.jsx
  9. 6 3
      packages/app/src/components/PrivateLegacyPages.tsx
  10. 2 2
      packages/app/src/components/PutbackPageModal.jsx
  11. 5 12
      packages/app/src/components/SearchPage/SearchResultContent.tsx
  12. 1 1
      packages/app/src/components/SearchTypeahead.tsx
  13. 6 10
      packages/app/src/components/Sidebar/PageTree/Item.tsx
  14. 4 4
      packages/app/src/components/Sidebar/PageTree/ItemsTree.tsx
  15. 1 5
      packages/app/src/server/models/obsolete-page.js
  16. 1 1
      packages/app/src/server/models/subscription.ts
  17. 13 48
      packages/app/src/server/routes/apiv3/page.js
  18. 2 12
      packages/app/src/server/routes/page.js
  19. 78 36
      packages/app/src/server/service/page.ts
  20. 9 16
      packages/app/src/stores/modal.tsx
  21. 0 11
      packages/app/test/integration/models/page.test.js
  22. 11 1
      packages/core/src/test/util/page-path-utils.test.js
  23. 26 23
      packages/core/src/utils/page-path-utils.ts

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

@@ -36,7 +36,7 @@ type CommonProps = {
   onClickBookmarkMenuItem?: (pageId: string, newValue?: boolean) => Promise<void>,
   onClickDuplicateMenuItem?: (pageId: string) => Promise<void> | void,
   onClickRenameMenuItem?: (pageId: string) => Promise<void> | void,
-  onClickDeleteMenuItem?: (pageId: string) => Promise<void> | void,
+  onClickDeleteMenuItem?: (pageId: string, pageInfo: IPageInfoAll | undefined) => Promise<void> | void,
   onClickRevertMenuItem?: (pageId: string) => Promise<void> | void,
 
   additionalMenuItemRenderer?: React.FunctionComponent<AdditionalMenuItemsRendererProps>,
@@ -100,7 +100,7 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
       logger.warn('This page could not be deleted.');
       return;
     }
-    await onClickDeleteMenuItem(pageId);
+    await onClickDeleteMenuItem(pageId, pageInfo);
   }, [onClickDeleteMenuItem, pageId, pageInfo]);
 
   let contents = <></>;
@@ -204,7 +204,7 @@ export const PageItemControlSubstance = (props: PageItemControlSubstanceProps):
   const {
     pageId, pageInfo: presetPageInfo, fetchOnInit,
     children,
-    onClickBookmarkMenuItem, onClickDuplicateMenuItem, onClickRenameMenuItem,
+    onClickBookmarkMenuItem, onClickDuplicateMenuItem, onClickRenameMenuItem, onClickDeleteMenuItem,
   } = props;
 
   const [isOpen, setIsOpen] = useState(false);
@@ -241,6 +241,13 @@ export const PageItemControlSubstance = (props: PageItemControlSubstanceProps):
     await onClickRenameMenuItem(pageId);
   }, [onClickRenameMenuItem, pageId]);
 
+  const deleteMenuItemClickHandler = useCallback(async() => {
+    if (onClickDeleteMenuItem == null) {
+      return;
+    }
+    await onClickDeleteMenuItem(pageId, fetchedPageInfo ?? presetPageInfo);
+  }, [onClickDeleteMenuItem, pageId, fetchedPageInfo, presetPageInfo]);
+
   return (
     <Dropdown isOpen={isOpen} toggle={() => setIsOpen(!isOpen)}>
 
@@ -257,6 +264,7 @@ export const PageItemControlSubstance = (props: PageItemControlSubstanceProps):
         onClickBookmarkMenuItem={bookmarkMenuItemClickHandler}
         onClickDuplicateMenuItem={duplicateMenuItemClickHandler}
         onClickRenameMenuItem={renameMenuItemClickHandler}
+        onClickDeleteMenuItem={deleteMenuItemClickHandler}
       />
     </Dropdown>
   );

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

@@ -13,9 +13,8 @@ import {
 } from '~/stores/ui';
 import {
   usePageAccessoriesModal, PageAccessoriesModalContents,
-  usePageDuplicateModal, usePageRenameModal, usePageDeleteModal, OnDeletedFunction, usePagePresentationModal,
+  usePageDuplicateModal, usePageRenameModal, usePageDeleteModal, OnDeletedFunction, usePagePresentationModal, IPageForPageDeleteModal,
 } from '~/stores/modal';
-import { useSWRxPageChildren } from '~/stores/page-listing';
 
 
 import {
@@ -143,7 +142,6 @@ const GrowiContextualSubNavigation = (props) => {
   const { data: isAbleToShowPageEditorModeManager } = useIsAbleToShowPageEditorModeManager();
   const { data: isAbleToShowPageAuthors } = useIsAbleToShowPageAuthors();
 
-  const { mutate: mutateChildren } = useSWRxPageChildren(path);
   const { mutate: mutateSWRTagsInfo, data: tagsInfoData } = useSWRTagsInfo(pageId);
 
   const { open: openDuplicateModal } = usePageDuplicateModal();
@@ -193,8 +191,6 @@ const GrowiContextualSubNavigation = (props) => {
       return;
     }
 
-    mutateChildren();
-
     const path = pathOrPathsToDelete;
 
     if (isCompletely) {
@@ -204,10 +200,10 @@ const GrowiContextualSubNavigation = (props) => {
     else {
       window.location.reload();
     }
-  }, [mutateChildren]);
+  }, []);
 
-  const deleteItemClickedHandler = useCallback(async(pageToDelete, isAbleToDeleteCompletely) => {
-    openDeleteModal([pageToDelete], onDeletedHandler, isAbleToDeleteCompletely);
+  const deleteItemClickedHandler = useCallback((pageToDelete: IPageForPageDeleteModal) => {
+    openDeleteModal([pageToDelete], { onDeleted: onDeletedHandler });
   }, [onDeletedHandler, openDeleteModal]);
 
   const templateMenuItemClickHandler = useCallback(() => {

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

@@ -26,7 +26,7 @@ type CommonProps = {
   additionalMenuItemRenderer?: React.FunctionComponent<AdditionalMenuItemsRendererProps>,
   onClickDuplicateMenuItem?: (pageId: string, path: string) => void,
   onClickRenameMenuItem?: (pageId: string, revisionId: string, path: string) => void,
-  onClickDeleteMenuItem?: (pageToDelete: IPageForPageDeleteModal | null, isAbleToDeleteCompletely: boolean) => void,
+  onClickDeleteMenuItem?: (pageToDelete: IPageForPageDeleteModal) => void,
 }
 
 type SubNavButtonsSubstanceProps = CommonProps & {
@@ -121,9 +121,10 @@ const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element
       pageId,
       revisionId,
       path,
+      isAbleToDeleteCompletely: pageInfo.isAbleToDeleteCompletely,
     };
 
-    onClickDeleteMenuItem(pageToDelete, pageInfo.isAbleToDeleteCompletely);
+    onClickDeleteMenuItem(pageToDelete);
   }, [onClickDeleteMenuItem, pageId, pageInfo.isAbleToDeleteCompletely, path, revisionId]);
 
   if (!isIPageInfoForOperation(pageInfo)) {

+ 11 - 13
packages/app/src/components/Page/TrashPageAlert.jsx

@@ -1,4 +1,4 @@
-import React, { useState, useEffect, useCallback } from 'react';
+import React, { useState, useCallback } from 'react';
 import PropTypes from 'prop-types';
 
 import { withTranslation } from 'react-i18next';
@@ -11,7 +11,7 @@ import PageContainer from '~/client/services/PageContainer';
 import EmptyTrashModal from '../EmptyTrashModal';
 
 import { useCurrentUpdatedAt, useShareLinkId } from '~/stores/context';
-import { usePageDeleteModal, usePutBackPageMOdal } from '~/stores/modal';
+import { usePageDeleteModal, usePutBackPageModal } from '~/stores/modal';
 import { useSWRxPageInfo } from '~/stores/page';
 
 const TrashPageAlert = (props) => {
@@ -30,16 +30,9 @@ const TrashPageAlert = (props) => {
 
   const { data: updatedAt } = useCurrentUpdatedAt();
   const [isEmptyTrashModalShown, setIsEmptyTrashModalShown] = useState(false);
-  const [isAbleToDeleteCompletely, setIsAbleToDeleteCompletely] = useState(false);
-
-  useEffect(() => {
-    if (pageInfo != null) {
-      setIsAbleToDeleteCompletely(pageInfo.isAbleToDeleteCompletely);
-    }
-  }, [pageInfo]);
 
   const { open: openDeleteModal } = usePageDeleteModal();
-  const { open: openPutBackPageModal } = usePutBackPageMOdal();
+  const { open: openPutBackPageModal } = usePutBackPageModal();
 
   function openEmptyTrashModalHandler() {
     setIsEmptyTrashModalShown(true);
@@ -68,8 +61,13 @@ const TrashPageAlert = (props) => {
       revisionId,
       path,
     };
-    const isDeleteCompletelyModal = true;
-    openDeleteModal([pageToDelete], onDeletedHandler, isAbleToDeleteCompletely, isDeleteCompletelyModal);
+    openDeleteModal(
+      [pageToDelete],
+      {
+        isAbleToDeleteCompletely: pageInfo.isAbleToDeleteCompletely,
+        onDeletedHandler,
+      },
+    );
   }
 
   function renderEmptyButton() {
@@ -100,7 +98,7 @@ const TrashPageAlert = (props) => {
         <button
           type="button"
           className="btn btn-danger rounded-pill btn-sm"
-          disabled={!isAbleToDeleteCompletely}
+          disabled={!(pageInfo?.isAbleToDeleteCompletely ?? false)}
           onClick={openPageDeleteModalHandler}
         >
           <i className="icon-fire" aria-hidden="true"></i> { t('Delete Completely') }

+ 27 - 21
packages/app/src/components/PageDeleteModal.tsx

@@ -1,4 +1,4 @@
-import React, { useState, useEffect, FC } from 'react';
+import React, { useState, FC, useMemo } from 'react';
 import {
   Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
@@ -11,6 +11,7 @@ import { usePageDeleteModal } from '~/stores/modal';
 import { IDeleteSinglePageApiv1Result, IDeleteManyPageApiv3Result } from '~/interfaces/page';
 
 import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
+import { isTrashPage } from '^/../core/src/utils/page-path-utils';
 
 
 const deleteIconAndKey = {
@@ -32,12 +33,24 @@ const PageDeleteModal: FC = () => {
   const { data: deleteModalData, close: closeDeleteModal } = usePageDeleteModal();
 
   const isOpened = deleteModalData?.isOpened ?? false;
-  const isAbleToDeleteCompletely = deleteModalData?.isAbleToDeleteCompletely ?? false;
-  const isDeleteCompletelyModal = deleteModalData?.isDeleteCompletelyModal ?? false;
+
+  const isAbleToDeleteCompletely = useMemo(() => {
+    if (deleteModalData != null && deleteModalData.pages != null && deleteModalData.pages.length > 0) {
+      return deleteModalData.pages.every(page => page.isAbleToDeleteCompletely);
+    }
+    return true;
+  }, [deleteModalData]);
+
+  const forceDeleteCompletelyMode = useMemo(() => {
+    if (deleteModalData != null && deleteModalData.pages != null && deleteModalData.pages.length > 0) {
+      return deleteModalData.pages.every(page => isTrashPage(page.path));
+    }
+    return false;
+  }, [deleteModalData]);
 
   const [isDeleteRecursively, setIsDeleteRecursively] = useState(true);
-  const [isDeleteCompletely, setIsDeleteCompletely] = useState(false);
-  const deleteMode = isDeleteCompletely ? 'completely' : 'temporary';
+  const [isDeleteCompletely, setIsDeleteCompletely] = useState(forceDeleteCompletelyMode);
+  const deleteMode = forceDeleteCompletelyMode || isDeleteCompletely ? 'completely' : 'temporary';
 
   // eslint-disable-next-line @typescript-eslint/no-unused-vars
   const [errs, setErrs] = useState<Error[] | null>(null);
@@ -46,12 +59,8 @@ const PageDeleteModal: FC = () => {
     setIsDeleteRecursively(!isDeleteRecursively);
   }
 
-  useEffect(() => {
-    setIsDeleteCompletely(isDeleteCompletelyModal && isAbleToDeleteCompletely);
-  }, [isAbleToDeleteCompletely, isDeleteCompletelyModal]);
-
   function changeIsDeleteCompletelyHandler() {
-    if (!isAbleToDeleteCompletely) {
+    if (forceDeleteCompletelyMode) {
       return;
     }
     setIsDeleteCompletely(!isDeleteCompletely);
@@ -79,8 +88,9 @@ const PageDeleteModal: FC = () => {
           isCompletely,
         });
 
-        if (deleteModalData.onDeleted != null) {
-          deleteModalData.onDeleted(data.paths, data.isRecursively, data.isCompletely);
+        const onDeleted = deleteModalData.opts?.onDeleted;
+        if (onDeleted != null) {
+          onDeleted(data.paths, data.isRecursively, data.isCompletely);
         }
       }
       catch (err) {
@@ -93,7 +103,7 @@ const PageDeleteModal: FC = () => {
     else {
       try {
         const recursively = isDeleteRecursively === true ? true : undefined;
-        const completely = isDeleteCompletely === true ? true : undefined;
+        const completely = forceDeleteCompletelyMode || isDeleteCompletely ? true : undefined;
 
         const page = deleteModalData.pages[0];
 
@@ -104,8 +114,9 @@ const PageDeleteModal: FC = () => {
           completely,
         }) as IDeleteSinglePageApiv1Result;
 
-        if (deleteModalData.onDeleted != null) {
-          deleteModalData.onDeleted(path, isRecursively, isCompletely);
+        const onDeleted = deleteModalData.opts?.onDeleted;
+        if (onDeleted != null) {
+          onDeleted(path, isRecursively, isCompletely);
         }
       }
       catch (err) {
@@ -138,11 +149,6 @@ const PageDeleteModal: FC = () => {
     );
   }
 
-  // DeleteCompletely is currently disabled
-  // TODO1 : Retrive isAbleToDeleteCompleltly state everywhere in the system via swr.
-  // Story: https://redmine.weseek.co.jp/issues/82222
-  // TODO2 : use toaster
-  // TASK : https://redmine.weseek.co.jp/issues/82299
   function renderDeleteCompletelyForm() {
     return (
       <div className="custom-control custom-checkbox custom-checkbox-danger">
@@ -190,7 +196,7 @@ const PageDeleteModal: FC = () => {
           {renderPagePathsToDelete()}
         </div>
         {renderDeleteRecursivelyForm()}
-        {!isDeleteCompletelyModal && renderDeleteCompletelyForm()}
+        { !forceDeleteCompletelyMode && renderDeleteCompletelyForm() }
       </ModalBody>
       <ModalFooter>
         <ApiErrorMessageList errs={errs} />

+ 3 - 2
packages/app/src/components/PageEditor/LinkEditModal.jsx

@@ -202,8 +202,9 @@ class LinkEditModal extends React.PureComponent {
   }
 
   handleChangeTypeahead(selected) {
-    const page = selected[0];
-    if (page != null) {
+    const pageWithMeta = selected[0];
+    if (pageWithMeta != null) {
+      const page = pageWithMeta.pageData;
       const permalink = `${window.location.origin}/${page.id}`;
       this.setState({ linkInputValue: page.path, permalink });
     }

+ 30 - 9
packages/app/src/components/PageList/PageListItemL.tsx

@@ -13,7 +13,7 @@ import { UserPicture, PageListMeta } from '@growi/ui';
 import { DevidedPagePath } from '@growi/core';
 import { useIsDeviceSmallerThanLg } from '~/stores/ui';
 import {
-  usePageRenameModal, usePageDuplicateModal, usePageDeleteModal, usePutBackPageMOdal,
+  usePageRenameModal, usePageDuplicateModal, usePageDeleteModal, usePutBackPageModal,
 } from '~/stores/modal';
 import {
   IPageInfoAll, IPageWithMeta, isIPageInfoForEntity, isIPageInfoForListing,
@@ -66,17 +66,18 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
   const { open: openDuplicateModal } = usePageDuplicateModal();
   const { open: openRenameModal } = usePageRenameModal();
   const { open: openDeleteModal } = usePageDeleteModal();
-  const { open: openPutBackPageModal } = usePutBackPageMOdal();
+  const { open: openPutBackPageModal } = usePutBackPageModal();
 
   const elasticSearchResult = isIPageSearchMeta(pageMeta) ? pageMeta.elasticSearchResult : null;
   const revisionShortBody = isIPageInfoForListing(pageMeta) ? pageMeta.revisionShortBody : null;
 
-  const dPagePath: DevidedPagePath = new DevidedPagePath(pageData.path, true);
+  const dPagePath: DevidedPagePath = new DevidedPagePath(elasticSearchResult?.highlightedPath || pageData.path, true);
   const linkedPagePathFormer = new LinkedPagePath(dPagePath.former);
   const linkedPagePathLatter = new LinkedPagePath(dPagePath.latter);
 
   const lastUpdateDate = format(new Date(pageData.updatedAt), 'yyyy/MM/dd HH:mm:ss');
 
+
   // click event handler
   const clickHandler = useCallback(() => {
     // do nothing if mobile
@@ -99,10 +100,15 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
     openRenameModal(pageId, revisionId as string, path);
   }, [openRenameModal, pageData]);
 
-  const deleteMenuItemClickHandler = useCallback(() => {
+
+  const deleteMenuItemClickHandler = useCallback((_id, pageInfo) => {
     const { _id: pageId, revision: revisionId, path } = pageData;
-    openDeleteModal([{ pageId, revisionId: revisionId as string, path }]);
-  }, [openDeleteModal, pageData]);
+    const isAbleToDeleteCompletely = pageInfo.isAbleToDeleteCompletely;
+    const pageToDelete = {
+      pageId, revisionId: revisionId as string, path, isAbleToDeleteCompletely,
+    };
+    openDeleteModal([pageToDelete]);
+  }, [pageData, openDeleteModal]);
 
   const revertMenuItemClickHandler = useCallback(() => {
     const { _id: pageId, path } = pageData;
@@ -113,6 +119,8 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
   // background color of list item changes when class "active" exists under 'list-group-item'
   const styleActive = !isDeviceSmallerThanLg && isSelected ? 'active' : '';
 
+  const shouldDangerouslySetInnerHTMLForPaths = elasticSearchResult != null && elasticSearchResult.highlightedPath.length > 0;
+
   return (
     <li
       key={pageData._id}
@@ -139,7 +147,10 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
           <div className="flex-grow-1 p-md-3 pl-2 py-3 pr-3">
             <div className="d-flex justify-content-between">
               {/* page path */}
-              <PagePathHierarchicalLink linkedPagePath={linkedPagePathFormer} />
+              <PagePathHierarchicalLink
+                linkedPagePath={linkedPagePathFormer}
+                shouldDangerouslySetInnerHTML={shouldDangerouslySetInnerHTMLForPaths}
+              />
               { showPageUpdatedTime && (
                 <span className="page-list-updated-at text-muted">Last update: {lastUpdateDate}</span>
               ) }
@@ -154,7 +165,17 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
                 <span className="h5 mb-0">
                   {/* 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>
+                    {shouldDangerouslySetInnerHTMLForPaths
+                      ? (
+                        <a
+                          className="page-segment"
+                          href={encodeURI(urljoin('/', pageData._id))}
+                          dangerouslySetInnerHTML={{ __html: linkedPagePathLatter.pathName }}
+                        >
+                        </a>
+                      )
+                      : <a className="page-segment" href={encodeURI(urljoin('/', pageData._id))}>{linkedPagePathLatter.pathName}</a>
+                    }
                   </span>
                 </span>
               </Clamp>
@@ -173,9 +194,9 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
                   pageInfo={pageMeta}
                   isEnableActions={isEnableActions}
                   forceHideMenuItems={forceHideMenuItems}
-                  onClickDeleteMenuItem={deleteMenuItemClickHandler}
                   onClickRenameMenuItem={renameMenuItemClickHandler}
                   onClickDuplicateMenuItem={duplicateMenuItemClickHandler}
+                  onClickDeleteMenuItem={deleteMenuItemClickHandler}
                   onClickRevertMenuItem={revertMenuItemClickHandler}
                 />
               </div>

+ 17 - 4
packages/app/src/components/PagePathHierarchicalLink.jsx

@@ -7,8 +7,9 @@ import LinkedPagePath from '../models/linked-page-path';
 
 
 const PagePathHierarchicalLink = (props) => {
-  const { linkedPagePath, basePath, isInTrash } = props;
-
+  const {
+    linkedPagePath, basePath, isInTrash, shouldDangerouslySetInnerHTML,
+  } = props;
   // render root element
   if (linkedPagePath.isRoot) {
     if (basePath != null) {
@@ -52,13 +53,24 @@ const PagePathHierarchicalLink = (props) => {
   return (
     <RootElm>
       { isParentExists && (
-        <PagePathHierarchicalLink linkedPagePath={linkedPagePath.parent} basePath={basePath} isInTrash={isInTrash || linkedPagePath.isInTrash} isInnerElem />
+        <PagePathHierarchicalLink
+          linkedPagePath={linkedPagePath.parent}
+          basePath={basePath}
+          isInTrash={isInTrash || linkedPagePath.isInTrash}
+          isInnerElem
+          shouldDangerouslySetInnerHTML={shouldDangerouslySetInnerHTML}
+        />
       ) }
       { isSeparatorRequired && (
         <span className="separator">/</span>
       ) }
 
-      <a className="page-segment" href={href}>{linkedPagePath.pathName}</a>
+      {
+        shouldDangerouslySetInnerHTML
+          ? <a className="page-segment" href={href} dangerouslySetInnerHTML={{ __html: linkedPagePath.pathName }}></a>
+          : <a className="page-segment" href={href}>{linkedPagePath.pathName}</a>
+      }
+
     </RootElm>
   );
 };
@@ -67,6 +79,7 @@ PagePathHierarchicalLink.propTypes = {
   linkedPagePath: PropTypes.instanceOf(LinkedPagePath).isRequired,
   basePath: PropTypes.string,
   isInTrash: PropTypes.bool,
+  shouldDangerouslySetInnerHTML: PropTypes.bool,
 
   // !!INTERNAL USE ONLY!!
   isInnerElem: PropTypes.bool,

+ 6 - 3
packages/app/src/components/PrivateLegacyPages.tsx

@@ -1,5 +1,5 @@
 import React, {
-  useCallback, useMemo, useRef, useState,
+  useCallback, useEffect, useMemo, useRef, useState,
 } from 'react';
 import { useTranslation } from 'react-i18next';
 
@@ -214,16 +214,19 @@ export const PrivateLegacyPages = (props: Props): JSX.Element => {
     });
   }, [configurationsByPagination]);
 
+  const hitsCount = data?.meta.hitsCount;
   const { offset, limit } = conditions;
 
   const searchControl = useMemo(() => {
+    const isCheckboxDisabled = hitsCount === 0;
+
     return (
       <div className="shadow-sm">
         <div className="search-control d-flex align-items-center py-md-2 py-3 px-md-4 px-3 border-bottom border-gray">
           <div className="d-flex pl-md-2">
             <OperateAllControl
               ref={selectAllControlRef}
-              isCheckboxDisabled={!isControlEnabled}
+              isCheckboxDisabled={isCheckboxDisabled}
               onCheckboxChanged={selectAllCheckboxChangedHandler}
             >
               <UncontrolledButtonDropdown>
@@ -248,7 +251,7 @@ export const PrivateLegacyPages = (props: Props): JSX.Element => {
         </div>
       </div>
     );
-  }, [convertMenuItemClickedHandler, isControlEnabled, selectAllCheckboxChangedHandler, t]);
+  }, [convertMenuItemClickedHandler, hitsCount, isControlEnabled, selectAllCheckboxChangedHandler, t]);
 
   const searchResultListHead = useMemo(() => {
     if (data == null) {

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

@@ -7,7 +7,7 @@ import {
 
 import { withTranslation } from 'react-i18next';
 
-import { usePutBackPageMOdal } from '~/stores/modal';
+import { usePutBackPageModal } from '~/stores/modal';
 import { apiPost } from '~/client/util/apiv1-client';
 
 import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
@@ -17,7 +17,7 @@ const PutBackPageModal = (props) => {
     t,
   } = props;
 
-  const { data: pageDataToRevert, close: closePutBackPageModal } = usePutBackPageMOdal();
+  const { data: pageDataToRevert, close: closePutBackPageModal } = usePutBackPageModal();
   const { isOpened, pageId, path } = pageDataToRevert;
 
   const [errs, setErrs] = useState(null);

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

@@ -18,7 +18,7 @@ import { SubNavButtons } from '../Navbar/SubNavButtons';
 import { AdditionalMenuItemsRendererProps, ForceHideMenuItems } from '../Common/Dropdown/PageItemControl';
 
 import {
-  usePageDuplicateModal, usePageRenameModal, usePageDeleteModal, OnDeletedFunction,
+  usePageDuplicateModal, usePageRenameModal, usePageDeleteModal,
 } from '~/stores/modal';
 
 
@@ -115,16 +115,9 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
     openRenameModal(pageId, revisionId, path);
   }, [openRenameModal]);
 
-  const onDeletedHandler: OnDeletedFunction = useCallback((pathOrPathsToDelete, isRecursively, isCompletely) => {
-    if (typeof pathOrPathsToDelete !== 'string') {
-      return;
-    }
-    window.location.reload();
-  }, []);
-
-  const deleteItemClickedHandler = useCallback(async(pageToDelete, isAbleToDeleteCompletely) => {
-    openDeleteModal([pageToDelete], onDeletedHandler, isAbleToDeleteCompletely);
-  }, [onDeletedHandler, openDeleteModal]);
+  const deleteItemClickedHandler = useCallback((pageToDelete) => {
+    openDeleteModal([pageToDelete]);
+  }, [openDeleteModal]);
 
   const ControlComponents = useCallback(() => {
     if (page == null) {
@@ -155,7 +148,7 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
         </div>
       </>
     );
-  }, [page, showPageControlDropdown, duplicateItemClickedHandler, renameItemClickedHandler, deleteItemClickedHandler]);
+  }, [page, showPageControlDropdown, forceHideMenuItems, duplicateItemClickedHandler, renameItemClickedHandler, deleteItemClickedHandler]);
 
   // return if page is null
   if (page == null) return <></>;

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

@@ -177,7 +177,7 @@ const SearchTypeahead: ForwardRefRenderFunction<IFocusable, Props> = (props: Pro
       return emptyLabel;
     }
 
-    return false;
+    return <></>;
   };
 
   const defaultSelected = (keywordOnInit !== '')

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

@@ -13,9 +13,7 @@ import { pathUtils, pagePathUtils } from '@growi/core';
 import { toastWarning, toastError, toastSuccess } from '~/client/util/apiNotification';
 
 import { useSWRxPageChildren } from '~/stores/page-listing';
-import { useSWRxPageInfo } from '~/stores/page';
 import { apiv3Put, apiv3Post } from '~/client/util/apiv3-client';
-import { useShareLinkId } from '~/stores/context';
 import { IPageForPageDeleteModal } from '~/stores/modal';
 
 import TriangleIcon from '~/components/Icons/TriangleIcon';
@@ -32,7 +30,7 @@ interface ItemProps {
   isEnabledAttachTitleHeader?: boolean
   onClickDuplicateMenuItem?(pageId: string, path: string): void
   onClickRenameMenuItem?(pageId: string, revisionId: string, path: string): void
-  onClickDeleteMenuItem?(pageToDelete: IPageForPageDeleteModal | null, isAbleToDeleteCompletely: boolean, callback?: VoidFunction): void
+  onClickDeleteMenuItem?(pageToDelete: IPageForPageDeleteModal, callback?: VoidFunction): void
   onSelfDeleted?: VoidFunction
 }
 
@@ -81,8 +79,6 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
   const { page, children } = itemNode;
 
   const [pageTitle, setPageTitle] = useState(page.path);
-  const { data: shareLinkId } = useShareLinkId();
-  const { data: pageInfo } = useSWRxPageInfo(page._id ?? null, shareLinkId);
   const [currentChildren, setCurrentChildren] = useState(children);
   const [isOpen, setIsOpen] = useState(_isOpen);
   const [isNewPageInputShown, setNewPageInputShown] = useState(false);
@@ -245,7 +241,7 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
     onClickRenameMenuItem(pageId, revisionId as string, path);
   }, [onClickRenameMenuItem, page]);
 
-  const deleteMenuItemClickHandler = useCallback(async(_pageId: string): Promise<void> => {
+  const deleteMenuItemClickHandler = useCallback(async(_pageId: string, pageInfo): Promise<void> => {
     if (onClickDeleteMenuItem == null) {
       return;
     }
@@ -260,13 +256,13 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
       pageId,
       revisionId: revisionId as string,
       path,
+      isAbleToDeleteCompletely: pageInfo?.isAbleToDeleteCompletely,
     };
-    const isAbleToDeleteCompletely = pageInfo?.isAbleToDeleteCompletely ?? false;
 
-    onClickDeleteMenuItem(pageToDelete, isAbleToDeleteCompletely, async() => {
+    onClickDeleteMenuItem(pageToDelete, async() => {
       if (onSelfDeleted != null) await onSelfDeleted();
     });
-  }, [onClickDeleteMenuItem, page, pageInfo?.isAbleToDeleteCompletely, onSelfDeleted]);
+  }, [onClickDeleteMenuItem, page, onSelfDeleted]);
 
   const onPressEnterForCreateHandler = async(inputText: string) => {
     setNewPageInputShown(false);
@@ -390,8 +386,8 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
             isEnableActions={isEnableActions}
             onClickBookmarkMenuItem={bookmarkMenuItemClickHandler}
             onClickDuplicateMenuItem={duplicateMenuItemClickHandler}
-            onClickDeleteMenuItem={deleteMenuItemClickHandler}
             onClickRenameMenuItem={renameMenuItemClickHandler}
+            onClickDeleteMenuItem={deleteMenuItemClickHandler}
           >
             <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>

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

@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
 import { IPageHasId } from '../../../interfaces/page';
 import { ItemNode } from './ItemNode';
 import Item from './Item';
-import { useSWRxPageAncestorsChildren, useSWRxPageChildren, useSWRxRootPage } from '~/stores/page-listing';
+import { useSWRxPageAncestorsChildren, useSWRxRootPage } from '~/stores/page-listing';
 import { TargetAndAncestors } from '~/interfaces/page-listing-results';
 import { toastError, toastSuccess } from '~/client/util/apiNotification';
 import {
@@ -67,7 +67,7 @@ const renderByInitialNode = (
     isEnabledAttachTitleHeader?: boolean,
     onClickDuplicateMenuItem?: (pageId: string, path: string) => void,
     onClickRenameMenuItem?: (pageId: string, revisionId: string, path: string) => void,
-    onClickDeleteMenuItem?: (pageToDelete: IPageForPageDeleteModal | null, isAbleToDeleteCompletely: boolean, onItemDeleted: VoidFunction) => void,
+    onClickDeleteMenuItem?: (pageToDelete: IPageForPageDeleteModal, onItemDeleted: VoidFunction) => void,
 ): JSX.Element => {
 
   return (
@@ -122,7 +122,7 @@ const ItemsTree: FC<ItemsTreeProps> = (props: ItemsTreeProps) => {
     openRenameModal(pageId, revisionId, path);
   };
 
-  const onClickDeleteMenuItem = (pageToDelete: IPageForPageDeleteModal, isAbleToDeleteCompletely, onItemDeleted: VoidFunction) => {
+  const onClickDeleteMenuItem = (pageToDelete: IPageForPageDeleteModal, onItemDeleted: VoidFunction) => {
     const onDeletedHandler: OnDeletedFunction = (pathOrPathsToDelete, isRecursively, isCompletely) => {
       if (typeof pathOrPathsToDelete !== 'string') {
         return;
@@ -140,7 +140,7 @@ const ItemsTree: FC<ItemsTreeProps> = (props: ItemsTreeProps) => {
       }
     };
 
-    openDeleteModal([pageToDelete], onDeletedHandler, isAbleToDeleteCompletely);
+    openDeleteModal([pageToDelete], { onDeleted: onDeletedHandler });
   };
 
   if (error1 != null || error2 != null) {

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

@@ -15,7 +15,7 @@ const differenceInYears = require('date-fns/differenceInYears');
 const { pathUtils } = require('@growi/core');
 const escapeStringRegexp = require('escape-string-regexp');
 
-const { isTopPage, isTrashPage, isUserNamePage } = pagePathUtils;
+const { isTopPage, isTrashPage } = pagePathUtils;
 const { checkTemplatePath } = templateChecker;
 
 const logger = loggerFactory('growi:models:page');
@@ -612,10 +612,6 @@ export const getPageSchema = (crowi) => {
     return path.replace('/trash', '');
   };
 
-  pageSchema.statics.isDeletableName = function(path) {
-    return !isTopPage(path) && !isUserNamePage(path);
-  };
-
   pageSchema.statics.fixToCreatableName = function(path) {
     return path
       .replace(/\/\//g, '/');

+ 1 - 1
packages/app/src/server/models/subscription.ts

@@ -22,7 +22,7 @@ export interface ISubscription {
 export interface SubscriptionDocument extends ISubscription, Document {}
 
 export interface SubscriptionModel extends Model<SubscriptionDocument> {
-  findByUserIdAndTargetId(userId: Types.ObjectId, targetId: Types.ObjectId): any
+  findByUserIdAndTargetId(userId: Types.ObjectId | string, targetId: Types.ObjectId | string): any
   upsertSubscription(user: Types.ObjectId, targetModel: string, target: Types.ObjectId, status: string): any
   subscribeByPageId(user: Types.ObjectId, pageId: Types.ObjectId, status: string): any
   getSubscription(target: Types.ObjectId): Promise<Types.ObjectId[]>

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

@@ -239,33 +239,36 @@ module.exports = (crowi) => {
       return res.apiv3Err(new ErrorV3('Parameter path or pageId is required.', 'invalid-request'));
     }
 
-    let result = {};
+    let page;
     try {
-      result = await pageService.findPageAndMetaDataByViewer({ pageId, path, user: req.user });
+      if (pageId != null) { // prioritized
+        page = await Page.findByIdAndViewer(pageId, req.user);
+      }
+      else {
+        page = await Page.findByPathAndViewer(path, req.user);
+      }
     }
     catch (err) {
       logger.error('get-page-failed', err);
       return res.apiv3Err(err, 500);
     }
 
-    const page = result.page;
-
     if (page == null) {
-      return res.apiv3(result);
+      return res.apiv3Err('Page is not found', 404);
     }
 
     try {
       page.initLatestRevisionField();
 
       // populate
-      result.page = await page.populateDataToShowRevision();
+      page = await page.populateDataToShowRevision();
     }
     catch (err) {
       logger.error('populate-page-failed', err);
       return res.apiv3Err(err, 500);
     }
 
-    return res.apiv3(result);
+    return res.apiv3({ page });
   });
 
   /**
@@ -360,51 +363,13 @@ module.exports = (crowi) => {
     const { pageId } = req.query;
 
     try {
-      const page = await Page.findByIdAndViewer(pageId, user, null, true);
+      const pageWithMeta = await pageService.findPageAndMetaDataByViewer(pageId, null, user, isSharedPage);
 
-      if (page == null) {
+      if (pageWithMeta == null) {
         return res.apiv3Err(`Page '${pageId}' is not found or forbidden`);
       }
 
-      if (isSharedPage) {
-        return {
-          isEmpty: page.isEmpty,
-          isMovable: false,
-          isDeletable: false,
-          isAbleToDeleteCompletely: false,
-          isRevertible: false,
-        };
-      }
-
-      const isGuestUser = !req.user;
-      const pageInfo = pageService.constructBasicPageInfo(page, isGuestUser);
-
-      const bookmarkCount = await Bookmark.countByPageId(pageId);
-
-      const responseBodyForGuest = {
-        ...pageInfo,
-        bookmarkCount,
-      };
-
-      if (isGuestUser) {
-        return res.apiv3(responseBodyForGuest);
-      }
-
-      const isBookmarked = await Bookmark.findByPageIdAndUserId(pageId, user._id);
-      const isLiked = page.isLiked(user);
-      const isAbleToDeleteCompletely = pageService.canDeleteCompletely(page.creator?._id, user);
-
-      const subscription = await Subscription.findByUserIdAndTargetId(user._id, pageId);
-
-      const responseBody = {
-        ...responseBodyForGuest,
-        isAbleToDeleteCompletely,
-        isBookmarked,
-        isLiked,
-        subscriptionStatus: subscription?.status,
-      };
-
-      return res.apiv3(responseBody);
+      return res.apiv3(pageWithMeta.pageMeta);
     }
     catch (err) {
       logger.error('get-page-info', err);

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

@@ -6,9 +6,8 @@ import mongoose from 'mongoose';
 import loggerFactory from '~/utils/logger';
 import { PageQueryBuilder } from '../models/obsolete-page';
 import UpdatePost from '../models/update-post';
-import { PageRedirectModel } from '../models/page-redirect';
 
-const { isCreatablePage, isTopPage } = pagePathUtils;
+const { isCreatablePage, isTopPage, isUsersHomePage } = pagePathUtils;
 const { serializePageSecurely } = require('../models/serializers/page-serializer');
 const { serializeRevisionSecurely } = require('../models/serializers/revision-serializer');
 const { serializeUserSecurely } = require('../models/serializers/user-serializer');
@@ -142,7 +141,6 @@ module.exports = function(crowi, app) {
 
   const Page = crowi.model('Page');
   const User = crowi.model('User');
-  const Bookmark = crowi.model('Bookmark');
   const PageTagRelation = crowi.model('PageTagRelation');
   const GlobalNotificationSetting = crowi.model('GlobalNotificationSetting');
   const ShareLink = crowi.model('ShareLink');
@@ -173,14 +171,6 @@ module.exports = function(crowi, app) {
     return pathUtils.normalizePath(req.pagePath || req.params[0] || '');
   }
 
-  function isUserPage(path) {
-    if (path.match(/^\/user\/[^/]+\/?$/)) {
-      return true;
-    }
-
-    return false;
-  }
-
   function generatePager(offset, limit, totalCount) {
     let prev = null;
 
@@ -450,7 +440,7 @@ module.exports = function(crowi, app) {
     const sharelinksNumber = await ShareLink.countDocuments({ relatedPage: page._id });
     renderVars.sharelinksNumber = sharelinksNumber;
 
-    if (isUserPage(path)) {
+    if (isUsersHomePage(path)) {
       // change template
       view = 'layout-growi/user_page';
       await addRenderVarsForUserPage(renderVars, page);

+ 78 - 36
packages/app/src/server/service/page.ts

@@ -5,28 +5,29 @@ import streamToPromise from 'stream-to-promise';
 import pathlib from 'path';
 import { Readable, Writable } from 'stream';
 
-import { serializePageSecurely } from '../models/serializers/page-serializer';
+import { HasObjectId } from '~/interfaces/has-object-id';
+import { Ref } from '~/interfaces/common';
 import { createBatchStream } from '~/server/util/batch-stream';
 import loggerFactory from '~/utils/logger';
 import {
   CreateMethod, generateGrantCondition, PageCreateOptions, PageDocument, PageModel,
 } from '~/server/models/page';
 import { stringifySnapshot } from '~/models/serializers/in-app-notification-snapshot/page';
-import ActivityDefine from '../util/activityDefine';
 import {
-  IPage, IPageInfo, IPageInfoForEntity,
+  IPage, IPageInfo, IPageInfoForEntity, IPageWithMeta,
 } from '~/interfaces/page';
+import { serializePageSecurely } from '../models/serializers/page-serializer';
 import { PageRedirectModel } from '../models/page-redirect';
+import Subscription from '../models/subscription';
 import { ObjectIdLike } from '../interfaces/mongoose-utils';
 import { IUserHasId } from '~/interfaces/user';
-import { Ref } from '~/interfaces/common';
-import { HasObjectId } from '~/interfaces/has-object-id';
+import ActivityDefine from '../util/activityDefine';
 
 const debug = require('debug')('growi:services:page');
 
 const logger = loggerFactory('growi:services:page');
 const {
-  isCreatablePage, isTrashPage, isTopPage, isDeletablePage, omitDuplicateAreaPathFromPaths, omitDuplicateAreaPageFromPages, isUserPage, isUserNamePage,
+  isTrashPage, isTopPage, omitDuplicateAreaPageFromPages, isMovablePage,
 } = pagePathUtils;
 
 const BULK_REINDEX_SIZE = 100;
@@ -213,41 +214,70 @@ class PageService {
     return pages.filter(p => p.isEmpty || this.canDeleteCompletely(p.creator, user));
   }
 
-  async findPageAndMetaDataByViewer({ pageId, path, user }) {
+  // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+  async findPageAndMetaDataByViewer(pageId: string, path: string, user: IUserHasId, isSharedPage = false): Promise<IPageWithMeta|null> {
 
     const Page = this.crowi.model('Page');
 
-    let pagePath = path;
-
-    let page;
+    let page: PageModel & PageDocument & HasObjectId;
     if (pageId != null) { // prioritized
       page = await Page.findByIdAndViewer(pageId, user);
-      pagePath = page.path;
     }
     else {
-      page = await Page.findByPathAndViewer(pagePath, user);
+      page = await Page.findByPathAndViewer(path, user);
     }
 
-    const result: any = {};
-
     if (page == null) {
-      const isExist = await Page.count({ $or: [{ _id: pageId }, { pat: pagePath }] }) > 0;
-      result.isForbidden = isExist;
-      result.isNotFound = !isExist;
-      result.isCreatable = isCreatablePage(pagePath);
-      result.page = page;
+      return null;
+    }
 
-      return result;
+    if (isSharedPage) {
+      return {
+        pageData: page,
+        pageMeta: {
+          isEmpty: page.isEmpty,
+          isMovable: false,
+          isDeletable: false,
+          isAbleToDeleteCompletely: false,
+          isRevertible: false,
+        },
+      };
     }
 
-    result.page = page;
-    result.isForbidden = false;
-    result.isNotFound = false;
-    result.isCreatable = false;
-    result.isDeletable = isDeletablePage(pagePath);
-    result.isDeleted = page.isDeleted();
+    const isGuestUser = user == null;
+    const pageInfo = this.constructBasicPageInfo(page, isGuestUser);
 
-    return result;
+    const Bookmark = this.crowi.model('Bookmark');
+    const bookmarkCount = await Bookmark.countByPageId(pageId);
+
+    const metadataForGuest = {
+      ...pageInfo,
+      bookmarkCount,
+    };
+
+    if (isGuestUser) {
+      return {
+        pageData: page,
+        pageMeta: metadataForGuest,
+      };
+    }
+
+    const isBookmarked = await Bookmark.findByPageIdAndUserId(pageId, user._id);
+    const isLiked = page.isLiked(user);
+    const isAbleToDeleteCompletely = this.canDeleteCompletely((page.creator as IUserHasId)?._id, user);
+
+    const subscription = await Subscription.findByUserIdAndTargetId(user._id, pageId);
+
+    return {
+      pageData: page,
+      pageMeta: {
+        ...metadataForGuest,
+        isAbleToDeleteCompletely,
+        isBookmarked,
+        isLiked,
+        subscriptionStatus: subscription?.status,
+      },
+    };
   }
 
   private shouldUseV4Process(page): boolean {
@@ -1067,7 +1097,7 @@ class PageService {
       throw new Error('This method does NOT support deleting trashed pages.');
     }
 
-    if (!Page.isDeletableName(page.path)) {
+    if (!isMovablePage(page.path)) {
       throw new Error('Page is not deletable.');
     }
 
@@ -1096,7 +1126,14 @@ class PageService {
       }, { new: true });
       await PageTagRelation.updateMany({ relatedPage: page._id }, { $set: { isPageTrashed: true } });
 
-      await PageRedirect.create({ fromPath: page.path, toPath: newPath });
+      try {
+        await PageRedirect.create({ fromPath: page.path, toPath: newPath });
+      }
+      catch (err) {
+        if (err.code !== 11000) {
+          throw err;
+        }
+      }
 
       this.pageEvent.emit('delete', page, user);
       this.pageEvent.emit('create', deletedPage, user);
@@ -1136,7 +1173,7 @@ class PageService {
       throw new Error('This method does NOT support deleting trashed pages.');
     }
 
-    if (!Page.isDeletableName(page.path)) {
+    if (!isMovablePage(page.path)) {
       throw new Error('Page is not deletable.');
     }
 
@@ -1153,7 +1190,14 @@ class PageService {
     }, { new: true });
     await PageTagRelation.updateMany({ relatedPage: page._id }, { $set: { isPageTrashed: true } });
 
-    await PageRedirect.create({ fromPath: page.path, toPath: newPath });
+    try {
+      await PageRedirect.create({ fromPath: page.path, toPath: newPath });
+    }
+    catch (err) {
+      if (err.code !== 11000) {
+        throw err;
+      }
+    }
 
     this.pageEvent.emit('delete', page, user);
     this.pageEvent.emit('create', deletedPage, user);
@@ -1704,7 +1748,7 @@ class PageService {
   }
 
   constructBasicPageInfo(page: IPage, isGuestUser?: boolean): IPageInfo | IPageInfoForEntity {
-    const isMovable = isGuestUser ? false : !isTopPage(page.path) && !isUserPage(page.path) && !isUserNamePage(page.path);
+    const isMovable = isGuestUser ? false : isMovablePage(page.path);
 
     if (page.isEmpty) {
       return {
@@ -1719,8 +1763,6 @@ class PageService {
     const likers = page.liker.slice(0, 15) as Ref<IUserHasId>[];
     const seenUsers = page.seenUsers.slice(0, 15) as Ref<IUserHasId>[];
 
-    const Page = this.crowi.model('Page');
-    const isRevertible = isTrashPage(page.path);
     return {
       isEmpty: false,
       sumOfLikers: page.liker.length,
@@ -1728,9 +1770,9 @@ class PageService {
       seenUserIds: this.extractStringIds(seenUsers),
       sumOfSeenUsers: page.seenUsers.length,
       isMovable,
-      isDeletable: Page.isDeletableName(page.path),
+      isDeletable: isMovable,
       isAbleToDeleteCompletely: false,
-      isRevertible,
+      isRevertible: isTrashPage(page.path),
     };
 
   }

+ 9 - 16
packages/app/src/stores/modal.tsx

@@ -1,7 +1,6 @@
 import { SWRResponse } from 'swr';
 import { useStaticSWR } from './use-static-swr';
 import { Nullable } from '~/interfaces/common';
-import { IPageInfo } from '~/interfaces/page';
 
 
 /*
@@ -36,7 +35,10 @@ export type IPageForPageDeleteModal = {
   revisionId?: string,
   path: string
   isAbleToDeleteCompletely?: boolean,
-  isDeleteCompletelyModal?: boolean,
+}
+
+export type IDeleteModalOption = {
+  onDeleted?: OnDeletedFunction,
 }
 
 export type OnDeletedFunction = (pathOrPaths: string | string[], isRecursively: Nullable<true>, isCompletely: Nullable<true>) => void;
@@ -44,17 +46,13 @@ export type OnDeletedFunction = (pathOrPaths: string | string[], isRecursively:
 type DeleteModalStatus = {
   isOpened: boolean,
   pages?: IPageForPageDeleteModal[],
-  onDeleted?: OnDeletedFunction,
-  isAbleToDeleteCompletely?: boolean,
-  isDeleteCompletelyModal?: boolean,
+  opts?: IDeleteModalOption,
 }
 
 type DeleteModalStatusUtils = {
   open(
     pages?: IPageForPageDeleteModal[],
-    onDeleted?: OnDeletedFunction,
-    isAbleToDeleteCompletely?: boolean,
-    isDeleteCompletelyModal?: boolean,
+    opts?: IDeleteModalOption,
   ): Promise<DeleteModalStatus | undefined>,
   close(): Promise<DeleteModalStatus | undefined>,
 }
@@ -63,9 +61,6 @@ export const usePageDeleteModal = (status?: DeleteModalStatus): SWRResponse<Dele
   const initialData: DeleteModalStatus = {
     isOpened: false,
     pages: [],
-    onDeleted: () => {},
-    isAbleToDeleteCompletely: false,
-    isDeleteCompletelyModal: false,
   };
   const swrResponse = useStaticSWR<DeleteModalStatus, Error>('deleteModalStatus', status, { fallbackData: initialData });
 
@@ -73,11 +68,9 @@ export const usePageDeleteModal = (status?: DeleteModalStatus): SWRResponse<Dele
     ...swrResponse,
     open: (
         pages?: IPageForPageDeleteModal[],
-        onDeleted?: OnDeletedFunction,
-        isAbleToDeleteCompletely?: boolean,
-        isDeleteCompletelyModal?: boolean,
+        opts?: IDeleteModalOption,
     ) => swrResponse.mutate({
-      isOpened: true, pages, onDeleted, isAbleToDeleteCompletely, isDeleteCompletelyModal,
+      isOpened: true, pages, opts,
     }),
     close: () => swrResponse.mutate({ isOpened: false }),
   };
@@ -161,7 +154,7 @@ type PutBackPageModalUtils = {
   close():Promise<PutBackPageModalStatus | undefined>
 }
 
-export const usePutBackPageMOdal = (status?: PutBackPageModalStatus): SWRResponse<PutBackPageModalStatus, Error> & PutBackPageModalUtils => {
+export const usePutBackPageModal = (status?: PutBackPageModalStatus): SWRResponse<PutBackPageModalStatus, Error> & PutBackPageModalUtils => {
   const initialData = { isOpened: false, pageId: '', path: '' };
   const swrResponse = useStaticSWR<PutBackPageModalStatus, Error>('putBackPageModalStatus', status, { fallbackData: initialData });
 

+ 0 - 11
packages/app/test/integration/models/page.test.js

@@ -156,17 +156,6 @@ describe('Page', () => {
     });
   });
 
-  describe('.isDeletableName', () => {
-    test('should decide deletable or not', () => {
-      expect(Page.isDeletableName('/')).toBeFalsy();
-      expect(Page.isDeletableName('/hoge')).toBeTruthy();
-      expect(Page.isDeletableName('/user/xxx')).toBeFalsy();
-      expect(Page.isDeletableName('/user/xxx123')).toBeFalsy();
-      expect(Page.isDeletableName('/user/xxx/')).toBeTruthy();
-      expect(Page.isDeletableName('/user/xxx/hoge')).toBeTruthy();
-    });
-  });
-
   describe('.isAccessiblePageByViewer', () => {
     describe('with a granted page', () => {
       test('should return true with granted user', async() => {

+ 11 - 1
packages/core/src/test/util/page-path-utils.test.js

@@ -1,5 +1,5 @@
 import {
-  isTopPage, convertToNewAffiliationPath, isCreatablePage, omitDuplicateAreaPathFromPaths,
+  isTopPage, isMovablePage, convertToNewAffiliationPath, isCreatablePage, omitDuplicateAreaPathFromPaths,
 } from '~/utils/page-path-utils';
 
 describe('TopPage Path test', () => {
@@ -21,6 +21,16 @@ describe('TopPage Path test', () => {
   });
 });
 
+describe('isMovablePage test', () => {
+  test('should decide deletable or not', () => {
+    expect(isMovablePage('/')).toBeFalsy();
+    expect(isMovablePage('/hoge')).toBeTruthy();
+    expect(isMovablePage('/user')).toBeFalsy();
+    expect(isMovablePage('/user/xxx')).toBeFalsy();
+    expect(isMovablePage('/user/xxx123')).toBeFalsy();
+    expect(isMovablePage('/user/xxx/hoge')).toBeTruthy();
+  });
+});
 
 describe('convertToNewAffiliationPath test', () => {
   test('Child path is not converted normally', () => {

+ 26 - 23
packages/core/src/utils/page-path-utils.ts

@@ -11,38 +11,48 @@ export const isTopPage = (path: string): boolean => {
 };
 
 /**
- * Whether path belongs to the trash page
+ * Whether path is the top page of users
  * @param path
  */
-export const isTrashPage = (path: string): boolean => {
-  // https://regex101.com/r/BSDdRr/1
-  if (path.match(/^\/trash(\/.*)?$/)) {
+export const isUsersTopPage = (path: string): boolean => {
+  return path === '/user';
+};
+
+/**
+ * Whether path is user's home page
+ * @param path
+ */
+export const isUsersHomePage = (path: string): boolean => {
+  // https://regex101.com/r/utVQct/1
+  if (path.match(/^\/user\/[^/]+$/)) {
     return true;
   }
-
   return false;
 };
 
 /**
- * Whether path belongs to the user page
+ * Whether path is the protected pages for systems
  * @param path
  */
-export const isUserPage = (path: string): boolean => {
-  // https://regex101.com/r/SxPejV/1
-  if (path.match(/^\/user(\/.*)?$/)) {
-    return true;
-  }
+export const isUsersProtectedPages = (path: string): boolean => {
+  return isUsersTopPage(path) || isUsersHomePage(path);
+};
 
-  return false;
+/**
+ * Whether path is movable
+ * @param path
+ */
+export const isMovablePage = (path: string): boolean => {
+  return !isTopPage(path) && !isUsersProtectedPages(path);
 };
 
 /**
- * Whether path is right under the path '/user'
+ * Whether path belongs to the trash page
  * @param path
  */
-export const isUserNamePage = (path: string): boolean => {
-  // https://regex101.com/r/GUZntH/1
-  if (path.match(/^\/user\/[^/]+$/)) {
+export const isTrashPage = (path: string): boolean => {
+  // https://regex101.com/r/BSDdRr/1
+  if (path.match(/^\/trash(\/.*)?$/)) {
     return true;
   }
 
@@ -62,13 +72,6 @@ export const isSharedPage = (path: string): boolean => {
   return false;
 };
 
-const restrictedPatternsToDelete: Array<RegExp> = [
-  /^\/user\/[^/]+$/, // user page
-];
-export const isDeletablePage = (path: string): boolean => {
-  return !restrictedPatternsToDelete.some(pattern => path.match(pattern));
-};
-
 const restrictedPatternsToCreate: Array<RegExp> = [
   /\^|\$|\*|\+|#|%|\?/,
   /^\/-\/.*/,