Преглед изворни кода

Merge branch 'master' into feat/89895-spinner-out-until-the-renaming-request-is-complete

Shun Miyazawa пре 4 година
родитељ
комит
a0386e0d9e
27 измењених фајлова са 739 додато и 140 уклоњено
  1. 18 30
      packages/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx
  2. 1 1
      packages/app/src/components/Common/Dropdown/PageItemControl.tsx
  3. 1 1
      packages/app/src/components/SearchPage.tsx
  4. 1 1
      packages/app/src/components/SearchPage/SearchControl.tsx
  5. 3 1
      packages/app/src/components/Sidebar/PageTree.tsx
  6. 3 3
      packages/app/src/components/Sidebar/PageTree/Item.tsx
  7. 0 10
      packages/app/src/server/models/obsolete-page.js
  8. 12 11
      packages/app/src/server/models/page.ts
  9. 1 1
      packages/app/src/server/models/user-group.ts
  10. 3 3
      packages/app/src/server/routes/apiv3/pages.js
  11. 1 1
      packages/app/src/server/routes/apiv3/user-group.js
  12. 1 1
      packages/app/src/server/routes/page.js
  13. 9 3
      packages/app/src/server/service/page-grant.ts
  14. 43 23
      packages/app/src/server/service/page.ts
  15. 1 0
      packages/app/src/styles/_navbar.scss
  16. 3 0
      packages/app/src/styles/_variables.scss
  17. 15 1
      packages/app/src/styles/theme/_apply-colors-dark.scss
  18. 9 0
      packages/app/src/styles/theme/_apply-colors-light.scss
  19. 1 0
      packages/app/src/styles/theme/_apply-colors.scss
  20. 1 1
      packages/app/src/styles/theme/default.scss
  21. 1 4
      packages/app/src/styles/theme/mixins/_list-group.scss
  22. 31 0
      packages/app/test/cypress/integration/2-basic-features/switch-sidebar-contents.spec.ts
  23. 318 34
      packages/app/test/integration/service/v5.migration.test.js
  24. 201 0
      packages/app/test/integration/service/v5.non-public-page.test.ts
  25. 1 10
      packages/app/test/integration/service/v5.public-page.test.ts
  26. 2 0
      packages/core/src/index.js
  27. 58 0
      packages/core/src/utils/page-utils.ts

+ 18 - 30
packages/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx

@@ -192,36 +192,24 @@ const UserGroupDetailPage: FC = () => {
 
 
   return (
   return (
     <div>
     <div>
-      <a href="/admin/user-groups" className="btn btn-outline-secondary">
-        <i className="icon-fw ti-arrow-left" aria-hidden="true"></i>
-        {t('admin:user_group_management.back_to_list')}
-      </a>
-
-      {
-        userGroup?.parent != null && ancestorUserGroups != null && ancestorUserGroups.length > 0 && (
-          <div className="btn-group ml-2">
-            <a className="btn btn-outline-secondary" href={`/admin/user-group-detail/${userGroup.parent}`}>
-              <i className="icon-fw ti-arrow-left" aria-hidden="true"></i>
-              {t('admin:user_group_management.back_to_ancestors_group')}
-            </a>
-            <button
-              type="button"
-              className="btn btn-outline-secondary dropdown-toggle dropdown-toggle-split"
-              data-toggle="dropdown"
-              aria-haspopup="true"
-              ria-expanded="false"
-            >
-            </button>
-            <div className="dropdown-menu">
-              {
-                ancestorUserGroups.map(userGroup => (
-                  <a className="dropdown-item" key={userGroup._id} href={`/admin/user-group-detail/${userGroup._id}`}>{userGroup.name}</a>
-                ))
-              }
-            </div>
-          </div>
-        )
-      }
+      <nav aria-label="breadcrumb">
+        <ol className="breadcrumb">
+          <li className="breadcrumb-item"><a href="/admin/user-groups">{t('admin:user_group_management.group_list')}</a></li>
+          {
+            ancestorUserGroups != null && ancestorUserGroups.length > 0 && (
+              ancestorUserGroups.map((ancestorUserGroup: IUserGroupHasId) => (
+                <li key={ancestorUserGroup._id} className={`breadcrumb-item ${ancestorUserGroup._id === userGroup._id ? 'active' : ''}`} aria-current="page">
+                  { ancestorUserGroup._id === userGroup._id ? (
+                    <>{ancestorUserGroup.name}</>
+                  ) : (
+                    <a href={`/admin/user-group-detail/${ancestorUserGroup._id}`}>{ancestorUserGroup.name}</a>
+                  )}
+                </li>
+              ))
+            )
+          }
+        </ol>
+      </nav>
 
 
       <div className="mt-4 form-box">
       <div className="mt-4 form-box">
         <UserGroupForm
         <UserGroupForm

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

@@ -257,7 +257,7 @@ export const PageItemControlSubstance = (props: PageItemControlSubstanceProps):
     <Dropdown isOpen={isOpen} toggle={() => setIsOpen(!isOpen)} data-testid="open-page-item-control-btn">
     <Dropdown isOpen={isOpen} toggle={() => setIsOpen(!isOpen)} data-testid="open-page-item-control-btn">
       { children ?? (
       { children ?? (
         <DropdownToggle color="transparent" className="border-0 rounded btn-page-item-control d-flex align-items-center justify-content-center">
         <DropdownToggle color="transparent" className="border-0 rounded btn-page-item-control d-flex align-items-center justify-content-center">
-          <i className="icon-options text-muted"></i>
+          <i className="icon-options"></i>
         </DropdownToggle>
         </DropdownToggle>
       ) }
       ) }
 
 

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

@@ -204,7 +204,7 @@ export const SearchPage = (props: Props): JSX.Element => {
       >
       >
         <button
         <button
           type="button"
           type="button"
-          className="btn btn-outline-danger border-0 px-2"
+          className="btn btn-outline-danger text-nowrap border-0 px-2"
           disabled={isDisabled}
           disabled={isDisabled}
           onClick={deleteAllButtonClickedHandler}
           onClick={deleteAllButtonClickedHandler}
         >
         >

+ 1 - 1
packages/app/src/components/SearchPage/SearchControl.tsx

@@ -61,7 +61,7 @@ const SearchControl: FC <Props> = React.memo((props: Props) => {
   }, [invokeSearch]);
   }, [invokeSearch]);
 
 
   return (
   return (
-    <div className="position-sticky fixed-top shadow-sm">
+    <div className="position-sticky sticky-top shadow-sm">
       <div className="grw-search-page-nav d-flex py-3 align-items-center">
       <div className="grw-search-page-nav d-flex py-3 align-items-center">
         <div className="flex-grow-1 mx-4">
         <div className="flex-grow-1 mx-4">
           <SearchForm
           <SearchForm

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

@@ -76,7 +76,9 @@ const PageTree: FC = memo(() => {
 
 
       {!isGuestUser && migrationStatus?.migratablePagesCount != null && migrationStatus.migratablePagesCount !== 0 && (
       {!isGuestUser && migrationStatus?.migratablePagesCount != null && migrationStatus.migratablePagesCount !== 0 && (
         <div className="grw-pagetree-footer border-top p-3 w-100">
         <div className="grw-pagetree-footer border-top p-3 w-100">
-          <PrivateLegacyPagesLink />
+          <div className="private-legacy-pages-link px-3 py-2">
+            <PrivateLegacyPagesLink />
+          </div>
         </div>
         </div>
       )}
       )}
     </>
     </>

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

@@ -102,7 +102,7 @@ type ItemCountProps = {
 const ItemCount: FC<ItemCountProps> = (props:ItemCountProps) => {
 const ItemCount: FC<ItemCountProps> = (props:ItemCountProps) => {
   return (
   return (
     <>
     <>
-      <span className="grw-pagetree-count px-0 badge badge-pill badge-light text-muted">
+      <span className="grw-pagetree-count px-0 badge badge-pill badge-light">
         {props.descendantCount}
         {props.descendantCount}
       </span>
       </span>
     </>
     </>
@@ -459,7 +459,7 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
           >
           >
             {/* pass the color property to reactstrap dropdownToggle props. https://6-4-0--reactstrap.netlify.app/components/dropdowns/  */}
             {/* pass the color property to reactstrap dropdownToggle props. https://6-4-0--reactstrap.netlify.app/components/dropdowns/  */}
             <DropdownToggle color="transparent" className="border-0 rounded btn-page-item-control p-0 grw-visible-on-hover mr-1">
             <DropdownToggle color="transparent" className="border-0 rounded btn-page-item-control p-0 grw-visible-on-hover mr-1">
-              <i className="icon-options fa fa-rotate-90 text-muted p-1"></i>
+              <i className="icon-options fa fa-rotate-90 p-1"></i>
             </DropdownToggle>
             </DropdownToggle>
           </PageItemControl>
           </PageItemControl>
           <button
           <button
@@ -467,7 +467,7 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
             className="border-0 rounded btn btn-page-item-control p-0 grw-visible-on-hover"
             className="border-0 rounded btn btn-page-item-control p-0 grw-visible-on-hover"
             onClick={onClickPlusButton}
             onClick={onClickPlusButton}
           >
           >
-            <i className="icon-plus text-muted d-block p-0" />
+            <i className="icon-plus d-block p-0" />
           </button>
           </button>
         </div>
         </div>
       </li>
       </li>

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

@@ -366,16 +366,6 @@ export const getPageSchema = (crowi) => {
     return queryBuilder.query.exec();
     return queryBuilder.query.exec();
   };
   };
 
 
-  pageSchema.statics.findByIdAndViewerToEdit = async function(id, user, includeEmpty = false) {
-    const baseQuery = this.findOne({ _id: id });
-    const queryBuilder = new this.PageQueryBuilder(baseQuery, includeEmpty);
-
-    // add grant conditions
-    await addConditionToFilteringByViewerToEdit(queryBuilder, user);
-
-    return queryBuilder.query.exec();
-  };
-
   // find page by path
   // find page by path
   pageSchema.statics.findByPath = function(path, includeEmpty = false) {
   pageSchema.statics.findByPath = function(path, includeEmpty = false) {
     if (path == null) {
     if (path == null) {

+ 12 - 11
packages/app/src/server/models/page.ts

@@ -46,7 +46,7 @@ export interface PageModel extends Model<PageDocument> {
   [x: string]: any; // for obsolete methods
   [x: string]: any; // for obsolete methods
   createEmptyPagesByPaths(paths: string[], onlyMigratedAsExistingPages?: boolean, publicOnly?: boolean): Promise<void>
   createEmptyPagesByPaths(paths: string[], onlyMigratedAsExistingPages?: boolean, publicOnly?: boolean): Promise<void>
   getParentAndFillAncestors(path: string, user): Promise<PageDocument & { _id: any }>
   getParentAndFillAncestors(path: string, user): Promise<PageDocument & { _id: any }>
-  findByIdsAndViewer(pageIds: string[], user, userGroups?, includeEmpty?: boolean): Promise<PageDocument[]>
+  findByIdsAndViewer(pageIds: ObjectIdLike[], user, userGroups?, includeEmpty?: boolean): Promise<PageDocument[]>
   findByPathAndViewer(path: string | null, user, userGroups?, useFindOne?: boolean, includeEmpty?: boolean): Promise<PageDocument[]>
   findByPathAndViewer(path: string | null, user, userGroups?, useFindOne?: boolean, includeEmpty?: boolean): Promise<PageDocument[]>
   findTargetAndAncestorsByPathOrId(pathOrId: string): Promise<TargetAndAncestorsResult>
   findTargetAndAncestorsByPathOrId(pathOrId: string): Promise<TargetAndAncestorsResult>
   findChildrenByParentPathOrIdAndViewer(parentPathOrId: string, user, userGroups?): Promise<PageDocument[]>
   findChildrenByParentPathOrIdAndViewer(parentPathOrId: string, user, userGroups?): Promise<PageDocument[]>
@@ -372,6 +372,17 @@ class PageQueryBuilder {
     return this;
     return this;
   }
   }
 
 
+  addConditionToExcludeByPageIdsArray(pageIds) {
+    this.query = this.query
+      .and({
+        _id: {
+          $nin: pageIds,
+        },
+      });
+
+    return this;
+  }
+
   populateDataToList(userPublicFields) {
   populateDataToList(userPublicFields) {
     this.query = this.query
     this.query = this.query
       .populate({
       .populate({
@@ -829,16 +840,6 @@ schema.statics.removeLeafEmptyPagesRecursively = async function(pageId: ObjectId
   await this.deleteMany({ _id: { $in: pageIdsToRemove } });
   await this.deleteMany({ _id: { $in: pageIdsToRemove } });
 };
 };
 
 
-schema.statics.findByPageIdsToEdit = async function(ids, user, shouldIncludeEmpty = false) {
-  const builder = new PageQueryBuilder(this.find({ _id: { $in: ids } }), shouldIncludeEmpty);
-
-  await this.addConditionToFilteringByViewerToEdit(builder, user);
-
-  const pages = await builder.query.exec();
-
-  return pages;
-};
-
 schema.statics.normalizeDescendantCountById = async function(pageId) {
 schema.statics.normalizeDescendantCountById = async function(pageId) {
   const children = await this.find({ parent: pageId });
   const children = await this.find({ parent: pageId });
 
 

+ 1 - 1
packages/app/src/server/models/user-group.ts

@@ -101,7 +101,7 @@ schema.statics.findGroupsWithAncestorsRecursively = async function(group, ancest
     return ancestors;
     return ancestors;
   }
   }
 
 
-  ancestors.push(parent);
+  ancestors.unshift(parent);
 
 
   return this.findGroupsWithAncestorsRecursively(parent, ancestors);
   return this.findGroupsWithAncestorsRecursively(parent, ancestors);
 };
 };

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

@@ -497,7 +497,7 @@ module.exports = (crowi) => {
     let renamedPage;
     let renamedPage;
 
 
     try {
     try {
-      page = await Page.findByIdAndViewerToEdit(pageId, req.user, true);
+      page = await Page.findByIdAndViewer(pageId, req.user, null, true);
 
 
       if (page == null) {
       if (page == null) {
         return res.apiv3Err(new ErrorV3(`Page '${pageId}' is not found or forbidden`, 'notfound_or_forbidden'), 401);
         return res.apiv3Err(new ErrorV3(`Page '${pageId}' is not found or forbidden`, 'notfound_or_forbidden'), 401);
@@ -653,7 +653,7 @@ module.exports = (crowi) => {
       return res.apiv3Err(new ErrorV3(`Page exists '${newPagePath})'`, 'already_exists'), 409);
       return res.apiv3Err(new ErrorV3(`Page exists '${newPagePath})'`, 'already_exists'), 409);
     }
     }
 
 
-    const page = await Page.findByIdAndViewerToEdit(pageId, req.user, true);
+    const page = await Page.findByIdAndViewer(pageId, req.user, null, true);
 
 
     const isEmptyAndNotRecursively = page?.isEmpty && !isRecursively;
     const isEmptyAndNotRecursively = page?.isEmpty && !isRecursively;
     if (page == null || isEmptyAndNotRecursively) {
     if (page == null || isEmptyAndNotRecursively) {
@@ -748,7 +748,7 @@ module.exports = (crowi) => {
 
 
     let pagesToDelete;
     let pagesToDelete;
     try {
     try {
-      pagesToDelete = await Page.findByPageIdsToEdit(pageIds, req.user, true);
+      pagesToDelete = await Page.findByIdsAndViewer(pageIds, req.user, null, true);
     }
     }
     catch (err) {
     catch (err) {
       logger.error('Failed to find pages to delete.', err);
       logger.error('Failed to find pages to delete.', err);

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

@@ -165,7 +165,7 @@ module.exports = (crowi) => {
 
 
     try {
     try {
       const userGroup = await UserGroup.findById(groupId);
       const userGroup = await UserGroup.findById(groupId);
-      const ancestorUserGroups = await UserGroup.findGroupsWithAncestorsRecursively(userGroup, []);
+      const ancestorUserGroups = await UserGroup.findGroupsWithAncestorsRecursively(userGroup);
       return res.apiv3({ ancestorUserGroups });
       return res.apiv3({ ancestorUserGroups });
     }
     }
     catch (err) {
     catch (err) {

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

@@ -1177,7 +1177,7 @@ module.exports = function(crowi, app) {
 
 
     const options = {};
     const options = {};
 
 
-    const page = await Page.findByIdAndViewerToEdit(pageId, req.user, true);
+    const page = await Page.findByIdAndViewer(pageId, req.user, null, true);
 
 
     if (page == null) {
     if (page == null) {
       return res.json(ApiResponse.error(`Page '${pageId}' is not found or forbidden`, 'notfound_or_forbidden'));
       return res.json(ApiResponse.error(`Page '${pageId}' is not found or forbidden`, 'notfound_or_forbidden'));

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

@@ -1,5 +1,5 @@
 import mongoose from 'mongoose';
 import mongoose from 'mongoose';
-import { pagePathUtils, pathUtils } from '@growi/core';
+import { pagePathUtils, pathUtils, pageUtils } from '@growi/core';
 import escapeStringRegexp from 'escape-string-regexp';
 import escapeStringRegexp from 'escape-string-regexp';
 
 
 import UserGroup from '~/server/models/user-group';
 import UserGroup from '~/server/models/user-group';
@@ -357,6 +357,8 @@ class PageGrantService {
       throw Error(`The maximum number of pageIds allowed is ${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 shouldCheckDescendants = true;
     const shouldCheckDescendants = true;
     const shouldIncludeNotMigratedPages = true;
     const shouldIncludeNotMigratedPages = true;
 
 
@@ -368,8 +370,12 @@ class PageGrantService {
         path, grant, grantedUsers: grantedUserIds, grantedGroup: grantedGroupId,
         path, grant, grantedUsers: grantedUserIds, grantedGroup: grantedGroupId,
       } = page;
       } = page;
 
 
-      const isNormalized = await this.isGrantNormalized(path, grant, grantedUserIds, grantedGroupId, shouldCheckDescendants, shouldIncludeNotMigratedPages);
-      if (isNormalized) {
+      if (!pageUtils.isPageNormalized(page)) {
+        nonNormalizable.push(page);
+        continue;
+      }
+
+      if (await this.isGrantNormalized(path, grant, grantedUserIds, grantedGroupId, shouldCheckDescendants, shouldIncludeNotMigratedPages)) {
         normalizable.push(page);
         normalizable.push(page);
       }
       }
       else {
       else {

+ 43 - 23
packages/app/src/server/service/page.ts

@@ -107,7 +107,6 @@ class PageCursorsForDescendantsFactory {
 
 
     const builder = new PageQueryBuilder(this.Page.find(), this.shouldIncludeEmpty);
     const builder = new PageQueryBuilder(this.Page.find(), this.shouldIncludeEmpty);
     builder.addConditionToFilteringByParentId(page._id);
     builder.addConditionToFilteringByParentId(page._id);
-    // await this.Page.addConditionToFilteringByViewerToEdit(builder, this.user);
 
 
     const cursor = builder.query.lean().cursor({ batchSize: BULK_REINDEX_SIZE }) as QueryCursor<any>;
     const cursor = builder.query.lean().cursor({ batchSize: BULK_REINDEX_SIZE }) as QueryCursor<any>;
 
 
@@ -305,7 +304,7 @@ class PageService {
     const isRoot = isTopPage(page.path);
     const isRoot = isTopPage(page.path);
     const isPageRestricted = page.grant === Page.GRANT_RESTRICTED;
     const isPageRestricted = page.grant === Page.GRANT_RESTRICTED;
 
 
-    const shouldUseV4Process = !isRoot && !isPageRestricted && (!isV5Compatible || !isPageMigrated || isTrashPage);
+    const shouldUseV4Process = !isRoot && (!isV5Compatible || !isPageMigrated || isTrashPage || isPageRestricted);
 
 
     return shouldUseV4Process;
     return shouldUseV4Process;
   }
   }
@@ -316,7 +315,7 @@ class PageService {
     const isV5Compatible = this.crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
     const isV5Compatible = this.crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
     const isPageRestricted = page.grant === Page.GRANT_RESTRICTED;
     const isPageRestricted = page.grant === Page.GRANT_RESTRICTED;
 
 
-    const shouldUseV4Process = !isPageRestricted && !isV5Compatible;
+    const shouldUseV4Process = !isV5Compatible || isPageRestricted;
 
 
     return shouldUseV4Process;
     return shouldUseV4Process;
   }
   }
@@ -534,18 +533,15 @@ class PageService {
     const newParentPath = pathlib.dirname(toPath);
     const newParentPath = pathlib.dirname(toPath);
 
 
     // local util
     // local util
-    const collectAncestorPathsUntilFromPath = (path: string, paths: string[] = [path]): string[] => {
-      const nextPath = pathlib.dirname(path);
-      if (nextPath === fromPath) {
-        return [...paths, nextPath];
-      }
-
-      paths.push(nextPath);
+    const collectAncestorPathsUntilFromPath = (path: string, paths: string[] = []): string[] => {
+      if (path === fromPath) return paths;
 
 
-      return collectAncestorPathsUntilFromPath(nextPath, paths);
+      const parentPath = pathlib.dirname(path);
+      paths.push(parentPath);
+      return collectAncestorPathsUntilFromPath(parentPath, paths);
     };
     };
 
 
-    const pathsToInsert = collectAncestorPathsUntilFromPath(newParentPath);
+    const pathsToInsert = collectAncestorPathsUntilFromPath(toPath);
     const originalParent = await Page.findById(originalPage.parent);
     const originalParent = await Page.findById(originalPage.parent);
     if (originalParent == null) {
     if (originalParent == null) {
       throw Error('Original parent not found');
       throw Error('Original parent not found');
@@ -2192,7 +2188,7 @@ class PageService {
     const Page = mongoose.model('Page') as unknown as PageModel;
     const Page = mongoose.model('Page') as unknown as PageModel;
 
 
     if (isRecursively) {
     if (isRecursively) {
-      const pages = await Page.findByPageIdsToEdit(pageIds, user, false);
+      const pages = await Page.findByIdsAndViewer(pageIds, user, null);
 
 
       // DO NOT await !!
       // DO NOT await !!
       this.normalizeParentRecursivelyByPages(pages, user);
       this.normalizeParentRecursivelyByPages(pages, user);
@@ -2373,6 +2369,9 @@ class PageService {
       // find pages again to get updated descendantCount
       // find pages again to get updated descendantCount
       // then calculate inc
       // then calculate inc
       const pageAfterUpdatingDescendantCount = await Page.findByIdAndViewer(page._id, user);
       const pageAfterUpdatingDescendantCount = await Page.findByIdAndViewer(page._id, user);
+      if (pageAfterUpdatingDescendantCount == null) {
+        throw Error('Page not found after updating descendantCount');
+      }
 
 
       const exDescendantCount = page.descendantCount;
       const exDescendantCount = page.descendantCount;
       const newDescendantCount = pageAfterUpdatingDescendantCount.descendantCount;
       const newDescendantCount = pageAfterUpdatingDescendantCount.descendantCount;
@@ -2555,15 +2554,13 @@ class PageService {
         },
         },
       ]);
       ]);
 
 
-    // limit pages to get
+    // Limit pages to get
     const total = await Page.countDocuments(filter);
     const total = await Page.countDocuments(filter);
     if (total > PAGES_LIMIT) {
     if (total > PAGES_LIMIT) {
       baseAggregation = baseAggregation.limit(Math.floor(total * 0.3));
       baseAggregation = baseAggregation.limit(Math.floor(total * 0.3));
     }
     }
 
 
     const pagesStream = await baseAggregation.cursor({ batchSize: BATCH_SIZE });
     const pagesStream = await baseAggregation.cursor({ batchSize: BATCH_SIZE });
-
-    // use batch stream
     const batchStream = createBatchStream(BATCH_SIZE);
     const batchStream = createBatchStream(BATCH_SIZE);
 
 
     let countPages = 0;
     let countPages = 0;
@@ -2574,12 +2571,38 @@ class PageService {
       async write(pages, encoding, callback) {
       async write(pages, encoding, callback) {
         const parentPaths = Array.from(new Set<string>(pages.map(p => pathlib.dirname(p.path))));
         const parentPaths = Array.from(new Set<string>(pages.map(p => pathlib.dirname(p.path))));
 
 
-        // Fill parents with empty pages
+        // 1. Remove unnecessary empty pages & reset parent for pages which had had those empty pages
+        const pageIdsToNotDelete = pages.map(p => p._id);
+        const emptyPagePathsToDelete = pages.map(p => p.path);
+
+        const builder1 = new PageQueryBuilder(Page.find({ isEmpty: true }, { _id: 1 }), true);
+        builder1.addConditionToListByPathsArray(emptyPagePathsToDelete);
+        builder1.addConditionToExcludeByPageIdsArray(pageIdsToNotDelete);
+
+        const emptyPagesToDelete = await builder1.query.lean().exec();
+        const resetParentOperations = emptyPagesToDelete.map((p) => {
+          return {
+            updateOne: {
+              filter: {
+                parent: p._id,
+              },
+              update: {
+                parent: null,
+              },
+            },
+          };
+        });
+
+        await Page.bulkWrite(resetParentOperations);
+
+        await Page.removeEmptyPages(pageIdsToNotDelete, emptyPagePathsToDelete);
+
+        // 2. Create lacking parents as empty pages
         await Page.createEmptyPagesByPaths(parentPaths, user, false);
         await Page.createEmptyPagesByPaths(parentPaths, user, false);
 
 
-        // Find parents
-        const builder = new PageQueryBuilder(Page.find({}, { _id: 1, path: 1 }), true);
-        const parents = await builder
+        // 3. Find parents
+        const builder2 = new PageQueryBuilder(Page.find({}, { _id: 1, path: 1 }), true);
+        const parents = await builder2
           .addConditionToListByPathsArray(parentPaths)
           .addConditionToListByPathsArray(parentPaths)
           .query
           .query
           .lean()
           .lean()
@@ -2632,9 +2655,6 @@ class PageService {
           throw err;
           throw err;
         }
         }
 
 
-        // Remove unnecessary empty pages
-        await Page.removeEmptyPages(pages.map(p => p._id), pages.map(p => p.path));
-
         callback();
         callback();
       },
       },
       final(callback) {
       final(callback) {

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

@@ -1,6 +1,7 @@
 .grw-navbar {
 .grw-navbar {
   top: -$grw-navbar-height !important;
   top: -$grw-navbar-height !important;
 
 
+  z-index: $grw-navbar-z-index !important;
   max-height: $grw-navbar-height + $grw-navbar-border-width;
   max-height: $grw-navbar-height + $grw-navbar-border-width;
   border-top: 0;
   border-top: 0;
   border-right: 0;
   border-right: 0;

+ 3 - 0
packages/app/src/styles/_variables.scss

@@ -15,6 +15,9 @@ $font-family-monospace-not-strictly: Monaco, Menlo, Consolas, 'Courier New', Mei
 //== Layout
 //== Layout
 $grw-navbar-height: 52px;
 $grw-navbar-height: 52px;
 $grw-navbar-border-width: 3.3333px;
 $grw-navbar-border-width: 3.3333px;
+// slightly larger than $zindex-sticky
+// https://getbootstrap.jp/docs/4.5/layout/overview/#z-index
+$grw-navbar-z-index: 1025;
 
 
 $grw-subnav-min-height: 95px;
 $grw-subnav-min-height: 95px;
 $grw-subnav-min-height-md: 115px;
 $grw-subnav-min-height-md: 115px;

+ 15 - 1
packages/app/src/styles/theme/_apply-colors-dark.scss

@@ -38,8 +38,9 @@ $border-color: $border-color-global;
   */
   */
 input.form-control,
 input.form-control,
 select.form-control,
 select.form-control,
+select.custom-select,
 textarea.form-control {
 textarea.form-control {
-  color: lighten($color-global, 30%);
+  color: $color-global;
   background-color: darken($bgcolor-global, 5%);
   background-color: darken($bgcolor-global, 5%);
   border-color: $border-color-global;
   border-color: $border-color-global;
   &:focus {
   &:focus {
@@ -69,6 +70,10 @@ textarea.form-control {
   border-color: $border-color-global;
   border-color: $border-color-global;
 }
 }
 
 
+label.custom-control-label::before {
+  background-color: darken($bgcolor-global, 5%);
+}
+
 /*
 /*
  * Dropdown
  * Dropdown
  */
  */
@@ -279,6 +284,15 @@ ul.pagination {
       lighten($bgcolor-list-hover, 5%),
       lighten($bgcolor-list-hover, 5%),
       $gray-500
       $gray-500
     );
     );
+    .grw-pagetree-count {
+      color: $gray-400;
+      background: $gray-700;
+    }
+  }
+  .private-legacy-pages-link {
+    &:hover {
+      background: $bgcolor-list-hover;
+    }
   }
   }
 }
 }
 
 

+ 9 - 0
packages/app/src/styles/theme/_apply-colors-light.scss

@@ -179,6 +179,15 @@ $border-color: $border-color-global;
       $bgcolor-list-active,
       $bgcolor-list-active,
       $gray-400
       $gray-400
     );
     );
+    .grw-pagetree-count {
+      color: $gray-500;
+      background: $gray-200;
+    }
+  }
+  .private-legacy-pages-link {
+    &:hover {
+      background: $bgcolor-list-hover;
+    }
   }
   }
 }
 }
 
 

+ 1 - 0
packages/app/src/styles/theme/_apply-colors.scss

@@ -712,6 +712,7 @@ mark.rbt-highlight-text {
 
 
 // Page Management Dropdown icon
 // Page Management Dropdown icon
 .btn-page-item-control {
 .btn-page-item-control {
+  color: $gray-500;
   &:hover,
   &:hover,
   &:focus {
   &:focus {
     background-color: rgba($color-link, 0.15);
     background-color: rgba($color-link, 0.15);

+ 1 - 1
packages/app/src/styles/theme/default.scss

@@ -72,7 +72,7 @@ html[light] {
   $bgcolor-resize-button-hover: lighten($bgcolor-resize-button, 5%);
   $bgcolor-resize-button-hover: lighten($bgcolor-resize-button, 5%);
   // Sidebar contents
   // Sidebar contents
   $color-sidebar-context: $color-global;
   $color-sidebar-context: $color-global;
-  $bgcolor-sidebar-context: $gray-100;
+  $bgcolor-sidebar-context: lighten($primary, 77%);
   // Sidebar list group
   // Sidebar list group
   $bgcolor-sidebar-list-group: $gray-50; // optional
   $bgcolor-sidebar-list-group: $gray-50; // optional
 
 

+ 1 - 4
packages/app/src/styles/theme/mixins/_list-group.scss

@@ -36,10 +36,7 @@
     border-color: $border-color-global;
     border-color: $border-color-global;
 
 
     &.grw-pagetree-is-target {
     &.grw-pagetree-is-target {
-      background: $bgcolor-active;
-    }
-    .grw-pagetree-count {
-      background: $bgcolor;
+      background: $bgcolor-hover;
     }
     }
     .grw-pagetree-button {
     .grw-pagetree-button {
       &:not(:hover) {
       &:not(:hover) {

+ 31 - 0
packages/app/test/cypress/integration/2-basic-features/switch-sidebar-contents.spec.ts

@@ -0,0 +1,31 @@
+context('Access to page', () => {
+  const ssPrefix = 'switch-sidebar-content';
+
+  let connectSid: string | undefined;
+
+  before(() => {
+    // login
+    cy.fixture("user-admin.json").then(user => {
+      cy.login(user.username, user.password);
+    });
+    cy.getCookie('connect.sid').then(cookie => {
+      connectSid = cookie?.value;
+    });
+  });
+
+  beforeEach(() => {
+    if (connectSid != null) {
+      cy.setCookie('connect.sid', connectSid);
+    }
+  });
+
+  it('PageTree is successfully shown', () => {
+    cy.visit('/');
+    cy.getByTestid('grw-sidebar-nav-primary-page-tree').click();
+    cy.screenshot(`${ssPrefix}-pagetree-before-load`, { capture: 'viewport' });
+    // eslint-disable-next-line cypress/no-unnecessary-waiting
+    cy.wait(1500);
+    cy.screenshot(`${ssPrefix}-pagetree-after-load`, { capture: 'viewport' });
+  });
+
+});

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

@@ -6,64 +6,245 @@ describe('V5 page migration', () => {
   let crowi;
   let crowi;
   let Page;
   let Page;
   let User;
   let User;
+  let UserGroup;
+  let UserGroupRelation;
 
 
   let testUser1;
   let testUser1;
 
 
+  let rootPage;
+
+  const groupIdIsolate = new mongoose.Types.ObjectId();
+  const groupIdA = new mongoose.Types.ObjectId();
+  const groupIdB = new mongoose.Types.ObjectId();
+  const groupIdC = new mongoose.Types.ObjectId();
+
+  const pageId1 = new mongoose.Types.ObjectId();
+  const pageId2 = new mongoose.Types.ObjectId();
+  const pageId3 = new mongoose.Types.ObjectId();
+  const pageId4 = new mongoose.Types.ObjectId();
+  const pageId5 = new mongoose.Types.ObjectId();
+  const pageId6 = new mongoose.Types.ObjectId();
+  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();
+
   beforeAll(async() => {
   beforeAll(async() => {
     jest.restoreAllMocks();
     jest.restoreAllMocks();
 
 
     crowi = await getInstance();
     crowi = await getInstance();
     Page = mongoose.model('Page');
     Page = mongoose.model('Page');
     User = mongoose.model('User');
     User = mongoose.model('User');
+    UserGroup = mongoose.model('UserGroup');
+    UserGroupRelation = mongoose.model('UserGroupRelation');
 
 
     await crowi.configManager.updateConfigsInTheSameNamespace('crowi', { 'app:isV5Compatible': true });
     await crowi.configManager.updateConfigsInTheSameNamespace('crowi', { 'app:isV5Compatible': true });
 
 
     await User.insertMany([{ name: 'testUser1', username: 'testUser1', email: 'testUser1@example.com' }]);
     await User.insertMany([{ name: 'testUser1', username: 'testUser1', email: 'testUser1@example.com' }]);
     testUser1 = await User.findOne({ username: 'testUser1' });
     testUser1 = await User.findOne({ username: 'testUser1' });
+    rootPage = await Page.findOne({ path: '/' });
+
+    await UserGroup.insertMany([
+      {
+        _id: groupIdIsolate,
+        name: 'groupIsolate',
+      },
+      {
+        _id: groupIdA,
+        name: 'groupA',
+      },
+      {
+        _id: groupIdB,
+        name: 'groupB',
+        parent: groupIdA,
+      },
+      {
+        _id: groupIdC,
+        name: 'groupC',
+        parent: groupIdB,
+      },
+    ]);
+
+    await UserGroupRelation.insertMany([
+      {
+        relatedGroup: groupIdIsolate,
+        relatedUser: testUser1._id,
+      },
+      {
+        relatedGroup: groupIdA,
+        relatedUser: testUser1._id,
+      },
+      {
+        relatedGroup: groupIdB,
+        relatedUser: testUser1._id,
+      },
+      {
+        relatedGroup: groupIdC,
+        relatedUser: testUser1._id,
+      },
+    ]);
+
+    await Page.insertMany([
+      {
+        path: '/private1',
+        grant: Page.GRANT_OWNER,
+        creator: testUser1,
+        lastUpdateUser: testUser1,
+        grantedUsers: [testUser1._id],
+      },
+      {
+        path: '/dummyParent/private1',
+        grant: Page.GRANT_OWNER,
+        creator: testUser1,
+        lastUpdateUser: testUser1,
+        grantedUsers: [testUser1._id],
+      },
+      {
+        path: '/dummyParent/private1/private2',
+        grant: Page.GRANT_OWNER,
+        creator: testUser1,
+        lastUpdateUser: testUser1,
+        grantedUsers: [testUser1._id],
+      },
+      {
+        path: '/dummyParent/private1/private3',
+        grant: Page.GRANT_OWNER,
+        creator: testUser1,
+        lastUpdateUser: testUser1,
+        grantedUsers: [testUser1._id],
+      },
+      {
+        _id: pageId1,
+        path: '/normalize_1',
+        parent: rootPage._id,
+        grant: Page.GRANT_PUBLIC,
+        isEmpty: true,
+      },
+      {
+        _id: pageId2,
+        path: '/normalize_1/normalize_2',
+        parent: pageId1,
+        grant: Page.GRANT_USER_GROUP,
+        grantedGroup: groupIdB,
+        grantedUsers: [testUser1._id],
+      },
+      {
+        _id: pageId3,
+        path: '/normalize_1',
+        grant: Page.GRANT_USER_GROUP,
+        grantedGroup: groupIdA,
+        grantedUsers: [testUser1._id],
+      },
+      {
+        _id: pageId4,
+        path: '/normalize_4',
+        parent: rootPage._id,
+        grant: Page.GRANT_PUBLIC,
+        isEmpty: true,
+      },
+      {
+        _id: pageId5,
+        path: '/normalize_4/normalize_5',
+        parent: pageId4,
+        grant: Page.GRANT_USER_GROUP,
+        grantedGroup: groupIdA,
+        grantedUsers: [testUser1._id],
+      },
+      {
+        _id: pageId6,
+        path: '/normalize_4',
+        grant: Page.GRANT_USER_GROUP,
+        grantedGroup: groupIdIsolate,
+        grantedUsers: [testUser1._id],
+      },
+      {
+        path: '/normalize_7/normalize_8_gA',
+        grant: Page.GRANT_USER_GROUP,
+        creator: testUser1,
+        grantedGroup: groupIdA,
+        grantedUsers: [testUser1._id],
+      },
+      {
+        path: '/normalize_7/normalize_8_gA/normalize_9_gB',
+        grant: Page.GRANT_USER_GROUP,
+        creator: testUser1,
+        grantedGroup: groupIdB,
+        grantedUsers: [testUser1._id],
+      },
+      {
+        path: '/normalize_7/normalize_8_gC',
+        grant: Page.GRANT_USER_GROUP,
+        grantedGroup: groupIdC,
+        grantedUsers: [testUser1._id],
+      },
+      {
+        _id: pageId7,
+        path: '/normalize_10',
+        grant: Page.GRANT_PUBLIC,
+        isEmpty: true,
+        parent: rootPage._id,
+        descendantCount: 3,
+      },
+      {
+        _id: pageId8,
+        path: '/normalize_10/normalize_11_gA',
+        isEmpty: true,
+        parent: pageId7,
+        descendantCount: 1,
+      },
+      {
+        _id: pageId9, // not v5
+        path: '/normalize_10/normalize_11_gA',
+        grant: Page.GRANT_USER_GROUP,
+        grantedGroup: groupIdA,
+        grantedUsers: [testUser1._id],
+      },
+      {
+        _id: pageId10,
+        path: '/normalize_10/normalize_11_gA/normalize_11_gB',
+        grant: Page.GRANT_USER_GROUP,
+        grantedGroup: groupIdB,
+        grantedUsers: [testUser1._id],
+        parent: pageId8,
+        descendantCount: 0,
+      },
+      {
+        _id: pageId11,
+        path: '/normalize_10/normalize_12_gC',
+        grant: Page.GRANT_USER_GROUP,
+        grantedGroup: groupIdC,
+        grantedUsers: [testUser1._id],
+        parent: pageId7,
+        descendantCount: 0,
+      },
+
+    ]);
+
   });
   });
 
 
+  // https://github.com/jest-community/eslint-plugin-jest/blob/v24.3.5/docs/rules/expect-expect.md#assertfunctionnames
+  // pass unless the data is one of [false, 0, '', null, undefined, NaN]
+  const expectAllToBeTruthy = (dataList) => {
+    dataList.forEach((data, i) => {
+      if (data == null) { console.log(`index: ${i}`) }
+      expect(data).toBeTruthy();
+    });
+  };
 
 
   describe('normalizeParentRecursivelyByPages()', () => {
   describe('normalizeParentRecursivelyByPages()', () => {
+
+    const normalizeParentRecursivelyByPages = async(pages, user) => {
+      return crowi.pageService.normalizeParentRecursivelyByPages(pages, user);
+    };
+
     test('should migrate all pages specified by pageIds', async() => {
     test('should migrate all pages specified by pageIds', async() => {
       jest.restoreAllMocks();
       jest.restoreAllMocks();
 
 
-      // initialize pages for test
-      await Page.insertMany([
-        {
-          path: '/private1',
-          grant: Page.GRANT_OWNER,
-          creator: testUser1,
-          lastUpdateUser: testUser1,
-          grantedUsers: [testUser1._id],
-        },
-        {
-          path: '/dummyParent/private1',
-          grant: Page.GRANT_OWNER,
-          creator: testUser1,
-          lastUpdateUser: testUser1,
-          grantedUsers: [testUser1._id],
-        },
-        {
-          path: '/dummyParent/private1/private2',
-          grant: Page.GRANT_OWNER,
-          creator: testUser1,
-          lastUpdateUser: testUser1,
-          grantedUsers: [testUser1._id],
-        },
-        {
-          path: '/dummyParent/private1/private3',
-          grant: Page.GRANT_OWNER,
-          creator: testUser1,
-          lastUpdateUser: testUser1,
-          grantedUsers: [testUser1._id],
-        },
-      ]);
-
       const pagesToRun = await Page.find({ path: { $in: ['/private1', '/dummyParent/private1'] } });
       const pagesToRun = await Page.find({ path: { $in: ['/private1', '/dummyParent/private1'] } });
 
 
       // migrate
       // migrate
-      await crowi.pageService.normalizeParentRecursivelyByPages(pagesToRun, testUser1);
-
+      await normalizeParentRecursivelyByPages(pagesToRun, testUser1);
       const migratedPages = await Page.find({
       const migratedPages = await Page.find({
         path: {
         path: {
           $in: ['/private1', '/dummyParent', '/dummyParent/private1', '/dummyParent/private1/private2', '/dummyParent/private1/private3'],
           $in: ['/private1', '/dummyParent', '/dummyParent/private1', '/dummyParent/private1/private2', '/dummyParent/private1/private3'],
@@ -76,6 +257,58 @@ describe('V5 page migration', () => {
       expect(migratedPagePaths.sort()).toStrictEqual(expected.sort());
       expect(migratedPagePaths.sort()).toStrictEqual(expected.sort());
     });
     });
 
 
+    test('should change all v4 pages with usergroup to v5 compatible and create new parent page', async() => {
+      const page8 = await Page.findOne({ path: '/normalize_7/normalize_8_gA' });
+      const page9 = await Page.findOne({ path: '/normalize_7/normalize_8_gA/normalize_9_gB' });
+      const page10 = await Page.findOne({ path: '/normalize_7/normalize_8_gC' });
+      const page11 = await Page.findOne({ path: '/normalize_7' });
+      expectAllToBeTruthy([page8, page9, page10]);
+      expect(page11).toBeNull();
+      await normalizeParentRecursivelyByPages([page8, page9, page10], testUser1);
+
+      // AM => After Migration
+      const page7 = await Page.findOne({ path: '/normalize_7' });
+      const page8AM = await Page.findOne({ path: '/normalize_7/normalize_8_gA' });
+      const page9AM = await Page.findOne({ path: '/normalize_7/normalize_8_gA/normalize_9_gB' });
+      const page10AM = await Page.findOne({ path: '/normalize_7/normalize_8_gC' });
+      expectAllToBeTruthy([page7, page8AM, page9AM, page10AM]);
+
+      expect(page7.isEmpty).toBe(true);
+
+      expect(page7.parent).toStrictEqual(rootPage._id);
+      expect(page8AM.parent).toStrictEqual(page7._id);
+      expect(page9AM.parent).toStrictEqual(page8AM._id);
+      expect(page10AM.parent).toStrictEqual(page7._id);
+    });
+
+    test("should replace empty page with same path with new non-empty page and update all related children's parent", async() => {
+      const page1 = await Page.findOne({ path: '/normalize_10', isEmpty: true, parent: { $ne: null } });
+      const page2 = await Page.findOne({
+        path: '/normalize_10/normalize_11_gA', _id: pageId8, isEmpty: true, parent: { $ne: null },
+      });
+      const page3 = await Page.findOne({ path: '/normalize_10/normalize_11_gA', _id: pageId9, parent: null }); // not v5
+      const page4 = await Page.findOne({ path: '/normalize_10/normalize_11_gA/normalize_11_gB', parent: { $ne: null } });
+      const page5 = await Page.findOne({ path: '/normalize_10/normalize_12_gC', parent: { $ne: null } });
+      expectAllToBeTruthy([page1, page2, page3, page4, page5]);
+
+      await normalizeParentRecursivelyByPages([page3], testUser1);
+
+      // AM => After Migration
+      const page1AM = await Page.findOne({ path: '/normalize_10' });
+      const page2AM = await Page.findOne({ path: '/normalize_10/normalize_11_gA', _id: pageId8 });
+      const page3AM = await Page.findOne({ path: '/normalize_10/normalize_11_gA', _id: pageId9 });
+      const page4AM = await Page.findOne({ path: '/normalize_10/normalize_11_gA/normalize_11_gB' });
+      const page5AM = await Page.findOne({ path: '/normalize_10/normalize_12_gC' });
+      expectAllToBeTruthy([page1AM, page3AM, page4AM, page5AM]);
+      expect(page2AM).toBeNull();
+
+      expect(page1AM.isEmpty).toBeTruthy();
+      expect(page3AM.parent).toStrictEqual(page1AM._id);
+      expect(page4AM.parent).toStrictEqual(page3AM._id);
+      expect(page5AM.parent).toStrictEqual(page1AM._id);
+
+      expect(page3AM.isEmpty).toBe(false);
+    });
   });
   });
 
 
   describe('normalizeAllPublicPages()', () => {
   describe('normalizeAllPublicPages()', () => {
@@ -174,6 +407,57 @@ describe('V5 page migration', () => {
     });
     });
   });
   });
 
 
+  describe('normalizeParentByPageId()', () => {
+    const normalizeParentByPageId = async(page, user) => {
+      return crowi.pageService.normalizeParentByPageId(page, user);
+    };
+    test('it should normalize not v5 page with usergroup that has parent group', async() => {
+      const page1 = await Page.findOne({ _id: pageId1, path: '/normalize_1', isEmpty: true });
+      const page2 = await Page.findOne({ _id: pageId2, path: '/normalize_1/normalize_2', parent: page1._id });
+      const page3 = await Page.findOne({ _id: pageId3, path: '/normalize_1' }); // NOT v5
+      expectAllToBeTruthy([page1, page2, page3]);
+
+      await normalizeParentByPageId(page3, testUser1);
+
+      // AM => After Migration
+      const page1AM = await Page.findOne({ _id: pageId1, path: '/normalize_1', isEmpty: true });
+      const page2AM = await Page.findOne({ _id: pageId2, path: '/normalize_1/normalize_2' });
+      const page3AM = await Page.findOne({ _id: pageId3, path: '/normalize_1' }); // v5 compatible
+      expectAllToBeTruthy([page2AM, page3AM]);
+      expect(page1AM).toBeNull();
+
+      expect(page2AM.parent).toStrictEqual(page3AM._id);
+      expect(page3AM.parent).toStrictEqual(rootPage._id);
+    });
+
+    test('should throw error if a page with isolated group becomes the parent of other page with different gourp after normalizing', async() => {
+      const page4 = await Page.findOne({ _id: pageId4, path: '/normalize_4', isEmpty: true });
+      const page5 = await Page.findOne({ _id: pageId5, path: '/normalize_4/normalize_5', parent: page4._id });
+      const page6 = await Page.findOne({ _id: pageId6, path: '/normalize_4' }); // NOT v5
+
+      expectAllToBeTruthy([page4, page5, page6]);
+
+      let isThrown;
+      try {
+        await normalizeParentByPageId(page6, testUser1);
+      }
+      catch (err) {
+        isThrown = true;
+      }
+
+      // AM => After Migration
+      const page4AM = await Page.findOne({ _id: pageId4, path: '/normalize_4', isEmpty: true });
+      const page5AM = await Page.findOne({ _id: pageId5, path: '/normalize_4/normalize_5', parent: page4._id });
+      const page6AM = await Page.findOne({ _id: pageId6, path: '/normalize_4' }); // NOT v5
+      expectAllToBeTruthy([page4AM, page5AM, page6AM]);
+
+      expect(isThrown).toBe(true);
+      expect(page4AM).toStrictEqual(page4);
+      expect(page5AM).toStrictEqual(page5);
+      expect(page6AM).toStrictEqual(page6);
+    });
+  });
+
   test('replace private parents with empty pages', async() => {
   test('replace private parents with empty pages', async() => {
     const replacedPathPages = await Page.find({ path: '/publicA/privateB' }); // ex-private page
     const replacedPathPages = await Page.find({ path: '/publicA/privateB' }); // ex-private page
 
 

+ 201 - 0
packages/app/test/integration/service/v5.non-public-page.test.ts

@@ -0,0 +1,201 @@
+/* eslint-disable no-unused-vars */
+import { advanceTo } from 'jest-date-mock';
+
+import mongoose from 'mongoose';
+
+import { getInstance } from '../setup-crowi';
+
+describe('PageService page operations with non-public pages', () => {
+
+  let dummyUser1;
+  let dummyUser2;
+  let npDummyUser1;
+  let npDummyUser2;
+  let npDummyUser3;
+
+  let crowi;
+  let Page;
+  let Revision;
+  let User;
+  let UserGroup;
+  let UserGroupRelation;
+  let Tag;
+  let PageTagRelation;
+  let Bookmark;
+  let Comment;
+  let ShareLink;
+  let PageRedirect;
+  let xssSpy;
+
+  let rootPage;
+
+  // pass unless the data is one of [false, 0, '', null, undefined, NaN]
+  const expectAllToBeTruthy = (dataList) => {
+    dataList.forEach((data, i) => {
+      if (data == null) { console.log(`index: ${i}`) }
+      expect(data).toBeTruthy();
+    });
+  };
+
+  beforeAll(async() => {
+    crowi = await getInstance();
+    await crowi.configManager.updateConfigsInTheSameNamespace('crowi', { 'app:isV5Compatible': true });
+
+    User = mongoose.model('User');
+    UserGroup = mongoose.model('UserGroup');
+    UserGroupRelation = mongoose.model('UserGroupRelation');
+    Page = mongoose.model('Page');
+    Revision = mongoose.model('Revision');
+    Tag = mongoose.model('Tag');
+    PageTagRelation = mongoose.model('PageTagRelation');
+    Bookmark = mongoose.model('Bookmark');
+    Comment = mongoose.model('Comment');
+    ShareLink = mongoose.model('ShareLink');
+    PageRedirect = mongoose.model('PageRedirect');
+
+    /*
+     * Common
+     */
+
+    dummyUser1 = await User.findOne({ username: 'v5DummyUser1' });
+    dummyUser2 = await User.findOne({ username: 'v5DummyUser2' });
+
+    const npUserId1 = new mongoose.Types.ObjectId();
+    const npUserId2 = new mongoose.Types.ObjectId();
+    const npUserId3 = new mongoose.Types.ObjectId();
+    await User.insertMany([
+      {
+        _id: npUserId1, name: 'npUser1', username: 'npUser1', email: 'npUser1@example.com',
+      },
+      {
+        _id: npUserId2, name: 'npUser2', username: 'npUser2', email: 'npUser2@example.com',
+      },
+      {
+        _id: npUserId3, name: 'npUser3', username: 'npUser3', email: 'npUser3@example.com',
+      },
+    ]);
+
+    const groupIdIsolate = new mongoose.Types.ObjectId();
+    const groupIdA = new mongoose.Types.ObjectId();
+    const groupIdB = new mongoose.Types.ObjectId();
+    const groupIdC = new mongoose.Types.ObjectId();
+    await UserGroup.insertMany([
+      {
+        _id: groupIdIsolate,
+        name: 'np_groupIsolate',
+      },
+      {
+        _id: groupIdA,
+        name: 'np_groupA',
+      },
+      {
+        _id: groupIdB,
+        name: 'np_groupB',
+        parent: groupIdA,
+      },
+      {
+        _id: groupIdC,
+        name: 'np_groupC',
+        parent: groupIdB,
+      },
+    ]);
+
+    await UserGroupRelation.insertMany([
+      {
+        relatedGroup: groupIdIsolate,
+        relatedUser: npUserId1,
+        createdAt: new Date(),
+      },
+      {
+        relatedGroup: groupIdIsolate,
+        relatedUser: npUserId2,
+        createdAt: new Date(),
+      },
+      {
+        relatedGroup: groupIdA,
+        relatedUser: npUserId1,
+        createdAt: new Date(),
+      },
+      {
+        relatedGroup: groupIdA,
+        relatedUser: npUserId2,
+        createdAt: new Date(),
+      },
+      {
+        relatedGroup: groupIdA,
+        relatedUser: npUserId3,
+        createdAt: new Date(),
+      },
+      {
+        relatedGroup: groupIdB,
+        relatedUser: npUserId2,
+        createdAt: new Date(),
+      },
+      {
+        relatedGroup: groupIdB,
+        relatedUser: npUserId3,
+        createdAt: new Date(),
+      },
+      {
+        relatedGroup: groupIdC,
+        relatedUser: npUserId3,
+        createdAt: new Date(),
+      },
+    ]);
+
+    xssSpy = jest.spyOn(crowi.xss, 'process').mockImplementation(path => path);
+
+    rootPage = await Page.findOne({ path: '/' });
+    if (rootPage == null) {
+      const pages = await Page.insertMany([{ path: '/', grant: Page.GRANT_PUBLIC }]);
+      rootPage = pages[0];
+    }
+
+    /*
+     * Rename
+     */
+
+    /*
+     * Duplicate
+     */
+
+    /**
+     * Delete
+     */
+
+    /**
+     * Delete completely
+     */
+
+    /**
+     * Revert
+     */
+  });
+
+  describe('Rename', () => {
+    test('dummy test to avoid test failure', async() => {
+      // write test code
+      expect(true).toBe(true);
+    });
+  });
+  describe('Duplicate', () => {
+    // test('', async() => {
+    //   // write test code
+    // });
+  });
+  describe('Delete', () => {
+    // test('', async() => {
+    //   // write test code
+    // });
+  });
+  describe('Delete completely', () => {
+    // test('', async() => {
+    //   // write test code
+    // });
+  });
+  describe('revert', () => {
+    // test('', async() => {
+    //   // write test code
+    // });
+  });
+});

+ 1 - 10
packages/app/test/integration/service/v5.page.test.ts → packages/app/test/integration/service/v5.public-page.test.ts

@@ -24,8 +24,6 @@ describe('PageService page operations with only public pages', () => {
 
 
   let rootPage;
   let rootPage;
 
 
-  /* eslint jest/expect-expect: ["error", { "assertFunctionNames": ["expectAllToBeTruthy"] }] */
-  // https://github.com/jest-community/eslint-plugin-jest/blob/v24.3.5/docs/rules/expect-expect.md#assertfunctionnames
 
 
   // pass unless the data is one of [false, 0, '', null, undefined, NaN]
   // pass unless the data is one of [false, 0, '', null, undefined, NaN]
   const expectAllToBeTruthy = (dataList) => {
   const expectAllToBeTruthy = (dataList) => {
@@ -901,7 +899,7 @@ describe('PageService page operations with only public pages', () => {
   describe('Rename', () => {
   describe('Rename', () => {
 
 
     const renamePage = async(page, newPagePath, user, options) => {
     const renamePage = async(page, newPagePath, user, options) => {
-    // mock return value
+      // mock return value
       const mockedRenameSubOperation = jest.spyOn(crowi.pageService, 'renameSubOperation').mockReturnValue(null);
       const mockedRenameSubOperation = jest.spyOn(crowi.pageService, 'renameSubOperation').mockReturnValue(null);
       const mockedCreateAndSendNotifications = jest.spyOn(crowi.pageService, 'createAndSendNotifications').mockReturnValue(null);
       const mockedCreateAndSendNotifications = jest.spyOn(crowi.pageService, 'createAndSendNotifications').mockReturnValue(null);
       const renamedPage = await crowi.pageService.renamePage(page, newPagePath, user, options);
       const renamedPage = await crowi.pageService.renamePage(page, newPagePath, user, options);
@@ -1166,7 +1164,6 @@ describe('PageService page operations with only public pages', () => {
       expect(renamedPageGrandchild.isEmpty).toBe(false);
       expect(renamedPageGrandchild.isEmpty).toBe(false);
     });
     });
   });
   });
-
   describe('Duplicate', () => {
   describe('Duplicate', () => {
 
 
     const duplicate = async(page, newPagePath, user, isRecursively) => {
     const duplicate = async(page, newPagePath, user, isRecursively) => {
@@ -1444,7 +1441,6 @@ describe('PageService page operations with only public pages', () => {
       expect(deletedTagRelation2.isPageTrashed).toBe(true);
       expect(deletedTagRelation2.isPageTrashed).toBe(true);
     });
     });
   });
   });
-
   describe('Delete completely', () => {
   describe('Delete completely', () => {
     const deleteCompletely = async(page, user, options = {}, isRecursively = false, preventEmitting = false) => {
     const deleteCompletely = async(page, user, options = {}, isRecursively = false, preventEmitting = false) => {
       const mockedDeleteCompletelyRecursivelyMainOperation = jest.spyOn(crowi.pageService, 'deleteCompletelyRecursivelyMainOperation').mockReturnValue(null);
       const mockedDeleteCompletelyRecursivelyMainOperation = jest.spyOn(crowi.pageService, 'deleteCompletelyRecursivelyMainOperation').mockReturnValue(null);
@@ -1562,8 +1558,6 @@ describe('PageService page operations with only public pages', () => {
 
 
     });
     });
   });
   });
-
-
   describe('revert', () => {
   describe('revert', () => {
     const revertDeletedPage = async(page, user, options = {}, isRecursively = false) => {
     const revertDeletedPage = async(page, user, options = {}, isRecursively = false) => {
       // mock return value
       // mock return value
@@ -1624,6 +1618,3 @@ describe('PageService page operations with only public pages', () => {
   });
   });
 
 
 });
 });
-describe('PageService page operations with non-public pages', () => {
-  // TODO: write test code
-});

+ 2 - 0
packages/core/src/index.js

@@ -1,6 +1,7 @@
 import * as _pathUtils from './utils/path-utils';
 import * as _pathUtils from './utils/path-utils';
 import * as _envUtils from './utils/env-utils';
 import * as _envUtils from './utils/env-utils';
 import * as _pagePathUtils from './utils/page-path-utils';
 import * as _pagePathUtils from './utils/page-path-utils';
+import * as _pageUtils from './utils/page-utils';
 import * as _templateChecker from './utils/template-checker';
 import * as _templateChecker from './utils/template-checker';
 import * as _customTagUtils from './plugin/util/custom-tag-utils';
 import * as _customTagUtils from './plugin/util/custom-tag-utils';
 
 
@@ -8,6 +9,7 @@ import * as _customTagUtils from './plugin/util/custom-tag-utils';
 export const pathUtils = _pathUtils;
 export const pathUtils = _pathUtils;
 export const envUtils = _envUtils;
 export const envUtils = _envUtils;
 export const pagePathUtils = _pagePathUtils;
 export const pagePathUtils = _pagePathUtils;
+export const pageUtils = _pageUtils;
 export const templateChecker = _templateChecker;
 export const templateChecker = _templateChecker;
 export const customTagUtils = _customTagUtils;
 export const customTagUtils = _customTagUtils;
 
 

+ 58 - 0
packages/core/src/utils/page-utils.ts

@@ -0,0 +1,58 @@
+import { isTopPage } from './page-path-utils';
+
+const GRANT_PUBLIC = 1;
+const GRANT_RESTRICTED = 2;
+const GRANT_SPECIFIED = 3; // DEPRECATED
+const GRANT_OWNER = 4;
+const GRANT_USER_GROUP = 5;
+const PAGE_GRANT_ERROR = 1;
+const STATUS_PUBLISHED = 'published';
+const STATUS_DELETED = 'deleted';
+
+/**
+ * Returns true if the page is on tree including the top page.
+ * @param page Page
+ * @returns boolean
+ */
+// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+export const isOnTree = (page): boolean => {
+  const { path, parent } = page;
+
+  if (isTopPage(path)) {
+    return true;
+  }
+
+  if (parent != null) {
+    return true;
+  }
+
+  return false;
+};
+
+/**
+ * Returns true if the page meet the condition below.
+ *   - The page is on tree (has parent or the top page)
+ *   - The page's grant is GRANT_RESTRICTED or GRANT_SPECIFIED
+ *   - The page's status is STATUS_DELETED
+ * This does not check grantedUser or grantedGroup.
+ * @param page PageDocument
+ * @returns boolean
+ */
+// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+export const isPageNormalized = (page): boolean => {
+  const { grant, status } = page;
+
+  if (grant === GRANT_RESTRICTED || grant === GRANT_SPECIFIED) {
+    return true;
+  }
+
+  if (status === STATUS_DELETED) {
+    return true;
+  }
+
+  if (isOnTree(page)) {
+    return true;
+  }
+
+  return true;
+};