Sfoglia il codice sorgente

Merge branch 'master' into fix/87892-fix-pt-scroll

Haku Mizuki 4 anni fa
parent
commit
7b3a546708
39 ha cambiato i file con 949 aggiunte e 670 eliminazioni
  1. 1 1
      lerna.json
  2. 1 1
      package.json
  3. 7 7
      packages/app/package.json
  4. 0 2
      packages/app/src/client/app.jsx
  5. 6 6
      packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  6. 7 6
      packages/app/src/components/Navbar/SubNavButtons.tsx
  7. 0 264
      packages/app/src/components/Page/PageManagement.jsx
  8. 10 9
      packages/app/src/components/PageDuplicateModal.jsx
  9. 11 4
      packages/app/src/components/PageList/PageListItemL.tsx
  10. 3 0
      packages/app/src/components/PagePathAutoComplete.jsx
  11. 9 10
      packages/app/src/components/PageRenameModal.jsx
  12. 10 5
      packages/app/src/components/PrivateLegacyPages.tsx
  13. 20 8
      packages/app/src/components/SearchPage.tsx
  14. 4 4
      packages/app/src/components/SearchPage/SearchResultContent.tsx
  15. 62 2
      packages/app/src/components/SearchPage2/SearchPageBase.tsx
  16. 13 5
      packages/app/src/components/Sidebar/PageTree/Item.tsx
  17. 7 7
      packages/app/src/components/Sidebar/PageTree/ItemsTree.tsx
  18. 4 0
      packages/app/src/server/crowi/index.js
  19. 25 0
      packages/app/src/server/interfaces/page-operation.ts
  20. 118 0
      packages/app/src/server/models/page-operation.ts
  21. 0 3
      packages/app/src/server/models/page-redirect.ts
  22. 25 54
      packages/app/src/server/models/page.ts
  23. 1 1
      packages/app/src/server/routes/apiv3/page.js
  24. 6 11
      packages/app/src/server/routes/apiv3/pages.js
  25. 2 9
      packages/app/src/server/service/page-grant.ts
  26. 101 0
      packages/app/src/server/service/page-operation.ts
  27. 415 196
      packages/app/src/server/service/page.ts
  28. 37 14
      packages/app/src/stores/modal.tsx
  29. 1 4
      packages/app/test/integration/service/page.test.js
  30. 10 4
      packages/app/test/integration/service/v5.migration.test.js
  31. 25 25
      packages/app/test/integration/service/v5.page.test.ts
  32. 1 1
      packages/codemirror-textlint/package.json
  33. 1 1
      packages/core/package.json
  34. 1 1
      packages/plugin-attachment-refs/package.json
  35. 1 1
      packages/plugin-lsx/package.json
  36. 1 1
      packages/plugin-pukiwiki-like-linker/package.json
  37. 1 1
      packages/slack/package.json
  38. 1 1
      packages/slackbot-proxy/package.json
  39. 1 1
      packages/ui/package.json

+ 1 - 1
lerna.json

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

+ 1 - 1
package.json

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

+ 7 - 7
packages/app/package.json

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

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

@@ -22,7 +22,6 @@ import PageComments from '../components/PageComments';
 import PageContentFooter from '../components/PageContentFooter';
 import PageTimeline from '../components/PageTimeline';
 import CommentEditorLazyRenderer from '../components/PageComment/CommentEditorLazyRenderer';
-import PageManagement from '../components/Page/PageManagement';
 import ShareLinkAlert from '../components/Page/ShareLinkAlert';
 import DuplicatedAlert from '../components/Page/DuplicatedAlert';
 import RedirectedAlert from '../components/Page/RedirectedAlert';
@@ -127,7 +126,6 @@ if (pageContainer.state.pageId != null) {
   Object.assign(componentMappings, {
     'page-comments-list': <PageComments />,
     'page-comment-write': <CommentEditorLazyRenderer />,
-    'page-management': <PageManagement />,
     'page-content-footer': <PageContentFooter />,
 
     'recent-created-icon': <RecentlyCreatedIcon />,

+ 6 - 6
packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -15,8 +15,8 @@ import {
   useIsAbleToShowPageEditorModeManager, useIsAbleToShowPageAuthors,
 } from '~/stores/ui';
 import {
-  usePageAccessoriesModal, PageAccessoriesModalContents,
-  usePageDuplicateModal, usePageRenameModal, usePageDeleteModal, usePagePresentationModal, IPageForPageDeleteModal,
+  usePageAccessoriesModal, PageAccessoriesModalContents, IPageForPageDuplicateModal,
+  usePageDuplicateModal, usePageRenameModal, IPageForPageRenameModal, usePageDeleteModal, usePagePresentationModal, IPageForPageDeleteModal,
 } from '~/stores/modal';
 
 
@@ -180,12 +180,12 @@ const GrowiContextualSubNavigation = (props) => {
   // eslint-disable-next-line react-hooks/exhaustive-deps
   }, [pageId]);
 
-  const duplicateItemClickedHandler = useCallback(async(pageId, path) => {
-    openDuplicateModal(pageId, path);
+  const duplicateItemClickedHandler = useCallback(async(page: IPageForPageDuplicateModal) => {
+    openDuplicateModal(page);
   }, [openDuplicateModal]);
 
-  const renameItemClickedHandler = useCallback(async(pageId, revisionId, path) => {
-    openRenameModal(pageId, revisionId, path);
+  const renameItemClickedHandler = useCallback(async(page: IPageForPageRenameModal) => {
+    openRenameModal(page);
   }, [openRenameModal]);
 
   const onDeletedHandler: OnDeletedFunction = useCallback((pathOrPathsToDelete, isRecursively, isCompletely) => {

+ 7 - 6
packages/app/src/components/Navbar/SubNavButtons.tsx

@@ -6,7 +6,7 @@ import { useSWRxPageInfo } from '../../stores/page';
 import { useSWRBookmarkInfo } from '../../stores/bookmark';
 import { useSWRxUsersList } from '../../stores/user';
 import { useIsGuestUser } from '~/stores/context';
-import { IPageForPageDeleteModal } from '~/stores/modal';
+import { IPageForPageDeleteModal, IPageForPageRenameModal, IPageForPageDuplicateModal } from '~/stores/modal';
 
 import SubscribeButton from '../SubscribeButton';
 import LikeButtons from '../LikeButtons';
@@ -24,8 +24,8 @@ type CommonProps = {
   showPageControlDropdown?: boolean,
   forceHideMenuItems?: ForceHideMenuItems,
   additionalMenuItemRenderer?: React.FunctionComponent<AdditionalMenuItemsRendererProps>,
-  onClickDuplicateMenuItem?: (pageId: string, path: string) => void,
-  onClickRenameMenuItem?: (pageId: string, revisionId: string, path: string) => void,
+  onClickDuplicateMenuItem?: (pageToDuplicate: IPageForPageDuplicateModal) => void,
+  onClickRenameMenuItem?: (pageToRename: IPageForPageRenameModal) => void,
   onClickDeleteMenuItem?: (pageToDelete: IPageForPageDeleteModal) => void,
 }
 
@@ -100,16 +100,17 @@ const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element
     if (onClickDuplicateMenuItem == null || path == null) {
       return;
     }
+    const page: IPageForPageDuplicateModal = { pageId, path };
 
-    onClickDuplicateMenuItem(pageId, path);
+    onClickDuplicateMenuItem(page);
   }, [onClickDuplicateMenuItem, pageId, path]);
 
   const renameMenuItemClickHandler = useCallback(async(_pageId: string): Promise<void> => {
     if (onClickRenameMenuItem == null || path == null) {
       return;
     }
-
-    onClickRenameMenuItem(pageId, revisionId, path);
+    const page: IPageForPageRenameModal = { pageId, revisionId, path };
+    onClickRenameMenuItem(page);
   }, [onClickRenameMenuItem, pageId, path, revisionId]);
 
   const deleteMenuItemClickHandler = useCallback(async(_pageId: string): Promise<void> => {

+ 0 - 264
packages/app/src/components/Page/PageManagement.jsx

@@ -1,264 +0,0 @@
-import React, { useState } from 'react';
-import PropTypes from 'prop-types';
-import { UncontrolledTooltip } from 'reactstrap';
-import { withTranslation } from 'react-i18next';
-import urljoin from 'url-join';
-
-import { pagePathUtils } from '@growi/core';
-import { usePageDeleteModal } from '~/stores/modal';
-
-import { withUnstatedContainers } from '../UnstatedUtils';
-import AppContainer from '~/client/services/AppContainer';
-import PageRenameModal from '../PageRenameModal';
-import PageDuplicateModal from '../PageDuplicateModal';
-import CreateTemplateModal from '../CreateTemplateModal';
-import PagePresentationModal from '../PagePresentationModal';
-import PresentationIcon from '../Icons/PresentationIcon';
-
-const { isTopPage } = pagePathUtils;
-
-
-const LegacyPageManagemenet = (props) => {
-  const {
-    t, appContainer, isCompactMode, pageId, revisionId, path, isDeletable, isAbleToDeleteCompletely,
-  } = props;
-
-  const { open: openDeleteModal } = usePageDeleteModal();
-
-  const { currentUser } = appContainer;
-  const isTopPagePath = isTopPage(path);
-  const [isPageRenameModalShown, setIsPageRenameModalShown] = useState(false);
-  const [isPageDuplicateModalShown, setIsPageDuplicateModalShown] = useState(false);
-  const [isPageTemplateModalShown, setIsPageTempleteModalShown] = useState(false);
-  const [isPagePresentationModalShown, setIsPagePresentationModalShown] = useState(false);
-  const presentationHref = urljoin(window.location.origin, path, '?presentation=1');
-
-  function openPageRenameModalHandler() {
-    setIsPageRenameModalShown(true);
-  }
-
-  function closePageRenameModalHandler() {
-    setIsPageRenameModalShown(false);
-  }
-
-  function openPageDuplicateModalHandler() {
-    setIsPageDuplicateModalShown(true);
-  }
-  function closePageDuplicateModalHandler() {
-    setIsPageDuplicateModalShown(false);
-  }
-
-  function openPageTemplateModalHandler() {
-    setIsPageTempleteModalShown(true);
-  }
-
-  function closePageTemplateModalHandler() {
-    setIsPageTempleteModalShown(false);
-  }
-
-  function openPagePresentationModalHandler() {
-    setIsPagePresentationModalShown(true);
-  }
-
-  function closePagePresentationModalHandler() {
-    setIsPagePresentationModalShown(false);
-  }
-
-
-  // TODO GW-2746 bulk export pages
-  // async function getArchivePageData() {
-  //   try {
-  //     const res = await appContainer.apiv3Get('page/count-children-pages', { pageId });
-  //     setTotalPages(res.data.dummy);
-  //   }
-  //   catch (err) {
-  //     setErrorMessage(t('export_bulk.failed_to_count_pages'));
-  //   }
-  // }
-
-  async function exportPageHandler(format) {
-    const url = new URL(urljoin(window.location.origin, '_api/v3/page/export', pageId));
-    url.searchParams.append('format', format);
-    url.searchParams.append('revisionId', revisionId);
-    window.location.href = url.href;
-  }
-
-  // TODO GW-2746 create api to bulk export pages
-  // function openArchiveModalHandler() {
-  //   setIsArchiveCreateModalShown(true);
-  //   getArchivePageData();
-  // }
-
-  // TODO GW-2746 create api to bulk export pages
-  // function closeArchiveCreateModalHandler() {
-  //   setIsArchiveCreateModalShown(false);
-  // }
-
-  function renderDropdownItemForTopPage() {
-    return (
-      <>
-        <button className="dropdown-item" type="button" onClick={openPageDuplicateModalHandler}>
-          <i className="icon-fw icon-docs"></i> { t('Duplicate') }
-        </button>
-        {/* TODO Presentation Mode is not function. So if it is really necessary, survey this cause and implement Presentation Mode in top page */}
-        {/* <button className="dropdown-item" type="button" onClick={openPagePresentationModalHandler}>
-          <i className="icon-fw"><PresentationIcon /></i><span className="d-none d-sm-inline"> { t('Presentation Mode') }</span>
-        </button> */}
-        <button type="button" className="dropdown-item" onClick={() => { exportPageHandler('md') }}>
-          <i className="icon-fw icon-cloud-download"></i>{t('export_bulk.export_page_markdown')}
-        </button>
-        <div className="dropdown-divider"></div>
-      </>
-    );
-  }
-
-  function renderDropdownItemForNotTopPage() {
-    return (
-      <>
-        <button className="dropdown-item" type="button" onClick={openPageRenameModalHandler}>
-          <i className="icon-fw icon-action-redo"></i> { t('Move/Rename') }
-        </button>
-        <button className="dropdown-item" type="button" onClick={openPageDuplicateModalHandler}>
-          <i className="icon-fw icon-docs"></i> { t('Duplicate') }
-        </button>
-        <button className="dropdown-item" type="button" onClick={openPagePresentationModalHandler}>
-          <i className="icon-fw"><PresentationIcon /></i> { t('Presentation Mode') }
-        </button>
-        <button className="dropdown-item" type="button" onClick={() => { exportPageHandler('md') }}>
-          <i className="icon-fw icon-cloud-download"></i>{t('export_bulk.export_page_markdown')}
-        </button>
-        {/* TODO GW-2746 create api to bulk export pages */}
-        {/* <button className="dropdown-item" type="button" onClick={openArchiveModalHandler}>
-          <i className="icon-fw"></i>{t('Create Archive Page')}
-        </button> */}
-        <div className="dropdown-divider"></div>
-      </>
-    );
-  }
-
-  function generatePageObjectToDelete() {
-    return { pageId, revisionId, path };
-  }
-  const pageToDelete = generatePageObjectToDelete();
-
-  function renderDropdownItemForDeletablePage() {
-    return (
-      <>
-        <div className="dropdown-divider"></div>
-        <button className="dropdown-item text-danger" type="button" onClick={() => openDeleteModal([pageToDelete])}>
-          <i className="icon-fw icon-fire"></i> { t('Delete') }
-        </button>
-      </>
-    );
-  }
-
-
-  function renderModals() {
-    if (currentUser == null) {
-      return null;
-    }
-
-    return (
-      <>
-        <PageRenameModal
-          isOpen={isPageRenameModalShown}
-          onClose={closePageRenameModalHandler}
-          pageId={pageId}
-          revisionId={revisionId}
-          path={path}
-        />
-        <PageDuplicateModal
-          isOpen={isPageDuplicateModalShown}
-          onClose={closePageDuplicateModalHandler}
-          pageId={pageId}
-          path={path}
-        />
-        <CreateTemplateModal
-          path={path}
-          isOpen={isPageTemplateModalShown}
-          onClose={closePageTemplateModalHandler}
-        />
-        <PagePresentationModal
-          isOpen={isPagePresentationModalShown}
-          onClose={closePagePresentationModalHandler}
-          href={presentationHref}
-        />
-      </>
-    );
-  }
-
-  function renderDotsIconForCurrentUser() {
-    return (
-      <>
-        <button
-          type="button"
-          className="btn-link nav-link dropdown-toggle dropdown-toggle-no-caret border-0 rounded btn-page-item-control"
-          data-toggle="dropdown"
-        >
-          <i className="text-muted icon-options"></i>
-        </button>
-      </>
-    );
-  }
-
-  function renderDotsIconForGuestUser() {
-    return (
-      <>
-        <button
-          type="button"
-          className="btn nav-link bg-transparent dropdown-toggle dropdown-toggle-no-caret disabled"
-          id="icon-options-guest-tltips"
-        >
-          <i className="text-muted icon-options"></i>
-        </button>
-        <UncontrolledTooltip placement="top" target="icon-options-guest-tltips" fade={false}>
-          {t('Not available for guest')}
-        </UncontrolledTooltip>
-      </>
-    );
-  }
-
-
-  return (
-    <>
-      {currentUser == null ? renderDotsIconForGuestUser() : renderDotsIconForCurrentUser()}
-      <div className="dropdown-menu dropdown-menu-right">
-        {isTopPagePath ? renderDropdownItemForTopPage() : renderDropdownItemForNotTopPage()}
-        <button className="dropdown-item" type="button" onClick={openPageTemplateModalHandler}>
-          <i className="icon-fw icon-magic-wand"></i> { t('template.option_label.create/edit') }
-        </button>
-        {(!isTopPagePath && isDeletable) && renderDropdownItemForDeletablePage()}
-      </div>
-      {renderModals()}
-    </>
-  );
-};
-
-/**
- * Wrapper component for using unstated
- */
-const LegacyPageManagemenetWrapper = withUnstatedContainers(LegacyPageManagemenet, [AppContainer]);
-
-
-LegacyPageManagemenet.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-
-
-  pageId: PropTypes.string.isRequired,
-  revisionId: PropTypes.string.isRequired,
-  path: PropTypes.string.isRequired,
-  isDeletable: PropTypes.bool.isRequired,
-  isAbleToDeleteCompletely: PropTypes.bool,
-
-  isCompactMode: PropTypes.bool,
-};
-
-LegacyPageManagemenet.defaultProps = {
-  isCompactMode: false,
-};
-
-const PageManagement = (props) => {
-  return <LegacyPageManagemenetWrapper {...props}></LegacyPageManagemenetWrapper>;
-};
-export default withTranslation()(PageManagement);

+ 10 - 9
packages/app/src/components/PageDuplicateModal.jsx

@@ -27,9 +27,10 @@ const PageDuplicateModal = (props) => {
   const config = appContainer.getConfig();
   const isReachable = config.isSearchServiceReachable;
   const { crowi } = appContainer.config;
-  const { data: pagesDataToDuplicate, close: closeDuplicateModal } = usePageDuplicateModal();
+  const { data: duplicateModalData, close: closeDuplicateModal } = usePageDuplicateModal();
 
-  const { isOpened, path, pageId } = pagesDataToDuplicate;
+  const { isOpened, page } = duplicateModalData;
+  const { pageId, path } = page;
 
   const [pageNameInput, setPageNameInput] = useState(path);
 
@@ -40,7 +41,7 @@ const PageDuplicateModal = (props) => {
   const [isDuplicateRecursivelyWithoutExistPath, setIsDuplicateRecursivelyWithoutExistPath] = useState(true);
   const [existingPaths, setExistingPaths] = useState([]);
 
-  const checkExistPaths = async(newParentPath) => {
+  const checkExistPaths = useCallback(async(newParentPath) => {
     try {
       const res = await appContainer.apiv3Get('/page/exist-paths', { fromPath: path, toPath: newParentPath });
       const { existPaths } = res.data;
@@ -50,15 +51,15 @@ const PageDuplicateModal = (props) => {
       setErrs(err);
       toastError(t('modal_rename.label.Fail to get exist path'));
     }
-  };
+  }, [appContainer, path, t]);
 
-  // eslint-disable-next-line react-hooks/exhaustive-deps
-  const checkExistPathsDebounce = useCallback(
-    debounce(1000, checkExistPaths), [pageId, path],
-  );
+
+  const checkExistPathsDebounce = useCallback(() => {
+    debounce(1000, checkExistPaths);
+  }, [checkExistPaths]);
 
   useEffect(() => {
-    if (pageId != null && pageNameInput !== path) {
+    if (pageId != null && path != null && pageNameInput !== path) {
       checkExistPathsDebounce(pageNameInput, subordinatedPages);
     }
   }, [pageNameInput, subordinatedPages, path, pageId, checkExistPathsDebounce]);

+ 11 - 4
packages/app/src/components/PageList/PageListItemL.tsx

@@ -93,13 +93,20 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
   }, [isDeviceSmallerThanLg, onClickItem, pageData._id]);
 
   const duplicateMenuItemClickHandler = useCallback(() => {
-    const { _id: pageId, path } = pageData;
-    openDuplicateModal(pageId, path);
+    const page = {
+      pageId: pageData._id,
+      path: pageData.path,
+    };
+    openDuplicateModal(page);
   }, [openDuplicateModal, pageData]);
 
   const renameMenuItemClickHandler = useCallback(() => {
-    const { _id: pageId, revision: revisionId, path } = pageData;
-    openRenameModal(pageId, revisionId as string, path);
+    const page = {
+      pageId: pageData._id,
+      revisionId: pageData.revision as string,
+      path: pageData.path,
+    };
+    openRenameModal(page);
   }, [openRenameModal, pageData]);
 
 

+ 3 - 0
packages/app/src/components/PagePathAutoComplete.jsx

@@ -30,6 +30,9 @@ const PagePathAutoComplete = (props) => {
   }
 
   function getKeywordOnInit(path) {
+    if (path == null) {
+      return;
+    }
     return addTrailingSlash
       ? pathUtils.addTrailingSlash(path)
       : pathUtils.removeTrailingSlash(path);

+ 9 - 10
packages/app/src/components/PageRenameModal.jsx

@@ -29,11 +29,10 @@ const PageRenameModal = (props) => {
   } = props;
 
   const { crowi } = appContainer.config;
-  const { data: pagesDataToRename, close: closeRenameModal } = usePageRenameModal();
+  const { data: renameModalData, close: closeRenameModal } = usePageRenameModal();
 
-  const {
-    isOpened, path, revisionId, pageId,
-  } = pagesDataToRename;
+  const { isOpened, page } = renameModalData;
+  const { pageId, revisionId, path } = page;
 
   const [pageNameInput, setPageNameInput] = useState('');
 
@@ -83,7 +82,7 @@ const PageRenameModal = (props) => {
   }, [isOpened, path, updateSubordinatedList]);
 
 
-  const checkExistPaths = async(newParentPath) => {
+  const checkExistPaths = useCallback(async(newParentPath) => {
     try {
       const res = await apiv3Get('/page/exist-paths', { fromPath: path, toPath: newParentPath });
       const { existPaths } = res.data;
@@ -93,15 +92,15 @@ const PageRenameModal = (props) => {
       setErrs(err);
       toastError(t('modal_rename.label.Fail to get exist path'));
     }
-  };
+  }, [path, t]);
 
   // eslint-disable-next-line react-hooks/exhaustive-deps
-  const checkExistPathsDebounce = useCallback(
-    debounce(1000, checkExistPaths), [path],
-  );
+  const checkExistPathsDebounce = useCallback(() => {
+    debounce(1000, checkExistPaths);
+  }, [checkExistPaths]);
 
   useEffect(() => {
-    if (pageId != null && pageNameInput !== path) {
+    if (pageId != null && path != null && pageNameInput !== path) {
       checkExistPathsDebounce(pageNameInput, subordinatedPages);
     }
   }, [pageNameInput, subordinatedPages, pageId, path, checkExistPathsDebounce]);

+ 10 - 5
packages/app/src/components/PrivateLegacyPages.tsx

@@ -1,5 +1,5 @@
 import React, {
-  useCallback, useEffect, useMemo, useRef, useState,
+  useCallback, useMemo, useRef, useState,
 } from 'react';
 import { useTranslation } from 'react-i18next';
 
@@ -14,12 +14,14 @@ import { toastSuccess } from '~/client/util/apiNotification';
 import {
   ISearchConfigurations, useSWRxNamedQuerySearch,
 } from '~/stores/search';
-import { ILegacyPrivatePage, useLegacyPrivatePagesMigrationModal } from '~/stores/modal';
+import {
+  ILegacyPrivatePage, useLegacyPrivatePagesMigrationModal,
+} from '~/stores/modal';
 
 import PaginationWrapper from './PaginationWrapper';
 import { OperateAllControl } from './SearchPage/OperateAllControl';
 
-import { IReturnSelectedPageIds, SearchPageBase } from './SearchPage2/SearchPageBase';
+import { IReturnSelectedPageIds, SearchPageBase, usePageDeleteModalForBulkDeletion } from './SearchPage2/SearchPageBase';
 import { MenuItemType } from './Common/Dropdown/PageItemControl';
 import { LegacyPrivatePagesMigrationModal } from './LegacyPrivatePagesMigrationModal';
 
@@ -176,6 +178,9 @@ export const PrivateLegacyPages = (props: Props): JSX.Element => {
     }
   }, []);
 
+  // for bulk deletion
+  const deleteAllButtonClickedHandler = usePageDeleteModalForBulkDeletion(data, searchPageBaseRef, () => mutate);
+
   const convertMenuItemClickedHandler = useCallback(() => {
     if (data == null) {
       return;
@@ -238,7 +243,7 @@ export const PrivateLegacyPages = (props: Props): JSX.Element => {
                     <i className="icon-fw icon-refresh"></i>
                     {t('private_legacy_pages.convert_all_selected_pages')}
                   </DropdownItem>
-                  <DropdownItem onClick={() => { /* TODO: implement */ }}>
+                  <DropdownItem onClick={deleteAllButtonClickedHandler}>
                     <span className="text-danger">
                       <i className="icon-fw icon-trash"></i>
                       {t('search_result.delete_all_selected_page')}
@@ -251,7 +256,7 @@ export const PrivateLegacyPages = (props: Props): JSX.Element => {
         </div>
       </div>
     );
-  }, [convertMenuItemClickedHandler, hitsCount, isControlEnabled, selectAllCheckboxChangedHandler, t]);
+  }, [convertMenuItemClickedHandler, deleteAllButtonClickedHandler, hitsCount, isControlEnabled, selectAllCheckboxChangedHandler, t]);
 
   const searchResultListHead = useMemo(() => {
     if (data == null) {

+ 20 - 8
packages/app/src/components/SearchPage.tsx

@@ -15,7 +15,7 @@ import PaginationWrapper from './PaginationWrapper';
 import { OperateAllControl } from './SearchPage/OperateAllControl';
 import SearchControl from './SearchPage/SearchControl';
 
-import { SearchPageBase } from './SearchPage2/SearchPageBase';
+import { IReturnSelectedPageIds, SearchPageBase, usePageDeleteModalForBulkDeletion } from './SearchPage2/SearchPageBase';
 
 
 // TODO: replace with "customize:showPageLimitationS"
@@ -115,11 +115,11 @@ export const SearchPage = (props: Props): JSX.Element => {
   });
 
   const selectAllControlRef = useRef<ISelectableAndIndeterminatable|null>(null);
-  const searchPageBaseRef = useRef<ISelectableAll|null>(null);
+  const searchPageBaseRef = useRef<ISelectableAll & IReturnSelectedPageIds|null>(null);
 
   const { data: isSearchServiceReachable } = useIsSearchServiceReachable();
 
-  const { data, conditions } = useSWRxFullTextSearch(keyword, {
+  const { data, conditions, mutate } = useSWRxFullTextSearch(keyword, {
     limit: INITIAL_PAGIONG_SIZE,
     ...configurationsByControl,
     ...configurationsByPagination,
@@ -163,13 +163,22 @@ export const SearchPage = (props: Props): JSX.Element => {
     }
   }, []);
 
+  const pagingSizeChangedHandler = useCallback((pagingSize: number) => {
+    setConfigurationsByPagination({
+      ...configurationsByPagination,
+      limit: pagingSize,
+    });
+    mutate();
+  }, [configurationsByPagination, mutate]);
+
   const pagingNumberChangedHandler = useCallback((activePage: number) => {
     const currentLimit = configurationsByPagination.limit ?? INITIAL_PAGIONG_SIZE;
     setConfigurationsByPagination({
       ...configurationsByPagination,
       offset: (activePage - 1) * currentLimit,
     });
-  }, [configurationsByPagination]);
+    mutate();
+  }, [configurationsByPagination, mutate]);
 
   const initialSearchConditions: Partial<ISearchConditions> = useMemo(() => {
     return {
@@ -178,6 +187,9 @@ export const SearchPage = (props: Props): JSX.Element => {
     };
   }, [initQ]);
 
+  // for bulk deletion
+  const deleteAllButtonClickedHandler = usePageDeleteModalForBulkDeletion(data, searchPageBaseRef, () => mutate);
+
   // push state
   useEffect(() => {
     const newUrl = new URL('/_search', 'http://example.com');
@@ -201,14 +213,14 @@ export const SearchPage = (props: Props): JSX.Element => {
           type="button"
           className="btn btn-outline-danger border-0 px-2"
           disabled={isDisabled}
-          onClick={() => null /* TODO implement */}
+          onClick={deleteAllButtonClickedHandler}
         >
           <i className="icon-fw icon-trash"></i>
           {t('search_result.delete_all_selected_page')}
         </button>
       </OperateAllControl>
     );
-  }, [hitsCount, selectAllCheckboxChangedHandler, t]);
+  }, [deleteAllButtonClickedHandler, hitsCount, selectAllCheckboxChangedHandler, t]);
 
   const searchControl = useMemo(() => {
     if (!isSearchServiceReachable) {
@@ -235,10 +247,10 @@ export const SearchPage = (props: Props): JSX.Element => {
         searchingKeyword={keyword}
         offset={offset}
         pagingSize={limit}
-        onPagingSizeChanged={() => {}}
+        onPagingSizeChanged={pagingSizeChangedHandler}
       />
     );
-  }, [data, keyword, limit, offset]);
+  }, [data, keyword, limit, offset, pagingSizeChangedHandler]);
 
   const searchPager = useMemo(() => {
     // when pager is not needed

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

@@ -107,12 +107,12 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
   const growiRenderer = appContainer.getRenderer('searchresult');
 
 
-  const duplicateItemClickedHandler = useCallback(async(pageId, path) => {
-    openDuplicateModal(pageId, path);
+  const duplicateItemClickedHandler = useCallback(async(pageToDuplicate) => {
+    openDuplicateModal(pageToDuplicate);
   }, [openDuplicateModal]);
 
-  const renameItemClickedHandler = useCallback(async(pageId, revisionId, path) => {
-    openRenameModal(pageId, revisionId, path);
+  const renameItemClickedHandler = useCallback(async(pageToRename) => {
+    openRenameModal(pageToRename);
   }, [openRenameModal]);
 
   const deleteItemClickedHandler = useCallback((pageToDelete) => {

+ 62 - 2
packages/app/src/components/SearchPage2/SearchPageBase.tsx

@@ -1,16 +1,22 @@
 import React, {
-  forwardRef, ForwardRefRenderFunction, useEffect, useImperativeHandle, useRef, useState,
+  forwardRef, ForwardRefRenderFunction, useCallback, useEffect, useImperativeHandle, useRef, useState,
 } from 'react';
+import { useTranslation } from 'react-i18next';
 import { ISelectableAll } from '~/client/interfaces/selectable-all';
 import AppContainer from '~/client/services/AppContainer';
+import { toastSuccess } from '~/client/util/apiNotification';
 import { IPageWithMeta } from '~/interfaces/page';
-import { IPageSearchMeta } from '~/interfaces/search';
+import { IFormattedSearchResult, IPageSearchMeta } from '~/interfaces/search';
+import { OnDeletedFunction } from '~/interfaces/ui';
 import { useIsGuestUser, useIsSearchServiceConfigured, useIsSearchServiceReachable } from '~/stores/context';
+import { IPageForPageDeleteModal, usePageDeleteModal } from '~/stores/modal';
+import { usePageTreeTermManager } from '~/stores/page-listing';
 import { ForceHideMenuItems } from '../Common/Dropdown/PageItemControl';
 
 import { SearchResultContent } from '../SearchPage/SearchResultContent';
 import { SearchResultList } from '../SearchPage/SearchResultList';
 
+
 export interface IReturnSelectedPageIds {
   getSelectedPageIds?: () => Set<string>,
 }
@@ -211,4 +217,58 @@ const SearchPageBaseSubstance: ForwardRefRenderFunction<ISelectableAll & IReturn
 };
 
 
+type VoidFunction = () => void;
+
+export const usePageDeleteModalForBulkDeletion = (
+    data: IFormattedSearchResult | undefined,
+    ref: React.MutableRefObject<(ISelectableAll & IReturnSelectedPageIds) | null>,
+    onDeleted?: OnDeletedFunction,
+): VoidFunction => {
+
+  const { t } = useTranslation();
+
+  const { open: openDeleteModal } = usePageDeleteModal();
+
+  // for PageTree mutation
+  const { advance: advancePt } = usePageTreeTermManager();
+
+  return () => {
+    if (data == null) {
+      return;
+    }
+
+    const instance = ref.current;
+    if (instance == null || instance.getSelectedPageIds == null) {
+      return;
+    }
+
+    const selectedPageIds = instance.getSelectedPageIds();
+
+    if (selectedPageIds.size === 0) {
+      return;
+    }
+
+    const selectedPages = data.data
+      .filter(pageWithMeta => selectedPageIds.has(pageWithMeta.pageData._id))
+      .map(pageWithMeta => ({
+        pageId: pageWithMeta.pageData._id,
+        path: pageWithMeta.pageData.path,
+        revisionId: pageWithMeta.pageData.revision as string,
+      } as IPageForPageDeleteModal));
+
+    openDeleteModal(selectedPages, {
+      onDeleted: (...args) => {
+        toastSuccess(args[2] ? t('deleted_pages_completely') : t('deleted_pages'));
+        advancePt();
+
+        if (onDeleted != null) {
+          onDeleted(...args);
+        }
+      },
+    });
+  };
+
+};
+
+
 export const SearchPageBase = forwardRef(SearchPageBaseSubstance);

+ 13 - 5
packages/app/src/components/Sidebar/PageTree/Item.tsx

@@ -14,7 +14,7 @@ import { toastWarning, toastError, toastSuccess } from '~/client/util/apiNotific
 
 import { useSWRxPageChildren } from '~/stores/page-listing';
 import { apiv3Put, apiv3Post } from '~/client/util/apiv3-client';
-import { IPageForPageDeleteModal } from '~/stores/modal';
+import { IPageForPageRenameModal, IPageForPageDuplicateModal, IPageForPageDeleteModal } from '~/stores/modal';
 
 import TriangleIcon from '~/components/Icons/TriangleIcon';
 import { bookmark, unbookmark } from '~/client/services/page-operation';
@@ -29,8 +29,8 @@ interface ItemProps {
   isScrolled: boolean,
   isOpen?: boolean
   isEnabledAttachTitleHeader?: boolean
-  onClickDuplicateMenuItem?(pageId: string, path: string): void
-  onClickRenameMenuItem?(pageId: string, revisionId: string, path: string): void
+  onClickDuplicateMenuItem?(pageToDuplicate: IPageForPageDuplicateModal): void
+  onClickRenameMenuItem?(pageToRename: IPageForPageRenameModal): void
   onClickDeleteMenuItem?(pageToDelete: IPageForPageDeleteModal): void
 }
 
@@ -198,7 +198,9 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
       throw Error('Any of _id and path must not be null.');
     }
 
-    onClickDuplicateMenuItem(pageId, path);
+    const pageToDuplicate = { pageId, path };
+
+    onClickDuplicateMenuItem(pageToDuplicate);
   }, [onClickDuplicateMenuItem, page]);
 
 
@@ -238,7 +240,13 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
       throw Error('Any of _id and revisionId and path must not be null.');
     }
 
-    onClickRenameMenuItem(pageId, revisionId as string, path);
+    const pageToRename: IPageForPageRenameModal = {
+      pageId,
+      revisionId: revisionId as string,
+      path,
+    };
+
+    onClickRenameMenuItem(pageToRename);
   }, [onClickRenameMenuItem, page]);
 
   const deleteMenuItemClickHandler = useCallback(async(_pageId: string, pageInfo): Promise<void> => {

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

@@ -9,7 +9,7 @@ import { TargetAndAncestors } from '~/interfaces/page-listing-results';
 import { OnDeletedFunction } from '~/interfaces/ui';
 import { toastError, toastSuccess } from '~/client/util/apiNotification';
 import {
-  IPageForPageDeleteModal, usePageDuplicateModal, usePageRenameModal, usePageDeleteModal,
+  IPageForPageDeleteModal, IPageForPageDuplicateModal, usePageDuplicateModal, IPageForPageRenameModal, usePageRenameModal, usePageDeleteModal,
 } from '~/stores/modal';
 import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
 
@@ -69,8 +69,8 @@ const renderByInitialNode = (
     isScrolled: boolean,
     targetPathOrId?: string,
     isEnabledAttachTitleHeader?: boolean,
-    onClickDuplicateMenuItem?: (pageId: string, path: string) => void,
-    onClickRenameMenuItem?: (pageId: string, revisionId: string, path: string) => void,
+    onClickDuplicateMenuItem?: (pageToDuplicate: IPageForPageDuplicateModal) => void,
+    onClickRenameMenuItem?: (pageToRename: IPageForPageRenameModal) => void,
     onClickDeleteMenuItem?: (pageToDelete: IPageForPageDeleteModal) => void,
 ): JSX.Element => {
 
@@ -137,12 +137,12 @@ const ItemsTree: FC<ItemsTreeProps> = (props: ItemsTreeProps) => {
     });
   }, []);
 
-  const onClickDuplicateMenuItem = (pageId: string, path: string) => {
-    openDuplicateModal(pageId, path);
+  const onClickDuplicateMenuItem = (pageToDuplicate: IPageForPageDuplicateModal) => {
+    openDuplicateModal(pageToDuplicate);
   };
 
-  const onClickRenameMenuItem = (pageId: string, revisionId: string, path: string) => {
-    openRenameModal(pageId, revisionId, path);
+  const onClickRenameMenuItem = (pageToRename: IPageForPageRenameModal) => {
+    openRenameModal(pageToRename);
   };
 
   const onClickDeleteMenuItem = (pageToDelete: IPageForPageDeleteModal) => {

+ 4 - 0
packages/app/src/server/crowi/index.js

@@ -22,6 +22,7 @@ import SearchService from '../service/search';
 import AttachmentService from '../service/attachment';
 import PageService from '../service/page';
 import PageGrantService from '../service/page-grant';
+import PageOperationService from '../service/page-operation';
 import { SlackIntegrationService } from '../service/slack-integration';
 import { UserNotificationService } from '../service/user-notification';
 import { InstallerService } from '../service/installer';
@@ -679,6 +680,9 @@ Crowi.prototype.setupPageService = async function() {
   if (this.pageGrantService == null) {
     this.pageGrantService = new PageGrantService(this);
   }
+  if (this.pageOperationService == null) {
+    this.pageOperationService = new PageOperationService(this);
+  }
 };
 
 Crowi.prototype.setupInAppNotificationService = async function() {

+ 25 - 0
packages/app/src/server/interfaces/page-operation.ts

@@ -0,0 +1,25 @@
+import { ObjectIdLike } from './mongoose-utils';
+
+export type IPageForResuming = {
+  _id: ObjectIdLike,
+  path: string,
+  isEmpty: boolean,
+  parent?: ObjectIdLike,
+  grant?: number,
+  grantedUsers?: ObjectIdLike[],
+  grantedGroup?: ObjectIdLike,
+  descendantCount: number,
+  status?: number,
+  revision?: ObjectIdLike,
+  lastUpdateUser?: ObjectIdLike,
+  creator?: ObjectIdLike,
+};
+
+export type IUserForResuming = {
+  _id: ObjectIdLike,
+};
+
+export type IOptionsForResuming = {
+  updateMetadata?: boolean,
+  createRedirectPage?: boolean,
+};

+ 118 - 0
packages/app/src/server/models/page-operation.ts

@@ -0,0 +1,118 @@
+import mongoose, {
+  Schema, Model, Document, QueryOptions, FilterQuery,
+} from 'mongoose';
+import { getOrCreateModel } from '@growi/core';
+
+import {
+  IPageForResuming, IUserForResuming, IOptionsForResuming,
+} from '~/server/interfaces/page-operation';
+import { ObjectIdLike } from '../interfaces/mongoose-utils';
+
+type IObjectId = mongoose.Types.ObjectId;
+const ObjectId = mongoose.Schema.Types.ObjectId;
+
+export const PageActionType = {
+  Rename: 'Rename',
+  Duplicate: 'Duplicate',
+  Delete: 'Delete',
+  DeleteCompletely: 'DeleteCompletely',
+  Revert: 'Revert',
+  NormalizeParent: 'NormalizeParent',
+} as const;
+export type PageActionType = typeof PageActionType[keyof typeof PageActionType];
+
+export const PageActionStage = {
+  Main: 'Main',
+  Sub: 'Sub',
+} as const;
+export type PageActionStage = typeof PageActionStage[keyof typeof PageActionStage];
+
+/*
+ * Main Schema
+ */
+export interface IPageOperation {
+  actionType: PageActionType,
+  actionStage: PageActionStage,
+  fromPath: string,
+  toPath?: string,
+  page: IPageForResuming,
+  user: IUserForResuming,
+  options?: IOptionsForResuming,
+  incForUpdatingDescendantCount?: number,
+}
+
+export interface PageOperationDocument extends IPageOperation, Document {}
+
+export type PageOperationDocumentHasId = PageOperationDocument & { _id: ObjectIdLike };
+
+export interface PageOperationModel extends Model<PageOperationDocument> {
+  findByIdAndUpdatePageActionStage(pageOpId: ObjectIdLike, stage: PageActionStage): Promise<PageOperationDocumentHasId | null>
+  findMainOps(filter?: FilterQuery<PageOperationDocument>, projection?: any, options?: QueryOptions): Promise<PageOperationDocumentHasId[]>
+}
+
+const pageSchemaForResuming = new Schema<IPageForResuming>({
+  _id: { type: ObjectId, ref: 'Page', index: true },
+  parent: { type: ObjectId, ref: 'Page' },
+  descendantCount: { type: Number },
+  isEmpty: { type: Boolean },
+  path: { type: String, required: true, index: true },
+  revision: { type: ObjectId, ref: 'Revision' },
+  status: { type: String },
+  grant: { type: Number },
+  grantedUsers: [{ type: ObjectId, ref: 'User' }],
+  grantedGroup: { type: ObjectId, ref: 'UserGroup' },
+  creator: { type: ObjectId, ref: 'User' },
+  lastUpdateUser: { type: ObjectId, ref: 'User' },
+});
+
+const userSchemaForResuming = new Schema<IUserForResuming>({
+  _id: { type: ObjectId, ref: 'User', required: true },
+});
+
+const optionsSchemaForResuming = new Schema<IOptionsForResuming>({
+  createRedirectPage: { type: Boolean },
+  updateMetadata: { type: Boolean },
+}, { _id: false });
+
+const schema = new Schema<PageOperationDocument, PageOperationModel>({
+  actionType: {
+    type: String,
+    enum: PageActionType,
+    required: true,
+    index: true,
+  },
+  actionStage: {
+    type: String,
+    enum: PageActionStage,
+    required: true,
+    index: true,
+  },
+  fromPath: { type: String, required: true, index: true },
+  toPath: { type: String, index: true },
+  page: { type: pageSchemaForResuming, required: true },
+  user: { type: userSchemaForResuming, required: true },
+  options: { type: optionsSchemaForResuming },
+  incForUpdatingDescendantCount: { type: Number },
+});
+
+schema.statics.findByIdAndUpdatePageActionStage = async function(
+    pageOpId: ObjectIdLike, stage: PageActionStage,
+): Promise<PageOperationDocumentHasId | null> {
+
+  return this.findByIdAndUpdate(pageOpId, {
+    $set: { actionStage: stage },
+  }, { new: true });
+};
+
+schema.statics.findMainOps = async function(
+    filter?: FilterQuery<PageOperationDocument>, projection?: any, options?: QueryOptions,
+): Promise<PageOperationDocumentHasId[]> {
+
+  return this.find(
+    { ...filter, actionStage: PageActionStage.Main },
+    projection,
+    options,
+  );
+};
+
+export default getOrCreateModel<PageOperationDocument, PageOperationModel>('PageOperation', schema);

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

@@ -16,9 +16,6 @@ export interface PageRedirectModel extends Model<PageRedirectDocument> {
   [x:string]: any // TODO: improve type
 }
 
-/**
- * This is the setting for notify to 3rd party tool (like Slack).
- */
 const schema = new Schema<PageRedirectDocument, PageRedirectModel>({
   fromPath: {
     type: String, required: true, unique: true, index: true,

+ 25 - 54
packages/app/src/server/models/page.ts

@@ -423,40 +423,6 @@ async function pushRevision(pageData, newRevision, user) {
   return pageData.save();
 }
 
-/**
- * return aggregate condition to get following pages
- * - page that has the same path as the provided path
- * - pages that are descendants of the above page
- * pages without parent will be ignored
- */
-schema.statics.getAggrConditionForPageWithProvidedPathAndDescendants = function(path:string) {
-  let match;
-  if (isTopPage(path)) {
-    match = {
-      // https://regex101.com/r/Kip2rV/1
-      $match: { $or: [{ path: { $regex: '^/.*' }, parent: { $ne: null } }, { path: '/' }] },
-    };
-  }
-  else {
-    match = {
-      // https://regex101.com/r/mJvGrG/1
-      $match: { path: { $regex: `^${path}(/.*|$)` }, parent: { $ne: null } },
-    };
-  }
-  return [
-    match,
-    {
-      $project: {
-        path: 1,
-        parent: 1,
-        field_length: { $strLenCP: '$path' },
-      },
-    },
-    { $sort: { field_length: -1 } },
-    { $project: { field_length: 0 } },
-  ];
-};
-
 /**
  * add/subtract descendantCount of pages with provided paths by increment.
  * increment can be negative number
@@ -512,6 +478,9 @@ schema.statics.recountDescendantCount = async function(id: ObjectIdLike):Promise
 schema.statics.findAncestorsUsingParentRecursively = async function(pageId: ObjectIdLike, shouldIncludeTarget: boolean) {
   const self = this;
   const target = await this.findById(pageId);
+  if (target == null) {
+    throw Error('Target not found');
+  }
 
   async function findAncestorsRecursively(target, ancestors = shouldIncludeTarget ? [target] : []) {
     const parent = await self.findOne({ _id: target.parent });
@@ -531,44 +500,41 @@ schema.statics.findAncestorsUsingParentRecursively = async function(pageId: Obje
  * @param pageId ObjectIdLike
  * @returns Promise<void>
  */
-schema.statics.removeLeafEmptyPagesById = async function(pageId: ObjectIdLike): Promise<void> {
+schema.statics.removeLeafEmptyPagesRecursively = async function(pageId: ObjectIdLike): Promise<void> {
   const self = this;
 
-  const initialLeafPage = await this.findById(pageId);
+  const initialPage = await this.findById(pageId);
 
-  if (initialLeafPage == null) {
+  if (initialPage == null) {
     return;
   }
 
-  if (!initialLeafPage.isEmpty) {
+  if (!initialPage.isEmpty) {
     return;
   }
 
-  async function generatePageIdsToRemove(page, pageIds: ObjectIdLike[]): Promise<ObjectIdLike[]> {
-    const nextPage = await self.findById(page.parent);
-
-    if (nextPage == null) {
+  async function generatePageIdsToRemove(childPage, page, pageIds: ObjectIdLike[] = []): Promise<ObjectIdLike[]> {
+    if (!page.isEmpty) {
       return pageIds;
     }
 
-    // delete leaf empty pages
-    const isNextPageEmpty = nextPage.isEmpty;
-
-    if (!isNextPageEmpty) {
+    const isChildrenOtherThanTargetExist = await self.exists({ _id: { $ne: childPage?._id }, parent: page._id });
+    if (isChildrenOtherThanTargetExist) {
       return pageIds;
     }
 
-    const isSiblingsExist = await self.exists({ parent: nextPage.parent, _id: { $ne: nextPage._id } });
-    if (isSiblingsExist) {
+    pageIds.push(page._id);
+
+    const nextPage = await self.findById(page.parent);
+
+    if (nextPage == null) {
       return pageIds;
     }
 
-    return generatePageIdsToRemove(nextPage, [...pageIds, nextPage._id]);
+    return generatePageIdsToRemove(page, nextPage, pageIds);
   }
 
-  const initialPageIdsToRemove = [initialLeafPage._id];
-
-  const pageIdsToRemove = await generatePageIdsToRemove(initialLeafPage, initialPageIdsToRemove);
+  const pageIdsToRemove = await generatePageIdsToRemove(null, initialPage);
 
   await this.deleteMany({ _id: { $in: pageIdsToRemove } });
 };
@@ -578,7 +544,7 @@ schema.statics.findByPageIdsToEdit = async function(ids, user, shouldIncludeEmpt
 
   await this.addConditionToFilteringByViewerToEdit(builder, user);
 
-  const pages = await builder.query.lean().exec();
+  const pages = await builder.query.exec();
 
   return pages;
 };
@@ -608,7 +574,7 @@ export default (crowi: Crowi): any => {
   }
 
   schema.statics.create = async function(path: string, body: string, user, options: PageCreateOptions = {}) {
-    if (crowi.pageGrantService == null || crowi.configManager == null || crowi.pageService == null) {
+    if (crowi.pageGrantService == null || crowi.configManager == null || crowi.pageService == null || crowi.pageOperationService == null) {
       throw Error('Crowi is not setup');
     }
 
@@ -618,6 +584,11 @@ export default (crowi: Crowi): any => {
       return this.createV4(path, body, user, options);
     }
 
+    const canOperate = await crowi.pageOperationService.canOperate(false, null, path);
+    if (!canOperate) {
+      throw Error(`Cannot operate create to path "${path}" right now.`);
+    }
+
     const Page = this;
     const Revision = crowi.model('Revision');
     const {

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

@@ -363,7 +363,7 @@ module.exports = (crowi) => {
     const { pageId } = req.query;
 
     try {
-      const pageWithMeta = await pageService.findPageAndMetaDataByViewer(pageId, null, user, isSharedPage);
+      const pageWithMeta = await pageService.findPageAndMetaDataByViewer(pageId, null, user, true, isSharedPage);
 
       if (pageWithMeta == null) {
         return res.apiv3Err(`Page '${pageId}' is not found or forbidden`);

+ 6 - 11
packages/app/src/server/routes/apiv3/pages.js

@@ -769,7 +769,8 @@ module.exports = (crowi) => {
     }
 
     // run delete
-    crowi.pageService.deleteMultiplePages(pagesCanBeDeleted, req.user, isCompletely, isRecursively);
+    const options = { isCompletely, isRecursively };
+    crowi.pageService.deleteMultiplePages(pagesCanBeDeleted, req.user, options);
 
     return res.apiv3({ paths: pagesCanBeDeleted.map(p => p.path), isRecursively, isCompletely });
   });
@@ -799,17 +800,11 @@ module.exports = (crowi) => {
       return res.apiv3Err(new ErrorV3(`The maximum number of pages you can select is ${LIMIT_FOR_MULTIPLE_PAGE_OP}.`, 'exceeded_maximum_number'), 400);
     }
 
-    if (isRecursively) {
-      // this method innerly uses socket to send message
-      crowi.pageService.normalizeParentRecursivelyByPageIds(pageIds, req.user);
+    try {
+      await crowi.pageService.normalizeParentByPageIds(pageIds, req.user, isRecursively);
     }
-    else {
-      try {
-        await crowi.pageService.normalizeParentByPageIds(pageIds, req.user);
-      }
-      catch (err) {
-        return res.apiv3Err(new ErrorV3(`Failed to migrate pages: ${err.message}`), 500);
-      }
+    catch (err) {
+      return res.apiv3Err(new ErrorV3(`Failed to migrate pages: ${err.message}`), 500);
     }
 
     return res.apiv3({});

+ 2 - 9
packages/app/src/server/service/page-grant.ts

@@ -352,24 +352,17 @@ class PageGrantService {
    * @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) {
+  async separateNormalizableAndNotNormalizablePages(pages): Promise<[(PageDocument & { _id: any })[], (PageDocument & { _id: any })[]]> {
+    if (pages.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 { PageQueryBuilder } = Page;
     const shouldCheckDescendants = true;
     const shouldIncludeNotMigratedPages = true;
 
     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());
-    builder.addConditionToListByPageIdsArray(pageIds);
-
-    const pages = await builder.query.exec();
-
     for await (const page of pages) {
       const {
         path, grant, grantedUsers: grantedUserIds, grantedGroup: grantedGroupId,

+ 101 - 0
packages/app/src/server/service/page-operation.ts

@@ -0,0 +1,101 @@
+import { pagePathUtils, pathUtils } from '@growi/core';
+import escapeStringRegexp from 'escape-string-regexp';
+
+import PageOperation from '~/server/models/page-operation';
+
+const { addTrailingSlash } = pathUtils;
+const { isTrashPage } = pagePathUtils;
+
+class PageOperationService {
+
+  crowi: any;
+
+  constructor(crowi) {
+    this.crowi = crowi;
+
+    // TODO: Remove this code when resuming feature is implemented
+    PageOperation.deleteMany();
+  }
+
+  /**
+   * Check if the operation is operatable by comparing paths with all Main PageOperation documents
+   * @param fromPath The path to operate from
+   * @param toPath The path to operate to
+   * @param actionType The action type of the operation
+   * @returns Promise<boolean>
+   */
+  async canOperate(isRecursively: boolean, fromPathToOp: string | null, toPathToOp: string | null): Promise<boolean> {
+    const mainOps = await PageOperation.findMainOps();
+
+    if (mainOps.length === 0) {
+      return true;
+    }
+
+    const toPaths = mainOps.map(op => op.toPath).filter((p): p is string => p != null);
+
+    if (isRecursively) {
+
+      if (fromPathToOp != null && !isTrashPage(fromPathToOp)) {
+        const flag = toPaths.some(p => this.isEitherOfPathAreaOverlap(p, fromPathToOp));
+        if (flag) return false;
+      }
+
+      if (toPathToOp != null && !isTrashPage(toPathToOp)) {
+        const flag = toPaths.some(p => this.isPathAreaOverlap(p, toPathToOp));
+        if (flag) return false;
+      }
+
+    }
+    else {
+
+      if (fromPathToOp != null && !isTrashPage(fromPathToOp)) {
+        const flag = toPaths.some(p => this.isPathAreaOverlap(p, fromPathToOp));
+        if (flag) return false;
+      }
+
+      if (toPathToOp != null && !isTrashPage(toPathToOp)) {
+        const flag = toPaths.some(p => this.isPathAreaOverlap(p, toPathToOp));
+        if (flag) return false;
+      }
+
+    }
+
+    return true;
+  }
+
+  private isEitherOfPathAreaOverlap(path1: string, path2: string): boolean {
+    if (path1 === path2) {
+      return true;
+    }
+
+    const path1WithSlash = addTrailingSlash(path1);
+    const path2WithSlash = addTrailingSlash(path2);
+
+    const path1Area = new RegExp(`^${escapeStringRegexp(path1WithSlash)}`);
+    const path2Area = new RegExp(`^${escapeStringRegexp(path2WithSlash)}`);
+
+    if (path1Area.test(path2) || path2Area.test(path1)) {
+      return true;
+    }
+
+    return false;
+  }
+
+  private isPathAreaOverlap(pathToTest: string, pathToBeTested: string): boolean {
+    if (pathToTest === pathToBeTested) {
+      return true;
+    }
+
+    const pathWithSlash = addTrailingSlash(pathToTest);
+
+    const pathAreaToTest = new RegExp(`^${escapeStringRegexp(pathWithSlash)}`);
+    if (pathAreaToTest.test(pathToBeTested)) {
+      return true;
+    }
+
+    return false;
+  }
+
+}
+
+export default PageOperationService;

File diff suppressed because it is too large
+ 415 - 196
packages/app/src/server/service/page.ts


+ 37 - 14
packages/app/src/stores/modal.tsx

@@ -82,25 +82,35 @@ export type IPageForPageDuplicateModal = {
   path: string
 }
 
+export type IDuplicateModalOption = {
+  onDeleted?: OnDeletedFunction,
+}
+
 type DuplicateModalStatus = {
   isOpened: boolean,
-  pageId?: string,
-  path?: string,
+  page?: IPageForPageDuplicateModal,
+  opts?: IDuplicateModalOption,
 }
 
 type DuplicateModalStatusUtils = {
-  open(pageId: string, path: string): Promise<DuplicateModalStatus | undefined>
+  open(
+    page?: IPageForPageDuplicateModal,
+    opts?: IRenameModalOption
+  ): Promise<DuplicateModalStatus | undefined>
   close(): Promise<DuplicateModalStatus | undefined>
 }
 
 export const usePageDuplicateModal = (status?: DuplicateModalStatus): SWRResponse<DuplicateModalStatus, Error> & DuplicateModalStatusUtils => {
-  const initialData: DuplicateModalStatus = { isOpened: false, pageId: '', path: '' };
+  const initialData: DuplicateModalStatus = { isOpened: false, page: { 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 }),
+    open: (
+        page?: IPageForPageDuplicateModal,
+        opts?: IDuplicateModalOption,
+    ) => swrResponse.mutate({ isOpened: true, page, opts }),
+    close: () => swrResponse.mutate({ isOpened: false, page: { pageId: '', path: '/' } }),
   };
 };
 
@@ -114,33 +124,46 @@ export type IPageForPageRenameModal = {
   path: string
 }
 
+export type IRenameModalOption = {
+  onDeleted?: OnDeletedFunction,
+}
+
 type RenameModalStatus = {
   isOpened: boolean,
-  pageId?: string,
-  revisionId?: string
-  path?: string,
+  page?: IPageForPageRenameModal,
+  opts?: IRenameModalOption
 }
 
 type RenameModalStatusUtils = {
-  open(pageId: string, revisionId: string, path: string): Promise<RenameModalStatus | undefined>
+  open(
+    page?: IPageForPageRenameModal,
+    opts?: IRenameModalOption
+    ): Promise<RenameModalStatus | undefined>
   close(): Promise<RenameModalStatus | undefined>
 }
 
 export const usePageRenameModal = (status?: RenameModalStatus): SWRResponse<RenameModalStatus, Error> & RenameModalStatusUtils => {
   const initialData: RenameModalStatus = {
-    isOpened: false, pageId: '', revisionId: '', path: '',
+    isOpened: false, page: { 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,
+    open: (
+        page?: IPageForPageRenameModal,
+        opts?: IRenameModalOption,
+    ) => swrResponse.mutate({
+      isOpened: true, page, opts,
     }),
-    close: () => swrResponse.mutate({ isOpened: false }),
+    close: () => swrResponse.mutate({ isOpened: false, page: { pageId: '', revisionId: '', path: '' } }),
   };
 };
 
+
+/*
+* PutBackPageModal
+*/
 type PutBackPageModalStatus = {
   isOpened: boolean,
   pageId?: string,

+ 1 - 4
packages/app/test/integration/service/page.test.js

@@ -635,10 +635,7 @@ describe('PageService', () => {
       expect(deleteManyPageTagRelationSpy).toHaveBeenCalledWith({ relatedPage: { $in: [parentForDeleteCompletely._id] } });
       expect(deleteManyShareLinkSpy).toHaveBeenCalledWith({ relatedPage: { $in: [parentForDeleteCompletely._id] } });
       expect(deleteManyRevisionSpy).toHaveBeenCalledWith({ pageId: { $in: [parentForDeleteCompletely._id] } });
-      expect(deleteManyPageSpy).toHaveBeenCalledWith({
-        $or: [{ path: { $in: [parentForDeleteCompletely.path] } },
-              { _id: { $in: [parentForDeleteCompletely._id] } }],
-      });
+      expect(deleteManyPageSpy).toHaveBeenCalledWith({ _id: { $in: [parentForDeleteCompletely._id] } });
       expect(removeAllAttachmentsSpy).toHaveBeenCalled();
     });
 

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

@@ -23,12 +23,12 @@ describe('V5 page migration', () => {
   });
 
 
-  describe('normalizeParentRecursivelyByPageIds()', () => {
+  describe('normalizeParentRecursivelyByPages()', () => {
     test('should migrate all pages specified by pageIds', async() => {
       jest.restoreAllMocks();
 
       // initialize pages for test
-      const pages = await Page.insertMany([
+      let pages = await Page.insertMany([
         {
           path: '/private1',
           grant: Page.GRANT_OWNER,
@@ -59,9 +59,15 @@ describe('V5 page migration', () => {
         },
       ]);
 
-      const pageIds = pages.map(page => page._id);
+      if (!await Page.exists({ path: '/' })) {
+        const additionalPages = await Page.insertMany([{ path: '/', grant: Page.GRANT_PUBLIC }]);
+        pages = [...additionalPages, ...pages];
+      }
+
+      const pagesToRun = await Page.find({ path: { $in: ['/private1', '/dummyParent/private1'] } });
+
       // migrate
-      await crowi.pageService.normalizeParentRecursivelyByPageIds(pageIds, testUser1);
+      await crowi.pageService.normalizeParentRecursivelyByPages(pagesToRun, testUser1);
 
       const migratedPages = await Page.find({
         path: {

+ 25 - 25
packages/app/test/integration/service/v5.page.test.ts

@@ -842,19 +842,19 @@ describe('PageService page operations with only public pages', () => {
 
     const renamePage = async(page, newPagePath, user, options) => {
     // mock return value
-      const mockedResumableRenameDescendants = jest.spyOn(crowi.pageService, 'resumableRenameDescendants').mockReturnValue(null);
+      const mockedRenameSubOperation = jest.spyOn(crowi.pageService, 'renameSubOperation').mockReturnValue(null);
       const mockedCreateAndSendNotifications = jest.spyOn(crowi.pageService, 'createAndSendNotifications').mockReturnValue(null);
       const renamedPage = await crowi.pageService.renamePage(page, newPagePath, user, options);
 
-      // retrieve the arguments passed when calling method resumableRenameDescendants inside renamePage method
-      const argsForResumableRenameDescendants = mockedResumableRenameDescendants.mock.calls[0];
+      // retrieve the arguments passed when calling method renameSubOperation inside renamePage method
+      const argsForRenameSubOperation = mockedRenameSubOperation.mock.calls[0];
 
       // restores the original implementation
-      mockedResumableRenameDescendants.mockRestore();
+      mockedRenameSubOperation.mockRestore();
       mockedCreateAndSendNotifications.mockRestore();
 
       // rename descendants
-      await crowi.pageService.resumableRenameDescendants(...argsForResumableRenameDescendants);
+      await crowi.pageService.renameSubOperation(...argsForRenameSubOperation);
 
       return renamedPage;
     };
@@ -1004,20 +1004,20 @@ describe('PageService page operations with only public pages', () => {
 
     const duplicate = async(page, newPagePath, user, isRecursively) => {
       // mock return value
-      const mockedResumableDuplicateDescendants = jest.spyOn(crowi.pageService, 'resumableDuplicateDescendants').mockReturnValue(null);
+      const mockedDuplicateRecursivelyMainOperation = jest.spyOn(crowi.pageService, 'duplicateRecursivelyMainOperation').mockReturnValue(null);
       const mockedCreateAndSendNotifications = jest.spyOn(crowi.pageService, 'createAndSendNotifications').mockReturnValue(null);
       const duplicatedPage = await crowi.pageService.duplicate(page, newPagePath, user, isRecursively);
 
-      // retrieve the arguments passed when calling method resumableDuplicateDescendants inside duplicate method
-      const argsForResumableDuplicateDescendants = mockedResumableDuplicateDescendants.mock.calls[0];
+      // retrieve the arguments passed when calling method duplicateRecursivelyMainOperation inside duplicate method
+      const argsForDuplicateRecursivelyMainOperation = mockedDuplicateRecursivelyMainOperation.mock.calls[0];
 
       // restores the original implementation
-      mockedResumableDuplicateDescendants.mockRestore();
+      mockedDuplicateRecursivelyMainOperation.mockRestore();
       mockedCreateAndSendNotifications.mockRestore();
 
       // duplicate descendants
       if (isRecursively) {
-        await crowi.pageService.resumableDuplicateDescendants(...argsForResumableDuplicateDescendants);
+        await crowi.pageService.duplicateRecursivelyMainOperation(...argsForDuplicateRecursivelyMainOperation);
       }
 
       return duplicatedPage;
@@ -1165,18 +1165,18 @@ describe('PageService page operations with only public pages', () => {
   });
   describe('Delete', () => {
     const deletePage = async(page, user, options, isRecursively) => {
-      const mockedResumableDeleteDescendants = jest.spyOn(crowi.pageService, 'resumableDeleteDescendants').mockReturnValue(null);
+      const mockedDeleteRecursivelyMainOperation = jest.spyOn(crowi.pageService, 'deleteRecursivelyMainOperation').mockReturnValue(null);
       const mockedCreateAndSendNotifications = jest.spyOn(crowi.pageService, 'createAndSendNotifications').mockReturnValue(null);
 
       const deletedPage = await crowi.pageService.deletePage(page, user, options, isRecursively);
 
-      const argsForResumableDeleteDescendants = mockedResumableDeleteDescendants.mock.calls[0];
+      const argsForDeleteRecursivelyMainOperation = mockedDeleteRecursivelyMainOperation.mock.calls[0];
 
-      mockedResumableDeleteDescendants.mockRestore();
+      mockedDeleteRecursivelyMainOperation.mockRestore();
       mockedCreateAndSendNotifications.mockRestore();
 
       if (isRecursively) {
-        await crowi.pageService.resumableDeleteDescendants(...argsForResumableDeleteDescendants);
+        await crowi.pageService.deleteRecursivelyMainOperation(...argsForDeleteRecursivelyMainOperation);
       }
 
       return deletedPage;
@@ -1280,18 +1280,18 @@ describe('PageService page operations with only public pages', () => {
 
   describe('Delete completely', () => {
     const deleteCompletely = async(page, user, options = {}, isRecursively = false, preventEmitting = false) => {
-      const mockedResumableDeleteCompletelyDescendants = jest.spyOn(crowi.pageService, 'resumableDeleteCompletelyDescendants').mockReturnValue(null);
+      const mockedDeleteCompletelyRecursivelyMainOperation = jest.spyOn(crowi.pageService, 'deleteCompletelyRecursivelyMainOperation').mockReturnValue(null);
       const mockedCreateAndSendNotifications = jest.spyOn(crowi.pageService, 'createAndSendNotifications').mockReturnValue(null);
 
       await crowi.pageService.deleteCompletely(page, user, options, isRecursively, preventEmitting);
 
-      const argsForResumableDeleteDescendants = mockedResumableDeleteCompletelyDescendants.mock.calls[0];
+      const argsForDeleteCompletelyRecursivelyMainOperation = mockedDeleteCompletelyRecursivelyMainOperation.mock.calls[0];
 
-      mockedResumableDeleteCompletelyDescendants.mockRestore();
+      mockedDeleteCompletelyRecursivelyMainOperation.mockRestore();
       mockedCreateAndSendNotifications.mockRestore();
 
       if (isRecursively) {
-        await crowi.pageService.resumableDeleteCompletelyDescendants(...argsForResumableDeleteDescendants);
+        await crowi.pageService.deleteCompletelyRecursivelyMainOperation(...argsForDeleteCompletelyRecursivelyMainOperation);
       }
 
       return;
@@ -1382,9 +1382,9 @@ describe('PageService page operations with only public pages', () => {
       expectAllToBeTruthy([parentPage, childPage, grandchildPage]);
 
       await deleteCompletely(childPage, dummyUser1, {}, false);
-      const parentPageAfterDelete = await Page.findOne({ path: parentPage.path });
-      const childPageAfterDelete = await Page.findOne({ path: childPage.path });
-      const grandchildPageAfterDelete = await Page.findOne({ path: grandchildPage.path });
+      const parentPageAfterDelete = await Page.findOne({ path: '/v5_PageForDeleteCompletely6' });
+      const childPageAfterDelete = await Page.findOne({ path: '/v5_PageForDeleteCompletely6/v5_PageForDeleteCompletely7' });
+      const grandchildPageAfterDelete = await Page.findOne({ path: '/v5_PageForDeleteCompletely6/v5_PageForDeleteCompletely7/v5_PageForDeleteCompletely8' });
       const childOfDeletedPage = await Page.findOne({ parent: childPageAfterDelete._id });
 
       expectAllToBeTruthy([parentPageAfterDelete, childPageAfterDelete, grandchildPageAfterDelete]);
@@ -1400,15 +1400,15 @@ describe('PageService page operations with only public pages', () => {
   describe('revert', () => {
     const revertDeletedPage = async(page, user, options = {}, isRecursively = false) => {
       // mock return value
-      const mockedResumableRevertDeletedDescendants = jest.spyOn(crowi.pageService, 'resumableRevertDeletedDescendants').mockReturnValue(null);
+      const mockedRevertRecursivelyMainOperation = jest.spyOn(crowi.pageService, 'revertRecursivelyMainOperation').mockReturnValue(null);
       const revertedPage = await crowi.pageService.revertDeletedPage(page, user, options, isRecursively);
 
-      const argsForResumableRevertDeletedDescendants = mockedResumableRevertDeletedDescendants.mock.calls[0];
+      const argsForRecursivelyMainOperation = mockedRevertRecursivelyMainOperation.mock.calls[0];
 
       // restores the original implementation
-      mockedResumableRevertDeletedDescendants.mockRestore();
+      mockedRevertRecursivelyMainOperation.mockRestore();
       if (isRecursively) {
-        await crowi.pageService.resumableRevertDeletedDescendants(...argsForResumableRevertDeletedDescendants);
+        await crowi.pageService.revertRecursivelyMainOperation(...argsForRecursivelyMainOperation);
       }
 
       return revertedPage;

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

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

+ 1 - 1
packages/core/package.json

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

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

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

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

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

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

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

+ 1 - 1
packages/slack/package.json

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

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

@@ -25,7 +25,7 @@
   },
   "dependencies": {
     "@godaddy/terminus": "^4.9.0",
-    "@growi/slack": "^5.0.0-RC.0",
+    "@growi/slack": "^5.0.0-RC.7",
     "@slack/oauth": "^2.0.1",
     "@slack/web-api": "^6.2.4",
     "@tsed/common": "^6.43.0",

+ 1 - 1
packages/ui/package.json

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

Some files were not shown because too many files changed in this diff