浏览代码

Merge branch 'master' into support/gw7740-VRT-tag

Mudana-Grune 3 年之前
父节点
当前提交
310d4b1247

+ 1 - 3
packages/app/src/client/services/ContextExtractor.tsx

@@ -13,7 +13,7 @@ import { useSetupGlobalSocket, useSetupGlobalAdminSocket } from '~/stores/websoc
 import {
   useSiteUrl,
   useCurrentCreatedAt, useDeleteUsername, useDeletedAt, useHasChildren, useHasDraftOnHackmd,
-  useIsDeleted, useIsNotCreatable, useIsTrashPage, useIsUserPage, useLastUpdateUsername,
+  useIsNotCreatable, useIsTrashPage, useIsUserPage, useLastUpdateUsername,
   useCurrentPageId, usePageIdOnHackmd, usePageUser, useCurrentPagePath, useRevisionCreatedAt, useRevisionId, useRevisionIdHackmdSynced,
   useShareLinkId, useShareLinksNumber, useTemplateTagData, useCurrentUpdatedAt, useCreator, useRevisionAuthor, useCurrentUser, useTargetAndAncestors,
   useNotFoundTargetPathOrId, useIsSearchPage, useIsForbidden, useIsIdenticalPath, useHasParent,
@@ -74,7 +74,6 @@ const ContextExtractorOnce: FC = () => {
   const isIdenticalPath = JSON.parse(mainContent?.getAttribute('data-identical-path') || jsonNull) ?? false;
   const isUserPage = JSON.parse(mainContent?.getAttribute('data-page-user') || jsonNull) != null;
   const isTrashPage = _isTrashPage(path);
-  const isDeleted = JSON.parse(mainContent?.getAttribute('data-page-is-deleted') || jsonNull) ?? false;
   const isNotCreatable = JSON.parse(mainContent?.getAttribute('data-page-is-not-creatable') || jsonNull) ?? false;
   const isForbidden = forbiddenContent != null;
   const pageUser = JSON.parse(mainContent?.getAttribute('data-page-user') || jsonNull);
@@ -132,7 +131,6 @@ const ContextExtractorOnce: FC = () => {
   useHasChildren(hasChildren);
   useHasDraftOnHackmd(hasDraftOnHackmd);
   useIsIdenticalPath(isIdenticalPath);
-  useIsDeleted(isDeleted);
   useIsNotCreatable(isNotCreatable);
   useIsForbidden(isForbidden);
   useIsTrashPage(isTrashPage);

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

@@ -61,7 +61,6 @@ export default class PageContainer extends Container {
 
       isUserPage: JSON.parse(mainContent.getAttribute('data-page-user')) != null,
       isTrashPage: isTrashPage(path),
-      isDeleted: JSON.parse(mainContent.getAttribute('data-page-is-deleted')),
       isNotCreatable: JSON.parse(mainContent.getAttribute('data-page-is-not-creatable')),
       isPageExist: mainContent.getAttribute('data-page-id') != null,
 

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

@@ -153,6 +153,7 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
           <DropdownItem
             onClick={bookmarkItemClickedHandler}
             className="grw-page-control-dropdown-item"
+            data-testid="add-remove-bookmark-btn"
           >
             <i className="fa fa-fw fa-bookmark-o grw-page-control-dropdown-icon"></i>
             { pageInfo.isBookmarked ? t('remove_bookmark') : t('add_bookmark') }

+ 2 - 1
packages/app/src/components/Me/PersonalSettings.jsx

@@ -58,10 +58,11 @@ const PersonalSettings = () => {
     };
   }, [t]);
 
+  const onPasswordSettings = window.location.hash === '#password';
 
   return (
     <div data-testid="grw-personal-settings">
-      <CustomNavAndContents navTabMapping={navTabMapping} navigationMode="both" tabContentClasses={['px-0']} />
+      <CustomNavAndContents defaultTabIndex={onPasswordSettings && 2} navTabMapping={navTabMapping} navigationMode="both" tabContentClasses={['px-0']} />
     </div>
   );
 

+ 8 - 1
packages/app/src/components/Navbar/GlobalSearch.tsx

@@ -73,7 +73,13 @@ const GlobalSearch: FC<Props> = (props: Props) => {
     <div className={`form-group mb-0 d-print-none ${isSearchServiceReachable ? '' : 'has-error'}`}>
       <div className="input-group flex-nowrap">
         <div className={`input-group-prepend ${dropup ? 'dropup' : ''}`}>
-          <button className="btn btn-secondary dropdown-toggle py-0" type="button" data-toggle="dropdown" aria-haspopup="true">
+          <button
+            className="btn btn-secondary dropdown-toggle py-0"
+            type="button"
+            data-toggle="dropdown"
+            aria-haspopup="true"
+            data-testid="select-search-scope"
+          >
             {scopeLabel}
           </button>
           <div className="dropdown-menu">
@@ -88,6 +94,7 @@ const GlobalSearch: FC<Props> = (props: Props) => {
               { t('header_search_box.item_label.All pages') }
             </button>
             <button
+              data-tesid="search-current-tree"
               className="dropdown-item"
               type="button"
               onClick={() => {

+ 2 - 3
packages/app/src/components/Page/TagsInput.tsx

@@ -38,11 +38,10 @@ const TagsInput: FC<Props> = (props: Props) => {
   const searchHandler = useCallback(async(query: string) => {
     const tagsSearchData = tagsSearch?.tags || [];
     setSearchQuery(query);
-
-    tagsSearchData.unshift(searchQuery);
+    tagsSearchData.unshift(query);
     setResultTags(Array.from(new Set(tagsSearchData)));
 
-  }, [searchQuery, tagsSearch?.tags]);
+  }, [tagsSearch?.tags]);
 
   const keyDownHandler = useCallback((event: React.KeyboardEvent) => {
     if (event.key === ' ') {

+ 4 - 3
packages/app/src/components/Page/TrashPageAlert.jsx

@@ -5,7 +5,7 @@ import PropTypes from 'prop-types';
 import { useTranslation } from 'react-i18next';
 
 import PageContainer from '~/client/services/PageContainer';
-import { useCurrentUpdatedAt, useShareLinkId } from '~/stores/context';
+import { useCurrentUpdatedAt, useIsTrashPage, useShareLinkId } from '~/stores/context';
 import { usePageDeleteModal, usePutBackPageModal } from '~/stores/modal';
 import { useSWRxPageInfo } from '~/stores/page';
 import { useIsAbleToShowTrashPageManagementButtons } from '~/stores/ui';
@@ -24,7 +24,7 @@ const TrashPageAlert = (props) => {
   const { t } = useTranslation();
   const { pageContainer } = props;
   const {
-    pageId, revisionId, path, isDeleted, lastUpdateUsername, deletedUserName, deletedAt,
+    pageId, revisionId, path, lastUpdateUsername, deletedUserName, deletedAt,
   } = pageContainer.state;
 
   const { data: isAbleToShowTrashPageManagementButtons } = useIsAbleToShowTrashPageManagementButtons();
@@ -38,6 +38,7 @@ const TrashPageAlert = (props) => {
   const { data: pageInfo } = useSWRxPageInfo(pageId ?? null, shareLinkId);
 
   const { data: updatedAt } = useCurrentUpdatedAt();
+  const { data: isTrashPage } = useIsTrashPage();
 
   const { open: openDeleteModal } = usePageDeleteModal();
   const { open: openPutBackPageModal } = usePutBackPageModal();
@@ -89,7 +90,7 @@ const TrashPageAlert = (props) => {
       <div className="alert alert-warning py-3 pl-4 d-flex flex-column flex-lg-row">
         <div className="flex-grow-1">
           This page is in the trash <i className="icon-trash" aria-hidden="true"></i>.
-          {isDeleted && (
+          {isTrashPage && (
             <>
               <br />
               <UserPicture user={{ username: deletedUserName || lastUpdateUsername }} />

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

@@ -98,7 +98,7 @@ export const getPageSchema = (crowi) => {
   }
 
   pageSchema.methods.isDeleted = function() {
-    return (this.status === STATUS_DELETED) || isTrashPage(this.path);
+    return isTrashPage(this.path);
   };
 
   pageSchema.methods.isPublic = function() {

+ 3 - 2
packages/app/src/server/routes/apiv3/pages.js

@@ -571,7 +571,8 @@ module.exports = (crowi) => {
     }
 
     try {
-      await crowi.pageService.resumeRenameSubOperation(page);
+      const pageOp = await crowi.pageOperationService.getRenameSubOperationByPageId(page._id);
+      await crowi.pageService.resumeRenameSubOperation(page, pageOp);
     }
     catch (err) {
       logger.error(err);
@@ -594,7 +595,7 @@ module.exports = (crowi) => {
   router.delete('/empty-trash', accessTokenParser, loginRequired, csrf, apiV3FormValidator, async(req, res) => {
     const options = {};
 
-    const pagesInTrash = await Page.findChildrenByParentPathOrIdAndViewer('/trash', req.user);
+    const pagesInTrash = await crowi.pageService.findChildrenByParentPathOrIdAndViewer('/trash', req.user);
 
     const deletablePages = crowi.pageService.filterPagesByCanDeleteCompletely(pagesInTrash, req.user, true);
 

+ 25 - 11
packages/app/src/server/service/page-operation.ts

@@ -8,7 +8,9 @@ import { ObjectIdLike } from '../interfaces/mongoose-utils';
 
 const logger = loggerFactory('growi:services:page-operation');
 
-const { isEitherOfPathAreaOverlap, isPathAreaOverlap, isTrashPage } = pagePathUtils;
+const {
+  isEitherOfPathAreaOverlap, isPathAreaOverlap, isTrashPage, collectAncestorPaths,
+} = pagePathUtils;
 const AUTO_UPDATE_INTERVAL_SEC = 5;
 
 const {
@@ -34,8 +36,10 @@ class PageOperationService {
    */
   async afterExpressServerReady(): Promise<void> {
     try {
+      const pageOps = await PageOperation.find({ actionType: PageActionType.Rename, actionStage: PageActionStage.Sub })
+        .sort({ createdAt: 'asc' });
       // execute rename operation
-      await this.executeAllRenameOperationBySystem();
+      await this.executeAllRenameOperationBySystem(pageOps);
     }
     catch (err) {
       logger.error(err);
@@ -45,17 +49,12 @@ class PageOperationService {
   /**
    * Execute renameSubOperation on every page operation for rename ordered by createdAt ASC
    */
-  private async executeAllRenameOperationBySystem(): Promise<void> {
-    const Page = this.crowi.model('Page');
-
-    const pageOps = await PageOperation.find({ actionType: PageActionType.Rename, actionStage: PageActionStage.Sub })
-      .sort({ createdAt: 'asc' });
+  private async executeAllRenameOperationBySystem(pageOps: PageOperationDocument[]): Promise<void> {
     if (pageOps.length === 0) return;
 
+    const Page = this.crowi.model('Page');
+
     for await (const pageOp of pageOps) {
-      const {
-        page, toPath, options, user,
-      } = pageOp;
 
       const renamedPage = await Page.findById(pageOp.page._id);
       if (renamedPage == null) {
@@ -64,7 +63,7 @@ class PageOperationService {
       }
 
       // rename
-      await this.crowi.pageService.renameSubOperation(page, toPath, user, options, renamedPage, pageOp._id);
+      await this.crowi.pageService.resumeRenameSubOperation(renamedPage, pageOp);
     }
   }
 
@@ -169,6 +168,21 @@ class PageOperationService {
     clearInterval(timerObj);
   }
 
+  /**
+   * Get ancestor's paths using fromPath and toPath. Merge same paths if any.
+   */
+  getAncestorsPathsByFromAndToPath(fromPath: string, toPath: string): string[] {
+    const fromAncestorsPaths = collectAncestorPaths(fromPath);
+    const toAncestorsPaths = collectAncestorPaths(toPath);
+    // merge duplicate paths and return paths of ancestors
+    return Array.from(new Set(toAncestorsPaths.concat(fromAncestorsPaths)));
+  }
+
+  async getRenameSubOperationByPageId(pageId: ObjectIdLike): Promise<PageOperationDocument | null> {
+    const filter = { actionType: PageActionType.Rename, actionStage: PageActionStage.Sub, 'page._id': pageId };
+    return PageOperation.findOne(filter);
+  }
+
 }
 
 export default PageOperationService;

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

@@ -29,7 +29,7 @@ import { prepareDeleteConfigValuesForCalc } from '~/utils/page-delete-config';
 
 import { ObjectIdLike } from '../interfaces/mongoose-utils';
 import { PathAlreadyExistsError } from '../models/errors';
-import PageOperation, { PageActionStage, PageActionType } from '../models/page-operation';
+import PageOperation, { PageActionStage, PageActionType, PageOperationDocument } from '../models/page-operation';
 import { PageRedirectModel } from '../models/page-redirect';
 import { serializePageSecurely } from '../models/serializers/page-serializer';
 import Subscription from '../models/subscription';
@@ -618,11 +618,7 @@ class PageService {
     await PageOperation.findByIdAndDelete(pageOpId);
   }
 
-  async resumeRenameSubOperation(renamedPage: PageDocument): Promise<void> {
-
-    // findOne PageOperation
-    const filter = { actionType: PageActionType.Rename, actionStage: PageActionStage.Sub, 'page._id': renamedPage._id };
-    const pageOp = await PageOperation.findOne(filter);
+  async resumeRenameSubOperation(renamedPage: PageDocument, pageOp: PageOperationDocument): Promise<void> {
     if (pageOp == null) {
       throw Error('There is nothing to be processed right now');
     }
@@ -630,18 +626,26 @@ class PageService {
     if (!isProcessable) {
       throw Error('This page operation is currently being processed');
     }
+    if (pageOp.toPath == null) {
+      throw Error(`Property toPath is missing which is needed to resume rename operation(${pageOp._id})`);
+    }
 
     const {
-      page, toPath, options, user,
+      page, fromPath, toPath, options, user,
     } = pageOp;
 
-    // check property
-    if (toPath == null) {
-      throw Error(`Property toPath is missing which is needed to resume page operation(${pageOp._id})`);
-    }
-
-    this.renameSubOperation(page, toPath, user, options, renamedPage, pageOp._id);
+    this.fixPathsAndDescendantCountOfAncestors(page, user, options, renamedPage, pageOp._id, fromPath, toPath);
+  }
 
+  /**
+   * Renaming paths and fixing descendantCount of ancestors. It shoud be run synchronously.
+   * `renameSubOperation` to restart rename operation
+   * `updateDescendantCountOfPagesWithPaths` to fix descendantCount of ancestors
+   */
+  private async fixPathsAndDescendantCountOfAncestors(page, user, options, renamedPage, pageOpId, fromPath, toPath): Promise<void> {
+    await this.renameSubOperation(page, toPath, user, options, renamedPage, pageOpId);
+    const ancestorsPaths = this.crowi.pageOperationService.getAncestorsPathsByFromAndToPath(fromPath, toPath);
+    await this.updateDescendantCountOfPagesWithPaths(ancestorsPaths);
   }
 
   private isRenamingToUnderTarget(fromPath: string, toPath: string): boolean {
@@ -3066,8 +3070,30 @@ class PageService {
     builder.addConditionToSortPagesByDescPath();
 
     const aggregatedPages = await builder.query.lean().cursor({ batchSize: BATCH_SIZE });
+    await this.recountAndUpdateDescendantCountOfPages(aggregatedPages, BATCH_SIZE);
+  }
 
+  /**
+   * update descendantCount of the pages sequentially from longer path to shorter path
+   */
+  async updateDescendantCountOfPagesWithPaths(paths: string[]): Promise<void> {
+    const BATCH_SIZE = 200;
+    const Page = this.crowi.model('Page');
+    const { PageQueryBuilder } = Page;
+
+    const builder = new PageQueryBuilder(Page.find(), true);
+    builder.addConditionToListByPathsArray(paths); // find by paths
+    builder.addConditionToSortPagesByDescPath(); // sort in DESC
+
+    const aggregatedPages = await builder.query.lean().cursor({ batchSize: BATCH_SIZE });
+    await this.recountAndUpdateDescendantCountOfPages(aggregatedPages, BATCH_SIZE);
+  }
 
+  /**
+   * Recount descendantCount of pages one by one
+   */
+  async recountAndUpdateDescendantCountOfPages(pageCursor: QueryCursor<any>, batchSize:number): Promise<void> {
+    const Page = this.crowi.model('Page');
     const recountWriteStream = new Writable({
       objectMode: true,
       async write(pageDocuments, encoding, callback) {
@@ -3081,8 +3107,8 @@ class PageService {
         callback();
       },
     });
-    aggregatedPages
-      .pipe(createBatchStream(BATCH_SIZE))
+    pageCursor
+      .pipe(createBatchStream(batchSize))
       .pipe(recountWriteStream);
 
     await streamToPromise(recountWriteStream);

+ 0 - 1
packages/app/src/server/views/layout-growi/identical-path-page.html

@@ -11,7 +11,6 @@
       data-path="{{ encodeURI(path) }}"
       data-current-user="{% if user %}{{ user._id.toString() }}{% endif %}"
       data-page-is-not-creatable="true"
-      data-page-is-deleted="{% if page.isDeleted() %}true{% else %}false{% endif %}"
       data-identical-path="true"
     >
       <div class="flex-grow-1 flex-basis-0 mw-0">

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

@@ -12,7 +12,6 @@
   data-page-grant="{{ grant }}"
   data-page-grant-group="{{ grantedGroupId }}"
   data-page-grant-group-name="{{ grantedGroupName }}"
-  data-page-is-deleted="{% if page.isDeleted() %}true{% else %}false{% endif %}"
   data-page-is-not-creatable="false"
   data-page-created-at="{{ page.createdAt|datetz('Y/m/d H:i:s') }}"
   data-page-creator="{% if page && page.creator %}{{ page.creator|json }}{% endif %}"
@@ -30,7 +29,6 @@
 <div id="content-main" class="content-main d-flex"
   data-path="{{ encodeURI(path) }}"
   data-current-user="{% if user %}{{ user._id.toString() }}{% endif %}"
-  data-page-is-deleted="{% if page.isDeleted() %}true{% else %}false{% endif %}"
   data-page-has-children="{% if pages.length > 0 %}true{% else %}false{% endif %}"
   >
 {% endif %}

+ 0 - 4
packages/app/src/stores/context.tsx

@@ -68,10 +68,6 @@ export const useIsTrashPage = (initialData?: boolean): SWRResponse<boolean, Erro
   return useStaticSWR<boolean, Error>('isTrashPage', initialData, { fallbackData: false });
 };
 
-export const useIsDeleted = (initialData?: boolean): SWRResponse<boolean, Error> => {
-  return useStaticSWR<boolean, Error>('isDeleted', initialData, { fallbackData: false });
-};
-
 export const useIsNotCreatable = (initialData?: boolean): SWRResponse<boolean, Error> => {
   return useStaticSWR<boolean, Error>('isNotCreatable', initialData, { fallbackData: false });
 };

+ 3 - 3
packages/app/src/stores/ui.tsx

@@ -19,7 +19,7 @@ import loggerFactory from '~/utils/logger';
 
 import {
   useCurrentPageId, useCurrentPagePath, useIsEditable, useIsTrashPage, useIsUserPage, useIsGuestUser, useEmptyPageId,
-  useIsNotCreatable, useIsSharedUser, useNotFoundTargetPathOrId, useIsForbidden, useIsIdenticalPath, useIsNotFoundPermalink, useCurrentUser, useIsDeleted,
+  useIsNotCreatable, useIsSharedUser, useNotFoundTargetPathOrId, useIsForbidden, useIsIdenticalPath, useIsNotFoundPermalink, useCurrentUser,
 } from './context';
 import { localStorageMiddleware } from './middlewares/sync-to-storage';
 import { useStaticSWR } from './use-static-swr';
@@ -396,9 +396,9 @@ export const usePageTreeDescCountMap = (initialData?: UpdateDescCountData): SWRR
 
 export const useIsAbleToShowTrashPageManagementButtons = (): SWRResponse<boolean, Error> => {
   const { data: currentUser } = useCurrentUser();
-  const { data: isDeleted } = useIsDeleted();
+  const { data: isTrashPage } = useIsTrashPage();
 
-  return useStaticSWR('isAbleToShowTrashPageManagementButtons', isDeleted && currentUser != null);
+  return useStaticSWR('isAbleToShowTrashPageManagementButtons', isTrashPage && currentUser != null);
 };
 
 export const useIsAbleToShowPageManagement = (): SWRResponse<boolean, Error> => {

+ 174 - 0
packages/app/test/cypress/integration/30-search/search.spec.ts

@@ -68,3 +68,177 @@ context('Access to legacy private pages', () => {
   });
 
 });
+
+context('Search all pages', () => {
+  const ssPrefix = 'search-all-pages-';
+
+  beforeEach(() => {
+    // login
+    cy.fixture("user-admin.json").then(user => {
+      cy.login(user.username, user.password);
+    });
+    // collapse sidebar
+    cy.collapseSidebar(true);
+  });
+
+  it(`Search all pages by word is successfully loaded`, () => {
+    const searchText = 'help';
+
+    cy.visit('/');
+    cy.get('.rbt-input').click();
+    cy.get('.rbt-menu.dropdown-menu.show').should('be.visible').within(() => {
+      cy.screenshot(`${ssPrefix}1-search-input-focused`);
+    })
+
+    cy.get('.rbt-input-main').type(`${searchText}`);
+    cy.screenshot(`${ssPrefix}2-insert-search-text`, { capture: 'viewport'});
+    cy.get('.rbt-input-main').type('{enter}');
+
+
+    cy.getByTestid('search-result-base').should('be.visible');
+    cy.getByTestid('search-result-list').should('be.visible');
+    cy.getByTestid('search-result-content').should('be.visible');
+    cy.screenshot(`${ssPrefix}3-search-page-results`, { capture: 'viewport'});
+
+    cy.getByTestid('open-page-item-control-btn').eq(1).click();
+    cy.screenshot(`${ssPrefix}4-click-three-dots-menu`, {capture: 'viewport'});
+
+    //Add bookmark
+    cy.getByTestid('add-remove-bookmark-btn').click({force: true});
+    cy.get('.btn-bookmark.active').should('be.visible');
+    cy.screenshot(`${ssPrefix}5-add-bookmark`, {capture: 'viewport'});
+
+    // Duplicate page
+    cy.getByTestid('open-page-duplicate-modal-btn').first().click({force: true});
+    cy.getByTestid('page-duplicate-modal').should('be.visible');
+    cy.screenshot(`${ssPrefix}6-duplicate-page`, {capture: 'viewport'});
+
+    // Close Modal
+    cy.get('body').type('{esc}');
+
+    // Move / Rename Page
+    cy.getByTestid('open-page-move-rename-modal-btn').first().click({force: true});
+    cy.getByTestid('page-rename-modal').should('be.visible');
+    cy.screenshot(`${ssPrefix}7-move-rename-page`, {capture: 'viewport'});
+
+    // Close Modal
+    cy.get('body').type('{esc}');
+
+    // Delete page
+    cy.getByTestid('open-page-delete-modal-btn').first().click({ force: true});
+    cy.getByTestid('page-delete-modal').should('be.visible');
+    cy.screenshot(`${ssPrefix}8-delete-page`, {capture: 'viewport'});
+  });
+
+  it(`Search all pages by tag is successfully loaded `, () => {
+    const tag = 'help';
+    const searchText = `tag:${tag}`;
+    cy.visit('/');
+    // Add tag
+    cy.get('#edit-tags-btn-wrapper-for-tooltip > a').click({force: true});
+    cy.get('#edit-tag-modal').should('be.visible');
+
+    cy.get('#edit-tag-modal').within(() => {
+      cy.get('.rbt-input-main').type(tag);
+      cy.get('#tag-typeahead-asynctypeahead').should('be.visible');
+      cy.get('#tag-typeahead-asynctypeahead-item-0').should('be.visible');
+      cy.get('a#tag-typeahead-asynctypeahead-item-0').click({force: true})
+    });
+
+    cy.get('#edit-tag-modal').within(() => {
+      cy.get('div.modal-footer > button').click();
+    });
+
+    cy.visit('/');
+    cy.get('.rbt-input').click();
+    cy.get('.rbt-input-main').type(`${searchText}`);
+    cy.screenshot(`${ssPrefix}1-insert-search-text-with-tag`, { capture: 'viewport'});
+    cy.get('.rbt-input-main').type('{enter}');
+
+    cy.getByTestid('search-result-base').should('be.visible');
+    cy.getByTestid('search-result-list').should('be.visible');
+    cy.getByTestid('search-result-content').should('be.visible');
+
+    cy.screenshot(`${ssPrefix}2-search-with-tag-result`, {capture: 'viewport'});
+    cy.getByTestid('open-page-item-control-btn').first().click();
+    cy.screenshot(`${ssPrefix}3-click-three-dots-menu-search-with-tag`, {capture: 'viewport'});
+
+  });
+  it('Successfully order page search results by tag', () => {
+    const tag = 'help';
+
+    cy.visit('/');
+    cy.get('.grw-taglabels-container > form > a').contains(tag).click();
+    cy.getByTestid('search-result-base').should('be.visible');
+    cy.getByTestid('search-result-list').should('be.visible');
+    cy.getByTestid('search-result-content').should('be.visible');
+    cy.screenshot(`${ssPrefix}1-tag-order-click-tag-name`, {capture: 'viewport'});
+
+    cy.get('.grw-search-page-nav').within(() => {
+      cy.get('button.dropdown-toggle').first().click({force: true});
+      cy.get('.dropdown-menu-right').should('be.visible');
+      cy.get('.dropdown-menu-right > button:nth-child(1)').click({force: true});
+    });
+    cy.getByTestid('search-result-base').should('be.visible');
+    cy.getByTestid('search-result-list').should('be.visible');
+    cy.getByTestid('search-result-content').should('be.visible');
+    cy.screenshot(`${ssPrefix}2-tag-order-by-relevance`);
+
+    cy.get('.grw-search-page-nav').within(() => {
+      cy.get('button.dropdown-toggle').first().click({force: true});
+      cy.get('.dropdown-menu-right').should('be.visible');
+      cy.get('.dropdown-menu-right > button:nth-child(2)').click({force: true});
+    });
+    cy.getByTestid('search-result-base').should('be.visible');
+    cy.getByTestid('search-result-list').should('be.visible');
+    cy.getByTestid('search-result-content').should('be.visible');
+    cy.screenshot(`${ssPrefix}3-tag-order-by-creation-date`);
+
+    cy.get('.grw-search-page-nav').within(() => {
+      cy.get('button.dropdown-toggle').first().click({force: true});
+      cy.get('.dropdown-menu-right').should('be.visible');
+      cy.get('.dropdown-menu-right > button:nth-child(3)').click({force: true});
+    });
+    cy.getByTestid('search-result-base').should('be.visible');
+    cy.getByTestid('search-result-list').should('be.visible');
+    cy.getByTestid('search-result-content').should('be.visible');
+    cy.screenshot(`${ssPrefix}4-tag-order-by-last-update-date`);
+  });
+
+});
+
+context('Search current tree with "prefix":', () => {
+  const ssPrefix = 'search-current-tree-';
+
+  beforeEach(() => {
+    // login
+    cy.fixture("user-admin.json").then(user => {
+      cy.login(user.username, user.password);
+    });
+    // collapse sidebar
+    cy.collapseSidebar(true);
+  });
+
+  it(`Search current tree by word is successfully loaded`, () => {
+    const searchText = 'help';
+    cy.visit('/');
+    cy.getByTestid('select-search-scope').first().click({force: true});
+    cy.get('.input-group-prepend.show > div > button:nth-child(2)').click({force: true});
+    cy.get('.rbt-input').click();
+    cy.get('.rbt-menu.dropdown-menu.show').should('be.visible').within(() => {
+      cy.screenshot(`${ssPrefix}1-search-input-focused`);
+    })
+    cy.get('.rbt-input').type(`${searchText}`);
+    cy.screenshot(`${ssPrefix}2-insert-search-text`, { capture: 'viewport'});
+    cy.get('.rbt-input').type('{enter}');
+
+    cy.getByTestid('search-result-base').should('be.visible');
+    cy.getByTestid('search-result-list').should('be.visible');
+    cy.getByTestid('search-result-content').should('be.visible');
+    cy.screenshot(`${ssPrefix}3-search-page-results`, { capture: 'viewport'});
+
+    cy.getByTestid('open-page-item-control-btn').first().click();
+    cy.screenshot(`${ssPrefix}4-click-three-dots-menu`, {capture: 'viewport'});
+  });
+
+});

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

@@ -95,14 +95,6 @@ describe('Test page service methods', () => {
     const pageId7 = new mongoose.Types.ObjectId();
     const pageId8 = new mongoose.Types.ObjectId();
     const pageId9 = new mongoose.Types.ObjectId();
-    const pageId10 = new mongoose.Types.ObjectId();
-    const pageId11 = new mongoose.Types.ObjectId();
-    const pageId12 = new mongoose.Types.ObjectId();
-    const pageId13 = new mongoose.Types.ObjectId();
-    const pageId14 = new mongoose.Types.ObjectId();
-    const pageId15 = new mongoose.Types.ObjectId();
-    const pageId16 = new mongoose.Types.ObjectId();
-    const pageId17 = new mongoose.Types.ObjectId();
 
     await Page.insertMany([
       {
@@ -158,7 +150,7 @@ describe('Test page service methods', () => {
       {
         _id: pageId5,
         path: '/resume_rename_4/resume_rename_5',
-        parent: pageId0,
+        parent: pageId4,
         grant: Page.GRANT_PUBLIC,
         creator: dummyUser1,
         lastUpdateUser: dummyUser1._id,
@@ -214,95 +206,6 @@ describe('Test page service methods', () => {
         descendantCount: 0,
         isEmpty: false,
       },
-      {
-        _id: pageId10,
-        path: '/resume_rename_11',
-        parent: rootPage._id,
-        grant: Page.GRANT_PUBLIC,
-        creator: dummyUser1,
-        lastUpdateUser: dummyUser1._id,
-        descendantCount: 3,
-        isEmpty: false,
-      },
-      {
-        _id: pageId11,
-        path: '/resume_rename_11/resume_rename_12',
-        parent: pageId10,
-        grant: Page.GRANT_PUBLIC,
-        creator: dummyUser1,
-        lastUpdateUser: dummyUser1._id,
-        descendantCount: 2,
-        isEmpty: false,
-      },
-      {
-        _id: pageId12,
-        path: '/resume_rename_11/resume_rename_12/resume_rename_13',
-        parent: pageId11,
-        grant: Page.GRANT_PUBLIC,
-        creator: dummyUser1,
-        lastUpdateUser: dummyUser1._id,
-        descendantCount: 1,
-        isEmpty: false,
-      },
-      {
-        path: '/resume_rename_11/resume_rename_12/resume_rename_13/resume_rename_14',
-        parent: pageId12,
-        grant: Page.GRANT_PUBLIC,
-        creator: dummyUser1,
-        lastUpdateUser: dummyUser1._id,
-        descendantCount: 0,
-        isEmpty: false,
-      },
-      {
-        _id: pageId13,
-        path: '/resume_rename_15',
-        parent: rootPage._id,
-        grant: Page.GRANT_PUBLIC,
-        creator: dummyUser1,
-        lastUpdateUser: dummyUser1._id,
-        descendantCount: 2,
-        isEmpty: false,
-      },
-      {
-        _id: pageId14,
-        path: '/resume_rename_15/resume_rename_16',
-        parent: pageId13,
-        grant: Page.GRANT_PUBLIC,
-        creator: dummyUser1,
-        lastUpdateUser: dummyUser1._id,
-        descendantCount: 0,
-        isEmpty: false,
-      },
-      {
-        _id: pageId15,
-        path: '/resume_rename_15/resume_rename_17',
-        parent: pageId13,
-        grant: Page.GRANT_PUBLIC,
-        creator: dummyUser1,
-        lastUpdateUser: dummyUser1._id,
-        descendantCount: 1,
-        isEmpty: false,
-      },
-      {
-        _id: pageId16,
-        path: '/resume_rename_15/resume_rename_17/resume_rename_18',
-        parent: pageId15,
-        grant: Page.GRANT_PUBLIC,
-        creator: dummyUser1,
-        lastUpdateUser: dummyUser1._id,
-        descendantCount: 1,
-        isEmpty: false,
-      },
-      {
-        _id: pageId17,
-        path: '/resume_rename_15/resume_rename_17/resume_rename_18/resume_rename_19',
-        parent: pageId16,
-        grant: Page.GRANT_PUBLIC,
-        creator: dummyUser1,
-        lastUpdateUser: dummyUser1._id,
-        descendantCount: 0,
-        isEmpty: false,
-      },
     ]);
 
     /**
@@ -312,14 +215,10 @@ describe('Test page service methods', () => {
     pageOpId2 = new mongoose.Types.ObjectId();
     pageOpId3 = new mongoose.Types.ObjectId();
     pageOpId4 = new mongoose.Types.ObjectId();
-    pageOpId5 = new mongoose.Types.ObjectId();
-    pageOpId6 = new mongoose.Types.ObjectId();
     const pageOpRevisionId1 = new mongoose.Types.ObjectId();
     const pageOpRevisionId2 = new mongoose.Types.ObjectId();
     const pageOpRevisionId3 = new mongoose.Types.ObjectId();
     const pageOpRevisionId4 = new mongoose.Types.ObjectId();
-    const pageOpRevisionId5 = new mongoose.Types.ObjectId();
-    const pageOpRevisionId6 = new mongoose.Types.ObjectId();
 
     await PageOperation.insertMany([
       {
@@ -438,76 +337,18 @@ describe('Test page service methods', () => {
         },
         unprocessableExpiryDate: null,
       },
-      {
-        _id: pageOpId5,
-        actionType: 'Rename',
-        actionStage: 'Sub',
-        fromPath: '/resume_rename_11/resume_rename_13',
-        toPath: '/resume_rename_11/resume_rename_12/resume_rename_13',
-        page: {
-          _id: pageId12,
-          parent: pageId10,
-          descendantCount: 1,
-          isEmpty: false,
-          path: '/resume_rename_11/resume_rename_13',
-          revision: pageOpRevisionId5,
-          status: 'published',
-          grant: Page.GRANT_PUBLIC,
-          grantedUsers: [],
-          grantedGroup: null,
-          creator: dummyUser1._id,
-          lastUpdateUser: dummyUser1._id,
-        },
-        user: {
-          _id: dummyUser1._id,
-        },
-        options: {
-          createRedirectPage: false,
-          updateMetadata: true,
-        },
-        unprocessableExpiryDate: new Date(),
-      },
-      {
-        _id: pageOpId6,
-        actionType: 'Rename',
-        actionStage: 'Sub',
-        fromPath: '/resume_rename_15/resume_rename_16/resume_rename_18',
-        toPath: '/resume_rename_15/resume_rename_17/resume_rename_18',
-        page: {
-          _id: pageId16,
-          parent: pageId14,
-          descendantCount: 1,
-          isEmpty: false,
-          path: '/resume_rename_15/resume_rename_16/resume_rename_18',
-          revision: pageOpRevisionId6,
-          status: 'published',
-          grant: Page.GRANT_PUBLIC,
-          grantedUsers: [],
-          grantedGroup: null,
-          creator: dummyUser1._id,
-          lastUpdateUser: dummyUser1._id,
-        },
-        user: {
-          _id: dummyUser1._id,
-        },
-        options: {
-          createRedirectPage: false,
-          updateMetadata: true,
-        },
-        unprocessableExpiryDate: new Date(),
-      },
     ]);
   });
 
   describe('restart renameOperation', () => {
-    const resumeRenameSubOperation = async(page) => {
-      const mockedRenameSubOperation = jest.spyOn(crowi.pageService, 'renameSubOperation').mockReturnValue(null);
-      await crowi.pageService.resumeRenameSubOperation(page);
+    const resumeRenameSubOperation = async(renamePage, pageOp) => {
+      const mockedPathsAndDescendantCountOfAncestors = jest.spyOn(crowi.pageService, 'fixPathsAndDescendantCountOfAncestors').mockReturnValue(null);
+      await crowi.pageService.resumeRenameSubOperation(renamePage, pageOp);
 
-      const argsForRenameSubOperation = mockedRenameSubOperation.mock.calls[0];
+      const argsForRenameSubOperation = mockedPathsAndDescendantCountOfAncestors.mock.calls[0];
 
-      mockedRenameSubOperation.mockRestore();
-      await crowi.pageService.renameSubOperation(...argsForRenameSubOperation);
+      mockedPathsAndDescendantCountOfAncestors.mockRestore();
+      await crowi.pageService.fixPathsAndDescendantCountOfAncestors(...argsForRenameSubOperation);
     };
 
     test('it should successfully restart rename operation', async() => {
@@ -547,7 +388,7 @@ describe('Test page service methods', () => {
       expect(_pageOperation).toBeTruthy();
 
       // rename
-      await resumeRenameSubOperation(_page1);
+      await resumeRenameSubOperation(_page1, _pageOperation);
 
       // page
       const page0 = await Page.findById(_page0._id);
@@ -605,7 +446,7 @@ describe('Test page service methods', () => {
       expect(_pageOperation).toBeTruthy();
 
       // rename
-      await resumeRenameSubOperation(_page1);
+      await resumeRenameSubOperation(_page1, _pageOperation);
 
       // page
       const page0 = await Page.findById(_page0._id);
@@ -632,38 +473,22 @@ describe('Test page service methods', () => {
     });
 
     test('it should fail and throw error if PageOperation is not found', async() => {
-      // create dummy page operation data not stored in DB
-      const notExistPageOp = {
+      const dummyPage = {
         _id: new mongoose.Types.ObjectId(),
-        actionType: 'Rename',
-        actionStage: 'Sub',
-        fromPath: '/FROM_NOT_EXIST',
-        toPath: 'TO_NOT_EXIST',
-        page: {
-          _id: new mongoose.Types.ObjectId(),
-          parent: rootPage._id,
-          descendantCount: 2,
-          isEmpty: false,
-          path: '/NOT_EXIST_PAGE',
-          revision: new mongoose.Types.ObjectId(),
-          status: 'published',
-          grant: 1,
-          grantedUsers: [],
-          grantedGroup: null,
-          creator: dummyUser1._id,
-          lastUpdateUser: dummyUser1._id,
-        },
-        user: {
-          _id: dummyUser1._id,
-        },
-        options: {
-          createRedirectPage: false,
-          updateMetadata: false,
-        },
-        unprocessableExpiryDate: new Date(),
+        parent: rootPage._id,
+        descendantCount: 2,
+        isEmpty: false,
+        path: '/NOT_EXIST_PAGE',
+        revision: new mongoose.Types.ObjectId(),
+        status: 'published',
+        grant: 1,
+        grantedUsers: [],
+        grantedGroup: null,
+        creator: dummyUser1._id,
+        lastUpdateUser: dummyUser1._id,
       };
 
-      await expect(resumeRenameSubOperation(notExistPageOp))
+      await expect(resumeRenameSubOperation(dummyPage, null))
         .rejects.toThrow(new Error('There is nothing to be processed right now'));
     });
 
@@ -683,16 +508,17 @@ describe('Test page service methods', () => {
       // page operation
       const fromPath = '/resume_rename_5';
       const toPath = '/resume_rename_4/resume_rename_5';
-      const pageOperation = await PageOperation.findOne({
+      const _pageOperation = await PageOperation.findOne({
         _id: pageOpId2, fromPath, toPath, 'page._id': _page1._id, actionType: PageActionType.Rename, actionStage: PageActionStage.Sub,
       });
-      expect(pageOperation).toBeTruthy();
+      expect(_pageOperation).toBeTruthy();
 
       // Make `unprocessableExpiryDate` 15 seconds ahead of current time.
       // The number 15 seconds has no meaning other than placing time in the furue.
-      await PageOperation.findByIdAndUpdate(pageOperation._id, { unprocessableExpiryDate: addSeconds(new Date(), 15) });
+      const pageOperation = await PageOperation.findByIdAndUpdate(_pageOperation._id, { unprocessableExpiryDate: addSeconds(new Date(), 15) }, { new: true });
+      expect(pageOperation).toBeTruthy();
 
-      await expect(resumeRenameSubOperation(_page1)).rejects.toThrow(new Error('This page operation is currently being processed'));
+      await expect(resumeRenameSubOperation(_page1, pageOperation)).rejects.toThrow(new Error('This page operation is currently being processed'));
 
       // cleanup
       await PageOperation.findByIdAndDelete(pageOperation._id);
@@ -710,152 +536,11 @@ describe('Test page service methods', () => {
       });
       expect(pageOperation).toBeTruthy();
 
-      const promise = resumeRenameSubOperation(_page1);
-      await expect(promise).rejects.toThrow(new Error(`Property toPath is missing which is needed to resume page operation(${pageOperation._id})`));
+      const promise = resumeRenameSubOperation(_page1, pageOperation);
+      await expect(promise).rejects.toThrow(new Error(`Property toPath is missing which is needed to resume rename operation(${pageOperation._id})`));
 
       // cleanup
       await PageOperation.findByIdAndDelete(pageOperation._id);
     });
-
-    test(`it should succeed but 2 extra descendantCount should be added
-    if the page operation was interrupted right after increasing ancestor's descendantCount in renameSubOperation`, async() => {
-      // paths before renaming
-      const _path0 = '/resume_rename_11'; // out of renaming scope
-      const _path1 = '/resume_rename_11/resume_rename_12'; // out of renaming scope
-      const _path2 = '/resume_rename_11/resume_rename_12/resume_rename_13'; // renamed already
-      const _path3 = '/resume_rename_11/resume_rename_12/resume_rename_13/resume_rename_14'; // renamed already
-
-      // paths after renaming
-      const path0 = '/resume_rename_11';
-      const path1 = '/resume_rename_11/resume_rename_12';
-      const path2 = '/resume_rename_11/resume_rename_12/resume_rename_13';
-      const path3 = '/resume_rename_11/resume_rename_12/resume_rename_13/resume_rename_14';
-
-      // page
-      const _page0 = await Page.findOne({ path: _path0 });
-      const _page1 = await Page.findOne({ path: _path1 });
-      const _page2 = await Page.findOne({ path: _path2 });
-      const _page3 = await Page.findOne({ path: _path3 });
-      expect(_page0).toBeTruthy();
-      expect(_page1).toBeTruthy();
-      expect(_page2).toBeTruthy();
-      expect(_page3).toBeTruthy();
-
-      // descendantCount
-      expect(_page0.descendantCount).toBe(3);
-      expect(_page1.descendantCount).toBe(2);
-      expect(_page2.descendantCount).toBe(1);
-      expect(_page3.descendantCount).toBe(0);
-
-      // page operation
-      const fromPath = '/resume_rename_11/resume_rename_13';
-      const toPath = '/resume_rename_11/resume_rename_12/resume_rename_13';
-      const _pageOperation = await PageOperation.findOne({
-        _id: pageOpId5, fromPath, toPath, 'page._id': _page2._id, actionType: PageActionType.Rename, actionStage: PageActionStage.Sub,
-      });
-      expect(_pageOperation).toBeTruthy();
-
-      // rename
-      await resumeRenameSubOperation(_page2);
-
-      // page
-      const page0 = await Page.findById(_page0._id);
-      const page1 = await Page.findById(_page1._id);
-      const page2 = await Page.findById(_page2._id);
-      const page3 = await Page.findById(_page3._id);
-      expect(page0).toBeTruthy();
-      expect(page1).toBeTruthy();
-      expect(page2).toBeTruthy();
-      expect(page3).toBeTruthy();
-      expect(page0.path).toBe(path0);
-      expect(page1.path).toBe(path1);
-      expect(page2.path).toBe(path2);
-      expect(page3.path).toBe(path3);
-
-      // page operation
-      const pageOperation = await PageOperation.findById(_pageOperation._id);
-      expect(pageOperation).toBeNull(); // should not exist
-
-      // 2 extra descendants should be added to page1
-      expect(page0.descendantCount).toBe(3);
-      expect(page1.descendantCount).toBe(3); // originally 2, +1 in Main, -1 in Sub, +2 for new descendants
-      expect(page2.descendantCount).toBe(1);
-      expect(page3.descendantCount).toBe(0);
-    });
-
-    test(`it should succeed but 2 extra descendantCount should be subtracted from ex parent page
-    if the page operation was interrupted right after reducing ancestor's descendantCount in renameSubOperation`, async() => {
-      // paths before renaming
-      const _path0 = '/resume_rename_15'; // out of renaming scope
-      const _path1 = '/resume_rename_15/resume_rename_16'; // out of renaming scope
-      const _path2 = '/resume_rename_15/resume_rename_17'; // out of renaming scope
-      const _path3 = '/resume_rename_15/resume_rename_17/resume_rename_18'; // renamed already
-      const _path4 = '/resume_rename_15/resume_rename_17/resume_rename_18/resume_rename_19'; // renamed already
-
-      // paths after renaming
-      const path0 = '/resume_rename_15';
-      const path1 = '/resume_rename_15/resume_rename_16';
-      const path2 = '/resume_rename_15/resume_rename_17';
-      const path3 = '/resume_rename_15/resume_rename_17/resume_rename_18';
-      const path4 = '/resume_rename_15/resume_rename_17/resume_rename_18/resume_rename_19';
-
-      // page
-      const _page0 = await Page.findOne({ path: _path0 });
-      const _page1 = await Page.findOne({ path: _path1 });
-      const _page2 = await Page.findOne({ path: _path2 });
-      const _page3 = await Page.findOne({ path: _path3 });
-      const _page4 = await Page.findOne({ path: _path4 });
-      expect(_page0).toBeTruthy();
-      expect(_page1).toBeTruthy();
-      expect(_page2).toBeTruthy();
-      expect(_page3).toBeTruthy();
-      expect(_page4).toBeTruthy();
-
-      // descendantCount
-      expect(_page0.descendantCount).toBe(2);
-      expect(_page1.descendantCount).toBe(0);
-      expect(_page2.descendantCount).toBe(1);
-      expect(_page3.descendantCount).toBe(1);
-      expect(_page4.descendantCount).toBe(0);
-
-      // page operation
-      const fromPath = '/resume_rename_15/resume_rename_16/resume_rename_18';
-      const toPath = '/resume_rename_15/resume_rename_17/resume_rename_18';
-      const _pageOperation = await PageOperation.findOne({
-        _id: pageOpId6, fromPath, toPath, 'page._id': _page3._id, actionType: PageActionType.Rename, actionStage: PageActionStage.Sub,
-      });
-      expect(_pageOperation).toBeTruthy();
-
-      // rename
-      await resumeRenameSubOperation(_page3);
-
-      // page
-      const page0 = await Page.findById(_page0._id);
-      const page1 = await Page.findById(_page1._id);
-      const page2 = await Page.findById(_page2._id);
-      const page3 = await Page.findById(_page3._id);
-      const page4 = await Page.findById(_page4._id);
-      expect(page0).toBeTruthy();
-      expect(page1).toBeTruthy();
-      expect(page2).toBeTruthy();
-      expect(page3).toBeTruthy();
-      expect(page3).toBeTruthy();
-      expect(page0.path).toBe(path0);
-      expect(page1.path).toBe(path1);
-      expect(page2.path).toBe(path2);
-      expect(page3.path).toBe(path3);
-      expect(page4.path).toBe(path4);
-
-      // page operation
-      const pageOperation = await PageOperation.findById(_pageOperation._id);
-      expect(pageOperation).toBeNull(); // should not exist
-
-      // 2 extra descendants should be subtracted from page1
-      expect(page0.descendantCount).toBe(2);
-      expect(page1.descendantCount).toBe(-2); // originally 0, -2 for old descendants
-      expect(page2.descendantCount).toBe(2); // originally 1, -1 in Sub, +2 for new descendants
-      expect(page3.descendantCount).toBe(1);
-      expect(page4.descendantCount).toBe(0);
-    });
   });
 });